Merge branch 'main' into feature/ldap-gateway-support

This commit is contained in:
Victor Santos
2025-11-12 10:07:30 -03:00
313 changed files with 14727 additions and 1448 deletions
+3
View File
@@ -52,3 +52,6 @@ docs/integrations/app-connections/railway.mdx:generic-api-key:156
.github/workflows/validate-db-schemas.yml:generic-api-key:21 .github/workflows/validate-db-schemas.yml:generic-api-key:21
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
docs/integrations/app-connections/redis.mdx:generic-api-key:80 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
+8 -6
View File
@@ -48,7 +48,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus
### Secrets Management: ### Secrets Management:
- **[Dashboard](https://infisical.com/docs/documentation/platform/project)**: Manage secrets across projects and environments (e.g. development, production, etc.) through a user-friendly interface. - **[Dashboard](https://infisical.com/docs/documentation/platform/project)**: Manage secrets across projects and environments (e.g. development, production, etc.) through a user-friendly interface.
- **[Native Integrations](https://infisical.com/docs/integrations/overview)**: Sync secrets to platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and use tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more. - **[Secret Syncs](https://infisical.com/docs/integrations/secret-syncs/overview)**: Sync secrets to platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and use tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more.
- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)**: Keep track of every secret and project state; roll back when needed. - **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)**: Keep track of every secret and project state; roll back when needed.
- **[Secret Rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview)**: Rotate secrets at regular intervals for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/secret-rotation/postgres-credentials), [MySQL](https://infisical.com/docs/documentation/platform/secret-rotation/mysql), [AWS IAM](https://infisical.com/docs/documentation/platform/secret-rotation/aws-iam), and more. - **[Secret Rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview)**: Rotate secrets at regular intervals for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/secret-rotation/postgres-credentials), [MySQL](https://infisical.com/docs/documentation/platform/secret-rotation/mysql), [AWS IAM](https://infisical.com/docs/documentation/platform/secret-rotation/aws-iam), and more.
- **[Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview)**: Generate ephemeral secrets on-demand for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/postgresql), [MySQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mysql), [RabbitMQ](https://infisical.com/docs/documentation/platform/dynamic-secrets/rabbit-mq), and more. - **[Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview)**: Generate ephemeral secrets on-demand for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/postgresql), [MySQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mysql), [RabbitMQ](https://infisical.com/docs/documentation/platform/dynamic-secrets/rabbit-mq), and more.
@@ -56,13 +56,15 @@ We're on a mission to make security tooling more accessible to everyone, not jus
- **[Infisical Kubernetes Operator](https://infisical.com/docs/documentation/getting-started/kubernetes)**: Deliver secrets to your Kubernetes workloads and automatically reload deployments. - **[Infisical Kubernetes Operator](https://infisical.com/docs/documentation/getting-started/kubernetes)**: Deliver secrets to your Kubernetes workloads and automatically reload deployments.
- **[Infisical Agent](https://infisical.com/docs/infisical-agent/overview)**: Inject secrets into applications without modifying any code logic. - **[Infisical Agent](https://infisical.com/docs/infisical-agent/overview)**: Inject secrets into applications without modifying any code logic.
### Infisical (Internal) PKI: ### Certificate Management
- **[Private Certificate Authority](https://infisical.com/docs/documentation/platform/pki/private-ca)**: Create CA hierarchies, configure [certificate templates](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-issuing-certificates) for policy enforcement, and start issuing X.509 certificates. - **[Internal CA](https://infisical.com/docs/documentation/platform/pki/private-ca)**: Create and manage a private
- **[Certificate Management](https://infisical.com/docs/documentation/platform/pki/certificates)**: Manage the certificate lifecycle from [issuance](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-issuing-certificates) to [revocation](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-revoking-certificates) with support for CRL. CA hierarchy directly within Infisical.
- **[External CA](https://infisical.com/docs/documentation/platform/pki/ca/external-ca)**: Integrate with third-party certificate authorities such as Let’s Encrypt, DigiCert, Microsoft AD CS, and more to leverage existing PKI infrastructure
or issue publicly trusted certificates.
- **[Certificate Lifecycle Management](https://infisical.com/docs/documentation/platform/pki/certificates/overview)**: Create certificate [profiles](https://infisical.com/docs/documentation/platform/pki/certificates/profiles) and [templates](https://infisical.com/docs/documentation/platform/pki/certificates/templates) to control how certificates are issued, including [enrollment methods](https://infisical.com/docs/documentation/platform/pki/enrollment-methods/overview) such as API, ACME, or EST. Manage the full lifecycle from issuance to renewal and [revocation](https://infisical.com/docs/documentation/platform/pki/certificates/certificates#guide-to-revoking-certificates) with CRL and inventory tracking.
- **[Certificate Syncs](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/overview)**: Sync certificates to external platforms like [AWS Certificate Manager](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager) and [Azure Key Vault](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/azure-key-vault).
- **[Alerting](https://infisical.com/docs/documentation/platform/pki/alerting)**: Configure alerting for expiring CA and end-entity certificates. - **[Alerting](https://infisical.com/docs/documentation/platform/pki/alerting)**: Configure alerting for expiring CA and end-entity certificates.
- **[Infisical PKI Issuer for Kubernetes](https://infisical.com/docs/documentation/platform/pki/pki-issuer)**: Deliver TLS certificates to your Kubernetes workloads with automatic renewal.
- **[Enrollment over Secure Transport](https://infisical.com/docs/documentation/platform/pki/est)**: Enroll and manage certificates via EST protocol.
### Infisical Key Management System (KMS): ### Infisical Key Management System (KMS):
+14
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
3.12
View File
+26
View File
@@ -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}"}
)
@@ -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
@@ -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"
}
"""
@@ -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
@@ -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
@@ -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"
}
"""
@@ -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
@@ -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
+486
View File
@@ -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"(?<!\[)\.(?![^\[]*\])", path_str)
for token in tokens:
token = token.strip()
if not token:
continue
# Check for attr[index] pattern
match = re.match(r"^(.+?)\[([^\]]+)\]$", token)
if match:
attr_name = match.group(1).strip()
index_str = match.group(2).strip()
# Parse index (support integers, slices, etc.)
if index_str.isdigit():
index = int(index_str)
elif "-" in index_str:
# Handle negative indices like [-1]
index = int(index_str)
elif ":" in index_str:
# Handle slices like [0:10]
index = slice(
*map(int, [x.strip() for x in index_str.split(":") if x.strip()])
)
else:
# Treat as string key
index = index_str
parts.extend([attr_name, index])
else:
# Plain attribute/key
parts.append(token)
return glom.Path(*parts)
def eval_var(context: Context, var_path: str, as_json: bool = True):
parts = var_path.split(".", 1)
value = context.vars[parts[0]]
if len(parts) == 2:
value = glom.glom(value, parse_glom_path(parts[1]))
if as_json:
if isinstance(value, JSONObjectWithFields):
value = value.to_json()
elif isinstance(value, requests.Response):
value = value.json()
elif isinstance(value, httpx.Response):
value = value.json()
return value
def prepare_headers(context: Context) -> 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
+16
View File
@@ -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",
]
+558
View File
@@ -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" },
]
+34 -24
View File
@@ -58,6 +58,7 @@
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2", "@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0", "@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4", "@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
@@ -83,6 +84,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"jose": "^6.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4", "jsrp": "^0.2.4",
@@ -96,6 +98,7 @@
"ms": "^2.1.3", "ms": "^2.1.3",
"mysql2": "^3.9.8", "mysql2": "^3.9.8",
"nanoid": "^3.3.8", "nanoid": "^3.3.8",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0", "oci-sdk": "^2.108.0",
"odbc": "^2.4.9", "odbc": "^2.4.9",
@@ -15271,6 +15274,15 @@
"form-data": "^4.0.0" "form-data": "^4.0.0"
} }
}, },
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node/node_modules/undici-types": { "node_modules/@types/node/node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -23236,9 +23248,10 @@
} }
}, },
"node_modules/jose": { "node_modules/jose": {
"version": "4.15.5", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
@@ -23638,6 +23651,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": { "node_modules/jwks-rsa/node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -27590,6 +27612,15 @@
"url": "https://github.com/sponsors/panva" "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": { "node_modules/openssl-wrapper": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz", "resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz",
@@ -33506,18 +33537,6 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/vite-node/node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/vite-node/node_modules/debug": { "node_modules/vite-node/node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -33549,15 +33568,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/vite-node/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/vite-node/node_modules/vite": { "node_modules/vite-node/node_modules/vite": {
"version": "7.1.12", "version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+3
View File
@@ -185,6 +185,7 @@
"@sindresorhus/slugify": "1.1.0", "@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2", "@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0", "@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4", "@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0", "acme-client": "^5.4.0",
"ajv": "^8.12.0", "ajv": "^8.12.0",
@@ -210,6 +211,7 @@
"ioredis": "^5.3.2", "ioredis": "^5.3.2",
"isomorphic-dompurify": "^2.22.0", "isomorphic-dompurify": "^2.22.0",
"jmespath": "^0.16.0", "jmespath": "^0.16.0",
"jose": "^6.1.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jsrp": "^0.2.4", "jsrp": "^0.2.4",
@@ -223,6 +225,7 @@
"ms": "^2.1.3", "ms": "^2.1.3",
"mysql2": "^3.9.8", "mysql2": "^3.9.8",
"nanoid": "^3.3.8", "nanoid": "^3.3.8",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0", "oci-sdk": "^2.108.0",
"odbc": "^2.4.9", "odbc": "^2.4.9",
+4
View File
@@ -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 { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; 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 { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types";
import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; 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 { TOrgServiceFactory } from "@app/services/org/org-service";
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-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 { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
@@ -294,6 +296,7 @@ declare module "fastify" {
certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory; certificateEst: TCertificateEstServiceFactory;
pkiAcme: TPkiAcmeServiceFactory;
certificateEstV3: TCertificateEstV3ServiceFactory; certificateEstV3: TCertificateEstV3ServiceFactory;
pkiCollection: TPkiCollectionServiceFactory; pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory;
@@ -353,6 +356,7 @@ declare module "fastify" {
role: TRoleServiceFactory; role: TRoleServiceFactory;
convertor: TConvertorServiceFactory; convertor: TConvertorServiceFactory;
subOrganization: TSubOrgServiceFactory; subOrganization: TSubOrgServiceFactory;
pkiAlertV2: TPkiAlertV2ServiceFactory;
}; };
// this is exclusive use for middlewares in which we need to inject data // this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer // everywhere else access using service layer
+72
View File
@@ -266,9 +266,39 @@ import {
TOrgRoles, TOrgRoles,
TOrgRolesInsert, TOrgRolesInsert,
TOrgRolesUpdate, 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, TPkiAlerts,
TPkiAlertsInsert, TPkiAlertsInsert,
TPkiAlertsUpdate, TPkiAlertsUpdate,
TPkiAlertsV2,
TPkiAlertsV2Insert,
TPkiAlertsV2Update,
TPkiApiEnrollmentConfigs, TPkiApiEnrollmentConfigs,
TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate, TPkiApiEnrollmentConfigsUpdate,
@@ -709,6 +739,32 @@ declare module "knex/types/tables" {
TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate 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<TPkiAcmeAuths, TPkiAcmeAuthsInsert, TPkiAcmeAuthsUpdate>;
[TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType<
TPkiAcmeOrderAuths,
TPkiAcmeOrderAuthsInsert,
TPkiAcmeOrderAuthsUpdate
>;
[TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType<
TPkiAcmeChallenges,
TPkiAcmeChallengesInsert,
TPkiAcmeChallengesUpdate
>;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigs,
TCertificateTemplateEstConfigsInsert, TCertificateTemplateEstConfigsInsert,
@@ -725,6 +781,22 @@ declare module "knex/types/tables" {
TCertificateSecretsUpdate TCertificateSecretsUpdate
>; >;
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>; [TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>;
[TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType<TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update>;
[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< [TableName.PkiCollection]: KnexOriginal.CompositeTableType<
TPkiCollections, TPkiCollections,
TPkiCollectionsInsert, TPkiCollectionsInsert,
@@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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");
});
}
}
@@ -0,0 +1,87 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
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<void> {
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);
}
}
@@ -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<void> {
// 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<void> {
// 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);
}
}
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project);
});
}
}
@@ -3,4 +3,4 @@ import { Knex } from "knex";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
export const dropConstraintIfExists = (tableName: TableName, constraintName: string, knex: Knex) => 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]);
+10
View File
@@ -92,7 +92,17 @@ export * from "./pam-accounts";
export * from "./pam-folders"; export * from "./pam-folders";
export * from "./pam-resources"; export * from "./pam-resources";
export * from "./pam-sessions"; 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";
export * from "./pki-alerts-v2";
export * from "./pki-api-enrollment-configs"; export * from "./pki-api-enrollment-configs";
export * from "./pki-certificate-profiles"; export * from "./pki-certificate-profiles";
export * from "./pki-certificate-templates-v2"; export * from "./pki-certificate-templates-v2";
+13 -1
View File
@@ -27,8 +27,13 @@ export enum TableName {
PkiCertificateProfile = "pki_certificate_profiles", PkiCertificateProfile = "pki_certificate_profiles",
PkiEstEnrollmentConfig = "pki_est_enrollment_configs", PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
PkiSubscriber = "pki_subscribers", PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts", PkiAlert = "pki_alerts",
PkiAlertsV2 = "pki_alerts_v2",
PkiAlertChannels = "pki_alert_channels",
PkiAlertHistory = "pki_alert_history",
PkiAlertHistoryCertificate = "pki_alert_history_certificate",
PkiCollection = "pki_collections", PkiCollection = "pki_collections",
PkiCollectionItem = "pki_collection_items", PkiCollectionItem = "pki_collection_items",
Groups = "groups", Groups = "groups",
@@ -210,7 +215,14 @@ export enum TableName {
PamAccount = "pam_accounts", PamAccount = "pam_accounts",
PamSession = "pam_sessions", 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"; export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
@@ -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<typeof PkiAcmeAccountsSchema>;
export type TPkiAcmeAccountsInsert = Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>;
export type TPkiAcmeAccountsUpdate = Partial<Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>>;
+25
View File
@@ -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<typeof PkiAcmeAuthsSchema>;
export type TPkiAcmeAuthsInsert = Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>;
export type TPkiAcmeAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>>;
@@ -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<typeof PkiAcmeChallengesSchema>;
export type TPkiAcmeChallengesInsert = Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>;
export type TPkiAcmeChallengesUpdate = Partial<Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>>;
@@ -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<typeof PkiAcmeEnrollmentConfigsSchema>;
export type TPkiAcmeEnrollmentConfigsInsert = Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TPkiAcmeEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>
>;
@@ -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<typeof PkiAcmeOrderAuthsSchema>;
export type TPkiAcmeOrderAuthsInsert = Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>;
export type TPkiAcmeOrderAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>>;
+26
View File
@@ -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<typeof PkiAcmeOrdersSchema>;
export type TPkiAcmeOrdersInsert = Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>;
export type TPkiAcmeOrdersUpdate = Partial<Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>>;
@@ -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<typeof PkiAlertChannelsSchema>;
export type TPkiAlertChannelsInsert = Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>;
export type TPkiAlertChannelsUpdate = Partial<Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>>;
@@ -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<typeof PkiAlertHistoryCertificateSchema>;
export type TPkiAlertHistoryCertificateInsert = Omit<
z.input<typeof PkiAlertHistoryCertificateSchema>,
TImmutableDBKeys
>;
export type TPkiAlertHistoryCertificateUpdate = Partial<
Omit<z.input<typeof PkiAlertHistoryCertificateSchema>, TImmutableDBKeys>
>;
@@ -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<typeof PkiAlertHistorySchema>;
export type TPkiAlertHistoryInsert = Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>;
export type TPkiAlertHistoryUpdate = Partial<Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>>;
+25
View File
@@ -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<typeof PkiAlertsV2Schema>;
export type TPkiAlertsV2Insert = Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>;
export type TPkiAlertsV2Update = Partial<Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>>;
@@ -18,7 +18,8 @@ export const PkiCertificateProfilesSchema = z.object({
estConfigId: z.string().uuid().nullable().optional(), estConfigId: z.string().uuid().nullable().optional(),
apiConfigId: z.string().uuid().nullable().optional(), apiConfigId: z.string().uuid().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
acmeConfigId: z.string().uuid().nullable().optional()
}); });
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>; export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
@@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({
isSecretRequestNotificationEnabled: z.boolean().default(false), isSecretRequestNotificationEnabled: z.boolean().default(false),
secretRequestChannels: z.string().default(""), secretRequestChannels: z.string().default(""),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date(),
isSecretSyncErrorNotificationEnabled: z.boolean().default(false),
secretSyncErrorChannels: z.string().default("")
}); });
export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>; export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>;
+6
View File
@@ -1,4 +1,5 @@
import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; 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 { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
import { registerAccessApprovalRequestRouter } from "./access-approval-request-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 { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
import { registerPamSessionRouter } from "./pam-session-router"; import { registerPamSessionRouter } from "./pam-session-router";
import { registerPITRouter } from "./pit-router"; import { registerPITRouter } from "./pit-router";
import { registerPkiAcmeRouter } from "./pki-acme-router";
import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRoleRouter } from "./project-role-router";
import { registerProjectRouter } from "./project-router"; import { registerProjectRouter } from "./project-router";
import { registerRateLimitRouter } from "./rate-limit-router"; import { registerRateLimitRouter } from "./rate-limit-router";
@@ -107,6 +109,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register( await server.register(
async (pkiRouter) => { async (pkiRouter) => {
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); 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" } { prefix: "/pki" }
); );
@@ -92,7 +92,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
gatewayClientCertificate: z.string(), gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(), gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(), gatewayServerCertificateChain: z.string(),
relayHost: z.string() relayHost: z.string(),
metadata: z.record(z.string(), z.string()).optional()
}) })
} }
}, },
+461
View File
@@ -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<any>,
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer U> ? U : string
>({
req,
schema
}: {
req: R;
schema?: TSchema;
}): Promise<TAuthenciatedJwsPayload<T>> => {
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 <T>(res: FastifyReply, profileId: string, response: TAcmeResponse<T>): Promise<T> => {
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/<profile_id>/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/<profile_id>/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/<profile_id>/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/<profile_id>/accounts/<account_id>
// 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/<profile_id>/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/<profile_id>/orders/<order_id>
// 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/<profile_id>/orders/<order_id>/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/<profile_id>/accounts/<account_id>/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/<profile_id>/orders/<order_id>/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/<profile_id>/authorizations/<authz_id>
// 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/<profile_id>/authorizations/<authz_id>/challenges/<challenge_id>
// 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
})
);
}
});
};
@@ -315,6 +315,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.extend({ .extend({
status: z.string(), status: z.string(),
comment: z.string().optional(), comment: z.string().optional(),
createdAt: z.date(),
isOrgMembershipActive: z.boolean().nullable().optional() isOrgMembershipActive: z.boolean().nullable().optional()
}) })
.array(), .array(),
@@ -1,14 +1,14 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { AccessScope, ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn"; import { groupBy } from "@app/lib/fn";
import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal";
import { TMembershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal"; import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal";
@@ -38,13 +38,13 @@ type TAccessApprovalPolicyServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">; projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">;
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
groupDAL: TGroupDALFactory; groupDAL: TGroupDALFactory;
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find" | "resetReviewByPolicyId">; accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find" | "resetReviewByPolicyId">;
additionalPrivilegeDAL: Pick<TAdditionalPrivilegeDALFactory, "delete">; additionalPrivilegeDAL: Pick<TAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">; accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory; accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory;
membershipUserDAL: TMembershipUserDALFactory;
}; };
export const accessApprovalPolicyServiceFactory = ({ export const accessApprovalPolicyServiceFactory = ({
@@ -60,7 +60,7 @@ export const accessApprovalPolicyServiceFactory = ({
accessApprovalRequestDAL, accessApprovalRequestDAL,
additionalPrivilegeDAL, additionalPrivilegeDAL,
accessApprovalRequestReviewerDAL, accessApprovalRequestReviewerDAL,
membershipUserDAL projectMembershipDAL
}: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => { }: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => {
const $policyExists = async ({ const $policyExists = async ({
envId, envId,
@@ -83,6 +83,21 @@ export const accessApprovalPolicyServiceFactory = ({
return policyId ? policy && policy.id !== policyId : Boolean(policy); return policyId ? policy && policy.id !== policyId : Boolean(policy);
}; };
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const createAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["createAccessApprovalPolicy"] = async ({ const createAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["createAccessApprovalPolicy"] = async ({
name, name,
actor, actor,
@@ -160,7 +175,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) { if (invalidUsernames.length) {
throw new BadRequestError({ throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}` message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
}); });
} }
@@ -171,6 +186,14 @@ export const accessApprovalPolicyServiceFactory = ({
})) }))
); );
} }
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
project.orgId,
project.id
);
}
let groupBypassers: string[] = []; let groupBypassers: string[] = [];
let bypasserUserIds: string[] = []; let bypasserUserIds: string[] = [];
@@ -257,6 +280,8 @@ export const accessApprovalPolicyServiceFactory = ({
} }
if (bypasserUserIds.length) { if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, project.orgId, project.id);
await accessApprovalPolicyBypasserDAL.insertMany( await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({ bypasserUserIds.map((userId) => ({
bypasserUserId: userId, bypasserUserId: userId,
@@ -420,23 +445,6 @@ export const accessApprovalPolicyServiceFactory = ({
bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))]; bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))];
} }
// Validate user bypassers
if (bypasserUserIds.length > 0) {
const orgMemberships = await membershipUserDAL.find({
$in: { actorUserId: bypasserUserIds },
scopeOrgId: actorOrgId,
scope: AccessScope.Organization
});
if (orgMemberships.length !== bypasserUserIds.length) {
const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.actorUserId as string));
const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id));
throw new BadRequestError({
message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}`
});
}
}
// Validate group bypassers // Validate group bypassers
if (groupBypassers.length > 0) { if (groupBypassers.length > 0) {
const orgGroups = await groupDAL.find({ const orgGroups = await groupDAL.find({
@@ -487,7 +495,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) { if (invalidUsernames.length) {
throw new BadRequestError({ throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}` message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
}); });
} }
@@ -498,6 +506,14 @@ export const accessApprovalPolicyServiceFactory = ({
})) }))
); );
} }
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
actorOrgId,
accessApprovalPolicy.projectId
);
}
await accessApprovalPolicyApproverDAL.insertMany( await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({ approverUserIds.map((el) => ({
approverUserId: el.id, approverUserId: el.id,
@@ -536,6 +552,8 @@ export const accessApprovalPolicyServiceFactory = ({
await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) { if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, accessApprovalPolicy.projectId);
await accessApprovalPolicyBypasserDAL.insertMany( await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({ bypasserUserIds.map((userId) => ({
bypasserUserId: userId, bypasserUserId: userId,
@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
); );
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; 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}`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({ await triggerWorkflowIntegrationNotification({
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
type: TriggerFeature.ACCESS_REQUEST, type: TriggerFeature.ACCESS_REQUEST,
payload: { payload: {
projectName: project.name, projectName: project.name,
projectPath,
requesterFullName, requesterFullName,
isTemporary, isTemporary,
requesterEmail: requestedByUser.email as string, requesterEmail: requestedByUser.email as string,
@@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.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}`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({ await triggerWorkflowIntegrationNotification({
@@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({
approvalUrl, approvalUrl,
editNote, editNote,
editorEmail: editedByUser.email as string, editorEmail: editedByUser.email as string,
editorFullName editorFullName,
projectPath
} }
}, },
projectId: project.id projectId: project.id
@@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-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 { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import { import {
@@ -322,6 +323,7 @@ export enum EventType {
GET_CERT_BODY = "get-cert-body", GET_CERT_BODY = "get-cert-body",
GET_CERT_PRIVATE_KEY = "get-cert-private-key", GET_CERT_PRIVATE_KEY = "get-cert-private-key",
GET_CERT_BUNDLE = "get-cert-bundle", GET_CERT_BUNDLE = "get-cert-bundle",
EXPORT_CERT_PKCS12 = "export-cert-pkcs12",
CREATE_PKI_ALERT = "create-pki-alert", CREATE_PKI_ALERT = "create-pki-alert",
GET_PKI_ALERT = "get-pki-alert", GET_PKI_ALERT = "get-pki-alert",
UPDATE_PKI_ALERT = "update-pki-alert", UPDATE_PKI_ALERT = "update-pki-alert",
@@ -370,6 +372,7 @@ export enum EventType {
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
RENEW_CERTIFICATE = "renew-certificate", RENEW_CERTIFICATE = "renew-certificate",
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
@@ -2313,15 +2316,24 @@ interface GetCertBundle {
serialNumber: string; serialNumber: string;
}; };
} }
interface GetCertPkcs12 {
type: EventType.EXPORT_CERT_PKCS12;
metadata: {
certId: string;
cn: string;
serialNumber: string;
};
}
interface CreatePkiAlert { interface CreatePkiAlert {
type: EventType.CREATE_PKI_ALERT; type: EventType.CREATE_PKI_ALERT;
metadata: { metadata: {
pkiAlertId: string; pkiAlertId: string;
pkiCollectionId: string; pkiCollectionId?: string;
name: string; name: string;
alertBeforeDays: number; alertBefore: string;
recipientEmails: string; eventType: PkiAlertEventType;
recipientEmails?: string;
}; };
} }
interface GetPkiAlert { interface GetPkiAlert {
@@ -2337,7 +2349,8 @@ interface UpdatePkiAlert {
pkiAlertId: string; pkiAlertId: string;
pkiCollectionId?: string; pkiCollectionId?: string;
name?: string; name?: string;
alertBeforeDays?: number; alertBefore?: string;
eventType?: PkiAlertEventType;
recipientEmails?: string; recipientEmails?: string;
}; };
} }
@@ -2749,6 +2762,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 { interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE; type: EventType.RENEW_CERTIFICATE;
metadata: { metadata: {
@@ -4237,6 +4261,7 @@ export type Event =
| GetCertBody | GetCertBody
| GetCertPrivateKey | GetCertPrivateKey
| GetCertBundle | GetCertBundle
| GetCertPkcs12
| CreatePkiAlert | CreatePkiAlert
| GetPkiAlert | GetPkiAlert
| UpdatePkiAlert | UpdatePkiAlert
@@ -4279,6 +4304,7 @@ export type Event =
| DeleteCertificateProfile | DeleteCertificateProfile
| GetCertificateProfile | GetCertificateProfile
| ListCertificateProfiles | ListCertificateProfiles
| GetCertificateProfileLatestActiveBundle
| IssueCertificateFromProfile | IssueCertificateFromProfile
| SignCertificateFromProfile | SignCertificateFromProfile
| OrderCertificateFromProfile | OrderCertificateFromProfile
@@ -480,6 +480,36 @@ export const pamAccountServiceFactory = ({
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
} }
let metadata;
switch (resourceType) {
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId: account.projectId
});
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
});
metadata = {
username: credentials.username,
database: connectionCredentials.database,
accountName: account.name,
accountPath
};
}
break;
default:
break;
}
return { return {
sessionId: session.id, sessionId: session.id,
resourceType, resourceType,
@@ -491,7 +521,8 @@ export const pamAccountServiceFactory = ({
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost, relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId, projectId: account.projectId,
account account,
metadata
}; };
}; };
@@ -104,8 +104,7 @@ const makeSqlConnection = (
// (like being able to do an auth handshake regardless pass or not) // (like being able to do an auth handshake regardless pass or not)
if ( if (
connectOnly && connectOnly &&
(error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` || error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`
error.message.includes("no pg_hba.conf entry for host"))
) { ) {
return; return;
} }
@@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionCertificateProfileActions.Edit, ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create, ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete, ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionCertificateProfileActions.IssueCert ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret
], ],
ProjectPermissionSub.CertificateProfiles ProjectPermissionSub.CertificateProfiles
); );
@@ -116,7 +116,9 @@ export enum ProjectPermissionCertificateProfileActions {
Create = "create", Create = "create",
Edit = "edit", Edit = "edit",
Delete = "delete", Delete = "delete",
IssueCert = "issue-cert" IssueCert = "issue-cert",
RevealAcmeEabSecret = "reveal-acme-eab-secret",
RotateAcmeEabSecret = "rotate-acme-eab-secret"
} }
export enum ProjectPermissionSecretSyncActions { export enum ProjectPermissionSecretSyncActions {
@@ -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<typeof pkiAcmeAccountDALFactory>;
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
};
};
@@ -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<typeof pkiAcmeAuthDALFactory>;
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
};
};
@@ -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<typeof pkiAcmeChallengeDALFactory>;
export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge);
const markAsValidCascadeById = async (id: string, tx?: Knex): Promise<TPkiAcmeChallenges> => {
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<TPkiAcmeChallenges> => {
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
};
};
@@ -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<void> => {
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 };
};
@@ -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";
}
}
@@ -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));
};
@@ -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<typeof pkiAcmeOrderAuthDALFactory>;
export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => {
const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth);
return {
...pkiAcmeOrderAuthOrm
};
};
@@ -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<typeof pkiAcmeOrderDALFactory>;
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
};
};
@@ -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()
});
@@ -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<TProjectDALFactory, "findOne" | "updateById" | "transaction">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
acmeAccountDAL: Pick<
TPkiAcmeAccountDALFactory,
"findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create"
>;
acmeOrderDAL: Pick<
TPkiAcmeOrderDALFactory,
| "create"
| "transaction"
| "updateById"
| "findByAccountAndOrderIdWithAuthorizations"
| "findByIdForFinalization"
| "listByAccountId"
>;
acmeAuthDAL: Pick<TPkiAcmeAuthDALFactory, "create" | "findByAccountIdAndAuthIdWithChallenges">;
acmeOrderAuthDAL: Pick<TPkiAcmeOrderAuthDALFactory, "insertMany">;
acmeChallengeDAL: Pick<
TPkiAcmeChallengeDALFactory,
"create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation"
>;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
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<TCertificateProfileWithConfigs> => {
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<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
rawJwsPayload,
getJWK,
schema
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
getJWK: (protectedHeader: JWSHeaderParameters) => Promise<JsonWebKey>;
schema?: TSchema;
}): Promise<TJwsPayload<T>> => {
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<TJwsPayload<TCreateAcmeAccountPayload>> => {
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<any> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
profileId,
rawJwsPayload,
schema,
expectedAccountId
}: {
url: URL;
profileId: string;
rawJwsPayload: TRawJwsPayload;
schema?: TSchema;
expectedAccountId?: string;
}): Promise<TAuthenciatedJwsPayload<T>> => {
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<TGetAcmeDirectoryResponse> => {
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<string> => {
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<TAcmeResponse<TCreateAcmeAccountResponse>> => {
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<TAcmeResponse<TDeactivateAcmeAccountResponse>> => {
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<TAcmeResponse<TAcmeOrderResource>> => {
// 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<TAcmeResponse<TAcmeOrderResource>> => {
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<TAcmeResponse<TAcmeOrderResource>> => {
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<TAcmeResponse<string>> => {
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<TAcmeResponse<TListAcmeOrdersResponse>> => {
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<TAcmeResponse<TGetAcmeAuthorizationResponse>> => {
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<TAcmeResponse<TRespondToAcmeChallengeResponse>> => {
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
};
};
@@ -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<typeof GetAcmeDirectoryResponseSchema>;
export type TCreateAcmeAccountResponse = z.infer<typeof CreateAcmeAccountResponseSchema>;
export type TAcmeOrderResource = z.infer<typeof AcmeOrderResourceSchema>;
export type TDeactivateAcmeAccountResponse = z.infer<typeof DeactivateAcmeAccountResponseSchema>;
export type TListAcmeOrdersResponse = z.infer<typeof ListAcmeOrdersResponseSchema>;
export type TDownloadAcmeCertificateDTO = string;
export type TGetAcmeAuthorizationResponse = z.infer<typeof GetAcmeAuthorizationResponseSchema>;
export type TRespondToAcmeChallengeResponse = z.infer<typeof RespondToAcmeChallengeResponseSchema>;
// Payload types
export type TRawJwsPayload = z.infer<typeof RawJwsPayloadSchema>;
export type TProtectedHeader = z.infer<typeof ProtectedHeaderSchema>;
export type TCreateAcmeAccountPayload = z.infer<typeof CreateAcmeAccountBodySchema>;
export type TCreateAcmeOrderPayload = z.infer<typeof CreateAcmeOrderBodySchema>;
export type TDeactivateAcmeAccountPayload = z.infer<typeof DeactivateAcmeAccountBodySchema>;
export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySchema>;
export type TJwsPayload<T> = {
protectedHeader: TProtectedHeader;
payload: T;
};
export type TAuthenciatedJwsPayload<T> = TJwsPayload<T> & {
profileId: string;
accountId: string;
};
export type TAcmeResponse<TPayload> = {
status: number;
headers: Record<string, string | string[]>;
body: TPayload;
};
export type TPkiAcmeServiceFactory = {
validateJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
rawJwsPayload,
getJWK,
schema
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
getJWK: (protectedHeader: JWSHeaderParameters) => Promise<JsonWebKey>;
schema?: TSchema;
}) => Promise<TJwsPayload<T>>;
validateNewAccountJwsPayload: ({
url,
rawJwsPayload
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
}) => Promise<TJwsPayload<TCreateAcmeAccountPayload>>;
validateExistingAccountJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
profileId,
rawJwsPayload,
schema,
expectedAccountId
}: {
url: URL;
profileId: string;
rawJwsPayload: TRawJwsPayload;
schema?: TSchema;
expectedAccountId?: string;
}) => Promise<TAuthenciatedJwsPayload<T>>;
getAcmeDirectory: (profileId: string) => Promise<TGetAcmeDirectoryResponse>;
getAcmeNewNonce: (profileId: string) => Promise<string>;
createAcmeAccount: ({
profileId,
alg,
jwk,
payload
}: {
profileId: string;
alg: string;
jwk: JsonWebKey;
payload: TCreateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TCreateAcmeAccountResponse>>;
deactivateAcmeAccount: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload?: TDeactivateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TDeactivateAcmeAccountResponse>>;
createAcmeOrder: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload: TCreateAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
getAcmeOrder: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
finalizeAcmeOrder: ({
profileId,
accountId,
orderId,
payload
}: {
profileId: string;
accountId: string;
orderId: string;
payload: TFinalizeAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
downloadAcmeCertificate: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<string>>;
listAcmeOrders: ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
}) => Promise<TAcmeResponse<TListAcmeOrdersResponse>>;
getAcmeAuthorization: ({
profileId,
accountId,
authzId
}: {
profileId: string;
accountId: string;
authzId: string;
}) => Promise<TAcmeResponse<TGetAcmeAuthorizationResponse>>;
respondToAcmeChallenge: ({
profileId,
accountId,
authzId,
challengeId
}: {
profileId: string;
accountId: string;
authzId: string;
challengeId: string;
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
};
export type TPkiAcmeChallengeServiceFactory = {
validateChallengeResponse: (challengeId: string) => Promise<void>;
};
@@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { containsGlobPatterns } from "@app/lib/picomatch"; import { containsGlobPatterns } from "@app/lib/picomatch";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserDALFactory } from "@app/services/user/user-dal";
import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types";
@@ -39,6 +40,7 @@ type TSecretApprovalPolicyServiceFactoryDep = {
secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory; secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">; projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">;
userDAL: Pick<TUserDALFactory, "find">; userDAL: Pick<TUserDALFactory, "find">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory;
secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -56,9 +58,25 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicyEnvironmentDAL, secretApprovalPolicyEnvironmentDAL,
projectEnvDAL, projectEnvDAL,
userDAL, userDAL,
projectMembershipDAL,
licenseService, licenseService,
secretApprovalRequestDAL secretApprovalRequestDAL
}: TSecretApprovalPolicyServiceFactoryDep) => { }: TSecretApprovalPolicyServiceFactoryDep) => {
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const $policyExists = async ({ const $policyExists = async ({
envIds, envIds,
envId, envId,
@@ -202,6 +220,7 @@ export const secretApprovalPolicyServiceFactory = ({
}, },
tx tx
); );
await secretApprovalPolicyEnvironmentDAL.insertMany( await secretApprovalPolicyEnvironmentDAL.insertMany(
envs.map((env) => ({ envs.map((env) => ({
envId: env.id, envId: env.id,
@@ -233,6 +252,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
} }
await verifyProjectUserMembership(userApproverIds, actorOrgId, projectId);
await secretApprovalPolicyApproverDAL.insertMany( await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({ userApproverIds.map((approverUserId) => ({
approverUserId, approverUserId,
@@ -250,6 +271,8 @@ export const secretApprovalPolicyServiceFactory = ({
); );
if (bypasserUserIds.length) { if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, projectId);
await secretApprovalPolicyBypasserDAL.insertMany( await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({ bypasserUserIds.map((userId) => ({
bypasserUserId: userId, bypasserUserId: userId,
@@ -425,6 +448,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
} }
await verifyProjectUserMembership(userApproverIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyApproverDAL.insertMany( await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({ userApproverIds.map((approverUserId) => ({
approverUserId, approverUserId,
@@ -458,6 +483,8 @@ export const secretApprovalPolicyServiceFactory = ({
await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) { if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyBypasserDAL.insertMany( await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({ bypasserUserIds.map((userId) => ({
bypasserUserId: userId, bypasserUserId: userId,
@@ -166,6 +166,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer), tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"), 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("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
@@ -240,6 +241,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerUsername: username, reviewerUsername: username,
reviewerFirstName: firstName, reviewerFirstName: firstName,
reviewerComment: comment, reviewerComment: comment,
reviewerCreatedAt: createdAt,
reviewerIsOrgMembershipActive: isOrgMembershipActive reviewerIsOrgMembershipActive: isOrgMembershipActive
}) => }) =>
userId userId
@@ -251,6 +253,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
lastName, lastName,
username, username,
comment: comment ?? "", comment: comment ?? "",
createdAt,
isOrgMembershipActive isOrgMembershipActive
} }
: undefined : undefined
@@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({
type: NotificationType.SECRET_CHANGE_REQUEST, type: NotificationType.SECRET_CHANGE_REQUEST,
title: "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}**.`, 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, firstName: reviewerUser.firstName,
projectName: project.name, projectName: project.name,
organizationName: project.organization.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 template: SmtpTemplates.SecretApprovalRequestNeedsReview
}); });
@@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({
const env = await projectEnvDAL.findOne({ id: policy.envId }); const env = await projectEnvDAL.findOne({ id: policy.envId });
const user = await userDAL.findById(actorId); 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({ await triggerWorkflowIntegrationNotification({
input: { input: {
projectId, projectId,
@@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
requestId: secretApprovalRequest.id, 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 user = await userDAL.findById(actorId);
const env = await projectEnvDAL.findOne({ id: policy.envId }); 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({ await triggerWorkflowIntegrationNotification({
input: { input: {
projectId, projectId,
@@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath, secretPath,
projectId, projectId,
requestId: secretApprovalRequest.id, 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
} }
} }
}, },
+3 -1
View File
@@ -77,7 +77,9 @@ export const KeyStorePrefixes = {
UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const, UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const,
IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const, IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const,
GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => 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 = { export const KeyStoreTtls = {
+5 -1
View File
@@ -62,6 +62,7 @@ export enum ApiDocsTags {
PkiCertificateCollections = "PKI Certificate Collections", PkiCertificateCollections = "PKI Certificate Collections",
PkiAlerting = "PKI Alerting", PkiAlerting = "PKI Alerting",
PkiSubscribers = "PKI Subscribers", PkiSubscribers = "PKI Subscribers",
PkiAcme = "PKI ACME",
SshCertificates = "SSH Certificates", SshCertificates = "SSH Certificates",
SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateAuthorities = "SSH Certificate Authorities",
SshCertificateTemplates = "SSH Certificate Templates", SshCertificateTemplates = "SSH Certificate Templates",
@@ -2308,7 +2309,10 @@ export const AppConnections = {
code: "The OAuth code to use to connect with Azure Client Secrets.", code: "The OAuth code to use to connect with Azure Client Secrets.",
tenantId: "The Tenant ID 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.", 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: { AZURE_DEVOPS: {
code: "The OAuth code to use to connect with Azure DevOps.", code: "The OAuth code to use to connect with Azure DevOps.",
+17
View File
@@ -106,6 +106,21 @@ const envSchema = z
HTTPS_ENABLED: zodStrBool, HTTPS_ENABLED: zodStrBool,
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
DAILY_RESOURCE_CLEAN_UP_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<string, string>;
})
.default("{}")
),
// smtp options // smtp options
SMTP_HOST: zpStr(z.string().optional()), SMTP_HOST: zpStr(z.string().optional()),
SMTP_IGNORE_TLS: zodStrBool.default("false"), 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", (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
isDailyResourceCleanUpDevelopmentMode: isDailyResourceCleanUpDevelopmentMode:
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, 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, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
@@ -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<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
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<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
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}`);
}
};
@@ -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<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
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<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
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}`);
}
};
@@ -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 { 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) => { export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => {
try { try {
@@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
return; return;
} }
const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); const handlerPromises = [
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); triggerSlackNotification({
projectId,
notification,
orgId: project.orgId,
projectSlackConfigDAL,
kmsService
}),
if (slackConfig) { triggerMicrosoftTeamsNotification({
if ( projectId,
notification.type === TriggerFeature.ACCESS_REQUEST || notification,
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED orgId: project.orgId,
) { projectMicrosoftTeamsConfigDAL,
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; microsoftTeamsService
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");
});
}
}
}
if (microsoftTeamsConfig) { await Promise.allSettled(handlerPromises);
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");
});
}
}
}
}
} catch (error) { } catch (error) {
logger.error(error, "Error triggering workflow integration notification"); logger.error(error, "Error triggering workflow integration notification");
} }
+19 -1
View File
@@ -7,7 +7,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
export enum TriggerFeature { export enum TriggerFeature {
SECRET_APPROVAL = "secret-approval", SECRET_APPROVAL = "secret-approval",
ACCESS_REQUEST = "access-request", 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 = export type TNotification =
@@ -20,6 +21,7 @@ export type TNotification =
requestId: string; requestId: string;
projectId: string; projectId: string;
secretKeys: string[]; secretKeys: string[];
approvalUrl: string;
}; };
} }
| { | {
@@ -31,6 +33,7 @@ export type TNotification =
secretPath: string; secretPath: string;
environment: string; environment: string;
projectName: string; projectName: string;
projectPath: string;
permissions: string[]; permissions: string[];
approvalUrl: string; approvalUrl: string;
note?: string; note?: string;
@@ -50,6 +53,21 @@ export type TNotification =
editNote?: string; editNote?: string;
editorFullName?: string; editorFullName?: string;
editorEmail?: 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;
}; };
}; };
+6
View File
@@ -51,6 +51,7 @@ export enum QueueName {
AuditLogPrune = "audit-log-prune", AuditLogPrune = "audit-log-prune",
DailyResourceCleanUp = "daily-resource-cleanup", DailyResourceCleanUp = "daily-resource-cleanup",
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
PkiSyncCleanup = "pki-sync-cleanup", PkiSyncCleanup = "pki-sync-cleanup",
PkiSubscriber = "pki-subscriber", PkiSubscriber = "pki-subscriber",
TelemetryInstanceStats = "telemtry-self-hosted-stats", TelemetryInstanceStats = "telemtry-self-hosted-stats",
@@ -90,6 +91,7 @@ export enum QueueJobs {
AuditLogPrune = "audit-log-prune-job", AuditLogPrune = "audit-log-prune-job",
DailyResourceCleanUp = "daily-resource-cleanup-job", DailyResourceCleanUp = "daily-resource-cleanup-job",
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
PkiSyncCleanup = "pki-sync-cleanup-job", PkiSyncCleanup = "pki-sync-cleanup-job",
SecWebhook = "secret-webhook-trigger", SecWebhook = "secret-webhook-trigger",
TelemetryInstanceStats = "telemetry-self-hosted-stats", TelemetryInstanceStats = "telemetry-self-hosted-stats",
@@ -159,6 +161,10 @@ export type TQueueJobTypes = {
name: QueueJobs.DailyExpiringPkiItemAlert; name: QueueJobs.DailyExpiringPkiItemAlert;
payload: undefined; payload: undefined;
}; };
[QueueName.DailyPkiAlertV2Processing]: {
name: QueueJobs.DailyPkiAlertV2Processing;
payload: undefined;
};
[QueueName.PkiSyncCleanup]: { [QueueName.PkiSyncCleanup]: {
name: QueueJobs.PkiSyncCleanup; name: QueueJobs.PkiSyncCleanup;
payload: undefined; payload: undefined;
@@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas";
const isScimRoutes = (pathname: string) => const isScimRoutes = (pathname: string) =>
pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups"); 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) => { export const addErrorsToResponseSchemas = fp(async (server) => {
server.addHook("onRoute", (routeOptions) => { 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 = { routeOptions.schema.response = {
...DefaultResponseErrorsSchema, ...DefaultResponseErrorsSchema,
...routeOptions.schema.response ...routeOptions.schema.response
@@ -182,8 +182,8 @@ export const injectIdentity = fp(
case AuthMode.IDENTITY_ACCESS_TOKEN: { case AuthMode.IDENTITY_ACCESS_TOKEN: {
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(
token, token,
subOrganizationSelector, req.realIp,
req.realIp subOrganizationSelector
); );
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
requestContext.set("orgId", identity.orgId); requestContext.set("orgId", identity.orgId);
@@ -5,6 +5,7 @@ import fastifyPlugin from "fastify-plugin";
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { AcmeError } from "@app/ee/services/pki-acme/pki-acme-errors";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { import {
BadRequestError, BadRequestError,
@@ -242,6 +243,19 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
error: "TokenError", error: "TokenError",
message: errorMessage 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 { } else {
void res.status(HttpStatusCodes.InternalServerError).send({ void res.status(HttpStatusCodes.InternalServerError).send({
reqId: req.id, reqId: req.id,
+4 -1
View File
@@ -31,7 +31,10 @@ export const registerServeUI = async (
CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY, CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY,
POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY, POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY,
INTERCOM_ID: appCfg.INTERCOM_ID, 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)});`; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`;
return res.send(js); return res.send(js);
+71 -4
View File
@@ -74,6 +74,13 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-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 { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal";
import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; 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 { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal";
import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-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 { 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"; 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 { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; 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 { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
@@ -547,6 +560,9 @@ export const registerRoutes = async (
const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db); const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db);
const membershipRoleDAL = membershipRoleDALFactory(db); const membershipRoleDAL = membershipRoleDALFactory(db);
const roleDAL = roleDALFactory(db); const roleDAL = roleDALFactory(db);
const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db);
const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db);
const pkiAlertV2DAL = pkiAlertV2DALFactory(db);
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
@@ -691,6 +707,7 @@ export const registerRoutes = async (
secretApprovalPolicyDAL, secretApprovalPolicyDAL,
licenseService, licenseService,
userDAL, userDAL,
projectMembershipDAL,
secretApprovalRequestDAL secretApprovalRequestDAL
}); });
@@ -1061,7 +1078,12 @@ export const registerRoutes = async (
const certificateProfileDAL = certificateProfileDALFactory(db); const certificateProfileDAL = certificateProfileDALFactory(db);
const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db);
const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(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 certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db);
const certificateSecretDAL = certificateSecretDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db);
@@ -1156,6 +1178,11 @@ export const registerRoutes = async (
certificateTemplateV2DAL, certificateTemplateV2DAL,
apiEnrollmentConfigDAL, apiEnrollmentConfigDAL,
estEnrollmentConfigDAL, estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
permissionService, permissionService,
kmsService, kmsService,
projectDAL projectDAL
@@ -1251,7 +1278,10 @@ export const registerRoutes = async (
licenseService, licenseService,
gatewayService, gatewayService,
gatewayV2Service, gatewayV2Service,
notificationService notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
}); });
const secretQueueService = secretQueueFactory({ const secretQueueService = secretQueueFactory({
@@ -1510,7 +1540,7 @@ export const registerRoutes = async (
accessApprovalRequestDAL, accessApprovalRequestDAL,
accessApprovalRequestReviewerDAL, accessApprovalRequestReviewerDAL,
additionalPrivilegeDAL, additionalPrivilegeDAL,
membershipUserDAL projectMembershipDAL
}); });
const accessApprovalRequestService = accessApprovalRequestServiceFactory({ const accessApprovalRequestService = accessApprovalRequestServiceFactory({
@@ -1801,6 +1831,21 @@ export const registerRoutes = async (
groupDAL groupDAL
}); });
const pkiAlertV2Service = pkiAlertV2ServiceFactory({
pkiAlertV2DAL,
pkiAlertChannelDAL,
pkiAlertHistoryDAL,
permissionService,
smtpService
});
const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({
queueService,
pkiAlertV2Service,
pkiAlertV2DAL,
pkiAlertHistoryDAL
});
const dynamicSecretProviders = buildDynamicSecretProviders({ const dynamicSecretProviders = buildDynamicSecretProviders({
gatewayService, gatewayService,
gatewayV2Service gatewayV2Service
@@ -2152,6 +2197,7 @@ export const registerRoutes = async (
certificateAuthorityDAL, certificateAuthorityDAL,
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
acmeAccountDAL,
internalCaService: internalCertificateAuthorityService, internalCaService: internalCertificateAuthorityService,
permissionService, permissionService,
certificateSyncDAL, certificateSyncDAL,
@@ -2178,6 +2224,24 @@ export const registerRoutes = async (
estEnrollmentConfigDAL estEnrollmentConfigDAL
}); });
const acmeChallengeService = pkiAcmeChallengeServiceFactory({
acmeChallengeDAL
});
const pkiAcmeService = pkiAcmeServiceFactory({
projectDAL,
certificateProfileDAL,
certificateBodyDAL,
acmeAccountDAL,
acmeOrderDAL,
acmeAuthDAL,
acmeOrderAuthDAL,
acmeChallengeDAL,
keyStore,
kmsService,
certificateV3Service,
acmeChallengeService
});
const pkiSubscriberService = pkiSubscriberServiceFactory({ const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL, pkiSubscriberDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
@@ -2355,6 +2419,7 @@ export const registerRoutes = async (
await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts(); await dailyExpiringPkiItemAlert.startSendingAlerts();
await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiSubscriberQueue.startDailyAutoRenewalJob();
await pkiAlertV2Queue.init();
await certificateV3Queue.init(); await certificateV3Queue.init();
await kmsService.startService(hsmStatus); await kmsService.startService(hsmStatus);
await microsoftTeamsService.start(); await microsoftTeamsService.start();
@@ -2433,6 +2498,7 @@ export const registerRoutes = async (
certificateProfile: certificateProfileService, certificateProfile: certificateProfileService,
certificateAuthorityCrl: certificateAuthorityCrlService, certificateAuthorityCrl: certificateAuthorityCrlService,
certificateEst: certificateEstService, certificateEst: certificateEstService,
pkiAcme: pkiAcmeService,
pit: pitService, pit: pitService,
pkiAlert: pkiAlertService, pkiAlert: pkiAlertService,
pkiCollection: pkiCollectionService, pkiCollection: pkiCollectionService,
@@ -2486,7 +2552,8 @@ export const registerRoutes = async (
role: roleService, role: roleService,
additionalPrivilege: additionalPrivilegeService, additionalPrivilege: additionalPrivilegeService,
identityProject: identityProjectService, identityProject: identityProjectService,
convertor: convertorService convertor: convertorService,
pkiAlertV2: pkiAlertV2Service
}); });
const cronJobs: CronJob[] = []; const cronJobs: CronJob[] = [];
@@ -141,7 +141,8 @@ export const secretRawSchema = z.object({
actorId: z.string().nullable().optional(), actorId: z.string().nullable().optional(),
actorType: z.string().nullable().optional(), actorType: z.string().nullable().optional(),
name: z.string().nullable().optional(), name: z.string().nullable().optional(),
membershipId: z.string().nullable().optional() membershipId: z.string().nullable().optional(),
groupId: z.string().nullable().optional()
}) })
.optional() .optional()
.nullable(), .nullable(),
@@ -44,7 +44,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional(),
acmeConfig: z.object({}).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -55,6 +56,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
if (data.apiConfig) { if (data.apiConfig) {
return false; return false;
} }
if (data.acmeConfig) {
return false;
}
} }
if (data.enrollmentType === EnrollmentType.API) { if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) { if (!data.apiConfig) {
@@ -63,12 +67,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
if (data.estConfig) { if (data.estConfig) {
return false; 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; return true;
}, },
{ {
message: 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: { response: {
@@ -150,6 +168,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(), autoRenew: z.boolean(),
renewBeforeDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional(),
acmeConfig: z
.object({
id: z.string(),
directoryUrl: z.string()
})
.optional() .optional()
}).array(), }).array(),
totalCount: z.number() totalCount: z.number()
@@ -473,4 +497,101 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
return { certificates }; 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 };
}
});
}; };
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-floating-promises */ /* eslint-disable @typescript-eslint/no-floating-promises */
import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { CertificatesSchema } from "@app/db/schemas"; import { CertificatesSchema } from "@app/db/schemas";
@@ -616,4 +617,64 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
}; };
} }
}); });
server.route({
method: "POST",
url: "/:serialNumber/pkcs12",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
hide: true,
tags: [ApiDocsTags.PkiCertificates],
description: "Download certificate in PKCS12 format",
params: z.object({
serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber)
}),
body: z.object({
password: z
.string()
.min(6, "Password must be at least 6 characters long")
.describe("Password for the keystore (minimum 6 characters)"),
alias: z.string().min(1, "Alias is required").describe("Alias for the certificate in the keystore")
}),
response: {
200: z.any().describe("PKCS12 keystore as binary data")
}
},
handler: async (req, reply) => {
const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({
serialNumber: req.params.serialNumber,
password: req.body.password,
alias: req.body.alias,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: cert.projectId,
event: {
type: EventType.EXPORT_CERT_PKCS12,
metadata: {
certId: cert.id,
cn: cert.commonName,
serialNumber: cert.serialNumber
}
}
});
addNoCacheHeaders(reply);
reply.header("Content-Type", "application/octet-stream");
reply.header(
"Content-Disposition",
`attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"`
);
return pkcs12Data;
}
});
}; };
@@ -614,8 +614,10 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
integrationId: z.string(), integrationId: z.string(),
accessRequestChannels: validateSlackChannelsField, accessRequestChannels: validateSlackChannelsField,
secretRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField,
secretSyncErrorChannels: validateSlackChannelsField,
isAccessRequestNotificationEnabled: z.boolean(), isAccessRequestNotificationEnabled: z.boolean(),
isSecretRequestNotificationEnabled: z.boolean() isSecretRequestNotificationEnabled: z.boolean(),
isSecretSyncErrorNotificationEnabled: z.boolean()
}), }),
z.object({ z.object({
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
@@ -633,7 +635,9 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
isAccessRequestNotificationEnabled: true, isAccessRequestNotificationEnabled: true,
accessRequestChannels: true, accessRequestChannels: true,
isSecretRequestNotificationEnabled: true, isSecretRequestNotificationEnabled: true,
secretRequestChannels: true secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge( }).merge(
z.object({ z.object({
integration: z.literal(WorkflowIntegration.SLACK), integration: z.literal(WorkflowIntegration.SLACK),
@@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; 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) => { export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
@@ -16,7 +17,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: { schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting], tags: [ApiDocsTags.PkiAlerting],
description: "Create PKI alert", description: "Create PKI alert",
body: z.object({ body: z.object({
@@ -52,7 +52,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
pkiAlertId: alert.id, pkiAlertId: alert.id,
pkiCollectionId: alert.pkiCollectionId, pkiCollectionId: alert.pkiCollectionId,
name: alert.name, name: alert.name,
alertBeforeDays: alert.alertBeforeDays, alertBefore: alert.alertBeforeDays.toString(),
eventType: PkiAlertEventType.EXPIRATION,
recipientEmails: alert.recipientEmails recipientEmails: alert.recipientEmails
} }
} }
@@ -70,7 +71,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: { schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting], tags: [ApiDocsTags.PkiAlerting],
description: "Get PKI alert", description: "Get PKI alert",
params: z.object({ params: z.object({
@@ -112,7 +112,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: { schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting], tags: [ApiDocsTags.PkiAlerting],
description: "Update PKI alert", description: "Update PKI alert",
params: z.object({ params: z.object({
@@ -152,7 +151,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
pkiAlertId: alert.id, pkiAlertId: alert.id,
pkiCollectionId: alert.pkiCollectionId, pkiCollectionId: alert.pkiCollectionId,
name: alert.name, name: alert.name,
alertBeforeDays: alert.alertBeforeDays, alertBefore: alert.alertBeforeDays.toString(),
eventType: PkiAlertEventType.EXPIRATION,
recipientEmails: alert.recipientEmails recipientEmails: alert.recipientEmails
} }
} }
@@ -170,7 +170,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: { schema: {
hide: false,
tags: [ApiDocsTags.PkiAlerting], tags: [ApiDocsTags.PkiAlerting],
description: "Delete PKI alert", description: "Delete PKI alert",
params: z.object({ params: z.object({
+13 -5
View File
@@ -162,7 +162,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
.default(InfisicalProjectTemplate.Default) .default(InfisicalProjectTemplate.Default)
.describe(PROJECTS.CREATE.template), .describe(PROJECTS.CREATE.template),
type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager),
shouldCreateDefaultEnvs: z.boolean().optional().default(true) shouldCreateDefaultEnvs: z.boolean().optional().default(true),
hasDeleteProtection: z.boolean().optional().default(false)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -183,7 +184,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
kmsKeyId: req.body.kmsKeyId, kmsKeyId: req.body.kmsKeyId,
template: req.body.template, template: req.body.template,
type: req.body.type, type: req.body.type,
createDefaultEnvs: req.body.shouldCreateDefaultEnvs createDefaultEnvs: req.body.shouldCreateDefaultEnvs,
hasDeleteProtection: req.body.hasDeleteProtection
}); });
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
@@ -769,7 +771,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true, isAccessRequestNotificationEnabled: true,
accessRequestChannels: true, accessRequestChannels: true,
isSecretRequestNotificationEnabled: true, isSecretRequestNotificationEnabled: true,
secretRequestChannels: true secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge( }).merge(
z.object({ z.object({
integration: z.literal(WorkflowIntegration.SLACK), integration: z.literal(WorkflowIntegration.SLACK),
@@ -873,7 +877,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
accessRequestChannels: validateSlackChannelsField, accessRequestChannels: validateSlackChannelsField,
secretRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField,
isAccessRequestNotificationEnabled: z.boolean(), isAccessRequestNotificationEnabled: z.boolean(),
isSecretRequestNotificationEnabled: z.boolean() isSecretRequestNotificationEnabled: z.boolean(),
secretSyncErrorChannels: validateSlackChannelsField,
isSecretSyncErrorNotificationEnabled: z.boolean()
}), }),
z.object({ z.object({
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
@@ -891,7 +897,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
isAccessRequestNotificationEnabled: true, isAccessRequestNotificationEnabled: true,
accessRequestChannels: true, accessRequestChannels: true,
isSecretRequestNotificationEnabled: true, isSecretRequestNotificationEnabled: true,
secretRequestChannels: true secretRequestChannels: true,
isSecretSyncErrorNotificationEnabled: true,
secretSyncErrorChannels: true
}).merge( }).merge(
z.object({ z.object({
integration: z.literal(WorkflowIntegration.SLACK), integration: z.literal(WorkflowIntegration.SLACK),
+8 -6
View File
@@ -550,12 +550,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
providerAuthToken: req.body.providerAuthToken providerAuthToken: req.body.providerAuthToken
}); });
void res.setCookie("jid", data.token.refresh, { if ([AuthMethod.GOOGLE, AuthMethod.GITHUB, AuthMethod.GITLAB].includes(data.decodedProviderToken.authMethod)) {
httpOnly: true, void res.setCookie("jid", data.token.refresh, {
path: "/", httpOnly: true,
sameSite: "strict", path: "/",
secure: appCfg.HTTPS_ENABLED sameSite: "strict",
}); secure: appCfg.HTTPS_ENABLED
});
}
addAuthOriginDomainCookie(res); addAuthOriginDomainCookie(res);
@@ -110,7 +110,8 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
.default(InfisicalProjectTemplate.Default) .default(InfisicalProjectTemplate.Default)
.describe(PROJECTS.CREATE.template), .describe(PROJECTS.CREATE.template),
type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager),
shouldCreateDefaultEnvs: z.boolean().optional().default(true) shouldCreateDefaultEnvs: z.boolean().optional().default(true),
hasDeleteProtection: z.boolean().optional().default(false)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -131,7 +132,8 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
kmsKeyId: req.body.kmsKeyId, kmsKeyId: req.body.kmsKeyId,
template: req.body.template, template: req.body.template,
type: req.body.type, type: req.body.type,
createDefaultEnvs: req.body.shouldCreateDefaultEnvs createDefaultEnvs: req.body.shouldCreateDefaultEnvs,
hasDeleteProtection: req.body.hasDeleteProtection
}); });
await server.services.telemetry.sendPostHogEvents({ await server.services.telemetry.sendPostHogEvents({
+2
View File
@@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router";
import { registerMfaRouter } from "./mfa-router"; import { registerMfaRouter } from "./mfa-router";
import { registerOrgRouter } from "./organization-router"; import { registerOrgRouter } from "./organization-router";
import { registerPasswordRouter } from "./password-router"; import { registerPasswordRouter } from "./password-router";
import { registerPkiAlertRouter } from "./pki-alert-router";
import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerPkiTemplatesRouter } from "./pki-templates-router";
import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretFolderRouter } from "./secret-folder-router";
import { registerSecretImportRouter } from "./secret-import-router"; import { registerSecretImportRouter } from "./secret-import-router";
@@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
async (pkiRouter) => { async (pkiRouter) => {
await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" });
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
}, },
{ prefix: "/pki" } { prefix: "/pki" }
); );
@@ -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;
}
});
};
@@ -20,6 +20,7 @@ import {
} from "@app/services/certificate-common/certificate-constants"; } from "@app/services/certificate-common/certificate-constants";
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
interface CertificateRequestForService { interface CertificateRequestForService {
@@ -204,7 +205,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
ttl: req.body.ttl ttl: req.body.ttl
}, },
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
enrollmentType: EnrollmentType.API
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
+1 -1
View File
@@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" });
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
await server.register(registerCertificatesRouter, { prefix: "/certificates" }); await server.register(registerCertificatesRouter, { prefix: "/pki/certificates" });
}; };
@@ -1,4 +1,5 @@
export enum AzureClientSecretsConnectionMethod { export enum AzureClientSecretsConnectionMethod {
OAuth = "oauth", OAuth = "oauth",
ClientSecret = "client-secret" ClientSecret = "client-secret",
Certificate = "certificate"
} }
@@ -1,9 +1,14 @@
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
import { AxiosError, AxiosResponse } from "axios"; 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 { getConfig } from "@app/lib/config/env";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { import {
decryptAppConnectionCredentials, decryptAppConnectionCredentials,
encryptAppConnectionCredentials, encryptAppConnectionCredentials,
@@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums";
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
import { import {
ExchangeCodeAzureResponse, ExchangeCodeAzureResponse,
TAzureClientSecretsConnectionCertificateCredentials,
TAzureClientSecretsConnectionClientSecretCredentials, TAzureClientSecretsConnectionClientSecretCredentials,
TAzureClientSecretsConnectionConfig, TAzureClientSecretsConnectionConfig,
TAzureClientSecretsConnectionCredentials TAzureClientSecretsConnectionCredentials
} from "./azure-client-secrets-connection-types"; } 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 = () => { export const getAzureClientSecretsConnectionListItem = () => {
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig(); const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
@@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => {
app: AppConnection.AzureClientSecrets as const, app: AppConnection.AzureClientSecrets as const,
methods: Object.values(AzureClientSecretsConnectionMethod) as [ methods: Object.values(AzureClientSecretsConnectionMethod) as [
AzureClientSecretsConnectionMethod.OAuth, AzureClientSecretsConnectionMethod.OAuth,
AzureClientSecretsConnectionMethod.ClientSecret AzureClientSecretsConnectionMethod.ClientSecret,
AzureClientSecretsConnectionMethod.Certificate
], ],
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
}; };
@@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async (
const { refreshToken } = credentials; const { refreshToken } = credentials;
const currentTime = Date.now(); const currentTime = Date.now();
switch (appConnection.method) { switch (appConnection.method) {
case AzureClientSecretsConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: {
if ( if (
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
@@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async (
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
return data.access_token; return data.access_token;
case AzureClientSecretsConnectionMethod.ClientSecret: }
case AzureClientSecretsConnectionMethod.ClientSecret: {
const accessTokenCredentials = (await decryptAppConnectionCredentials({ const accessTokenCredentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId, orgId: appConnection.orgId,
projectId: appConnection.projectId, projectId: appConnection.projectId,
@@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async (
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
return clientData.access_token; 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<ExchangeCodeAzureResponse>(
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: default:
throw new InternalServerError({ throw new InternalServerError({
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}` message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
@@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
} = getConfig(); } = getConfig();
switch (method) { switch (method) {
case AzureClientSecretsConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: {
if (!SITE_URL) { if (!SITE_URL) {
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); 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, refreshToken: tokenResp.data.refresh_token,
expiresAt: Date.now() + tokenResp.data.expires_in * 1000 expiresAt: Date.now() + tokenResp.data.expires_in * 1000
}; };
}
case AzureClientSecretsConnectionMethod.ClientSecret: case AzureClientSecretsConnectionMethod.ClientSecret: {
const { tenantId, clientId, clientSecret } = inputCredentials; const { tenantId, clientId, clientSecret } = inputCredentials;
try { try {
const { data: clientData } = await request.post<ExchangeCodeAzureResponse>( const { data: clientData } = await request.post<ExchangeCodeAzureResponse>(
@@ -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<ExchangeCodeAzureResponse>(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: default:
throw new InternalServerError({ throw new InternalServerError({
message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}`
@@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z.
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) .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({ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({
clientId: z.string(), clientId: z.string(),
clientSecret: z.string(), clientSecret: z.string(),
@@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z
expiresAt: z.number() 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", [ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({ z.object({
method: z method: z
@@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe( credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials 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 credentials: z
.union([ .union([
AzureClientSecretsConnectionOAuthInputCredentialsSchema, AzureClientSecretsConnectionOAuthInputCredentialsSchema,
AzureClientSecretsConnectionClientSecretInputCredentialsSchema AzureClientSecretsConnectionClientSecretInputCredentialsSchema,
AzureClientSecretsConnectionCertificateInputCredentialsSchema
]) ])
.optional() .optional()
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials) .describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
@@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection(
z.object({ z.object({
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
}),
z.object({
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema
}) })
]) ])
); );
@@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion(
clientId: true, clientId: true,
tenantId: true tenantId: true
}) })
}),
BaseAzureClientSecretsConnectionSchema.extend({
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({
tenantId: true,
clientId: true
})
}) })
]); ]);
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums"; import { AppConnection } from "../app-connection-enums";
import { import {
AzureClientSecretsConnectionCertificateOutputCredentialsSchema,
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema, AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
AzureClientSecretsConnectionOAuthOutputCredentialsSchema, AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
AzureClientSecretsConnectionSchema, AzureClientSecretsConnectionSchema,
@@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer<
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
>; >;
export type TAzureClientSecretsConnectionCertificateCredentials = z.infer<
typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema
>;
export interface ExchangeCodeAzureResponse { export interface ExchangeCodeAzureResponse {
token_type: string; token_type: string;
scope: string; scope: string;
+1
View File
@@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
IDENTITY = "identity", IDENTITY = "identity",
Machine = "machine", Machine = "machine",
SCIM_CLIENT = "scimClient", SCIM_CLIENT = "scimClient",
ACME_ACCOUNT = "acmeAccount",
UNKNOWN_USER = "unknownUser" UNKNOWN_USER = "unknownUser"
} }
@@ -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<TCertificateProfileWithConfigs | undefined> => { const findByIdWithConfigs = async (id: string, tx?: Knex): Promise<TCertificateProfileWithConfigs | undefined> => {
try { try {
const query = (tx || db)(TableName.PkiCertificateProfile) const query = (tx || db)(TableName.PkiCertificateProfile)
@@ -88,6 +105,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
`${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id` `${TableName.PkiApiEnrollmentConfig}.id`
) )
.leftJoin(
TableName.PkiAcmeEnrollmentConfig,
`${TableName.PkiCertificateProfile}.acmeConfigId`,
`${TableName.PkiAcmeEnrollmentConfig}.id`
)
.select(selectAllTableCols(TableName.PkiCertificateProfile)) .select(selectAllTableCols(TableName.PkiCertificateProfile))
.select( .select(
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), 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("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
db.ref("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) .where(`${TableName.PkiCertificateProfile}.id`, id)
.first(); .first();
@@ -134,6 +158,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} as TCertificateProfileWithConfigs["apiConfig"]) } as TCertificateProfileWithConfigs["apiConfig"])
: undefined; : undefined;
const acmeConfig = result.acmeConfigId
? ({
id: result.acmeConfigId,
encryptedEabSecret: result.acmeConfigEncryptedEabSecret
} as TCertificateProfileWithConfigs["acmeConfig"])
: undefined;
const certificateAuthority = const certificateAuthority =
result.caId && result.caProjectId && result.caStatus && result.caName result.caId && result.caProjectId && result.caStatus && result.caName
? ({ ? ({
@@ -164,10 +195,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
enrollmentType: result.enrollmentType as EnrollmentType, enrollmentType: result.enrollmentType as EnrollmentType,
estConfigId: result.estConfigId, estConfigId: result.estConfigId,
apiConfigId: result.apiConfigId, apiConfigId: result.apiConfigId,
acmeConfigId: result.acmeConfigId,
createdAt: result.createdAt, createdAt: result.createdAt,
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
estConfig, estConfig,
apiConfig, apiConfig,
acmeConfig,
certificateAuthority, certificateAuthority,
certificateTemplate certificateTemplate
}; };
@@ -241,6 +274,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
`${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiCertificateProfile}.apiConfigId`,
`${TableName.PkiApiEnrollmentConfig}.id` `${TableName.PkiApiEnrollmentConfig}.id`
) )
.leftJoin(
TableName.PkiAcmeEnrollmentConfig,
`${TableName.PkiCertificateProfile}.acmeConfigId`,
`${TableName.PkiAcmeEnrollmentConfig}.id`
)
.select(selectAllTableCols(TableName.PkiCertificateProfile)) .select(selectAllTableCols(TableName.PkiCertificateProfile))
.select( .select(
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), 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("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("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 const results = (await query
@@ -279,6 +318,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
: undefined; : undefined;
const acmeConfig = result.acmeId
? {
id: result.acmeId as string
}
: undefined;
const baseProfile = { const baseProfile = {
id: result.id, id: result.id,
projectId: result.projectId, projectId: result.projectId,
@@ -292,7 +337,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
createdAt: result.createdAt, createdAt: result.createdAt,
updatedAt: result.updatedAt, updatedAt: result.updatedAt,
estConfig, estConfig,
apiConfig apiConfig,
acmeConfig
}; };
return baseProfile as TCertificateProfileWithConfigs; 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) => { const isProfileInUse = async (profileId: string, tx?: Knex) => {
try { try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
@@ -432,12 +496,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
updateById, updateById,
deleteById, deleteById,
findById, findById,
findByIdWithOwnerOrgId,
findByIdWithConfigs, findByIdWithConfigs,
findBySlugAndProjectId, findBySlugAndProjectId,
findByProjectId, findByProjectId,
countByProjectId, countByProjectId,
findByNameAndProjectId, findByNameAndProjectId,
getCertificatesByProfile, getCertificatesByProfile,
getLatestActiveCertificateForProfile,
isProfileInUse isProfileInUse
}; };
}; };
@@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional(),
acmeConfig: z.object({}).optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z
if (data.apiConfig) { if (data.apiConfig) {
return false; return false;
} }
if (data.acmeConfig) {
return false;
}
} }
if (data.enrollmentType === EnrollmentType.API) { if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) { if (!data.apiConfig) {
@@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z
if (data.estConfig) { if (data.estConfig) {
return false; 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; return true;
}, },
@@ -9,7 +9,12 @@ import type { TPermissionServiceFactory } from "@app/ee/services/permission/perm
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType, AuthMethod } from "../auth/auth-type"; 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 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 { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
import type { TKmsServiceFactory } from "../kms/kms-service"; import type { TKmsServiceFactory } from "../kms/kms-service";
@@ -142,6 +147,17 @@ describe("CertificateProfileService", () => {
delete: vi.fn() delete: vi.fn()
} as unknown as TEstEnrollmentConfigDALFactory; } 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 = { const mockPermissionService = {
getProjectPermission: vi.fn().mockResolvedValue({ getProjectPermission: vi.fn().mockResolvedValue({
permission: { permission: {
@@ -166,6 +182,54 @@ describe("CertificateProfileService", () => {
transaction: vi.fn() transaction: vi.fn()
} as unknown as Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">; } as unknown as Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
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(() => { beforeEach(() => {
vi.spyOn(ForbiddenError, "from").mockReturnValue({ vi.spyOn(ForbiddenError, "from").mockReturnValue({
throwUnlessCan: vi.fn() throwUnlessCan: vi.fn()
@@ -182,6 +246,11 @@ describe("CertificateProfileService", () => {
certificateTemplateV2DAL: mockCertificateTemplateV2DAL, certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
certificateBodyDAL: mockCertificateBodyDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
permissionService: mockPermissionService, permissionService: mockPermissionService,
kmsService: mockKmsService, kmsService: mockKmsService,
projectDAL: mockProjectDAL projectDAL: mockProjectDAL
@@ -234,6 +303,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfigId: "api-config-123", apiConfigId: "api-config-123",
estConfigId: null, estConfigId: null,
acmeConfigId: null,
projectId: "project-123" projectId: "project-123"
}, },
undefined undefined
@@ -4,19 +4,26 @@ import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions, ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "../auth/auth-type"; 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 { 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 { 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 { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "../kms/kms-service"; import { TKmsServiceFactory } from "../kms/kms-service";
import { TProjectDALFactory } from "../project/project-dal"; import { TProjectDALFactory } from "../project/project-dal";
@@ -31,6 +38,36 @@ import {
TCertificateProfileWithConfigs TCertificateProfileWithConfigs
} from "./certificate-profile-types"; } from "./certificate-profile-types";
const generateAndEncryptAcmeEabSecret = async (
projectId: string,
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction">
) => {
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 ( const validateAndEncryptPemCaChain = async (
caChain: string, caChain: string,
projectId: string, projectId: string,
@@ -95,9 +132,13 @@ const decryptCaChain = async (
} }
}; };
export type TCertificateProfileCreateData = Omit<TCertificateProfileInsert, "estConfigId" | "apiConfigId"> & { export type TCertificateProfileCreateData = Omit<
TCertificateProfileInsert,
"estConfigId" | "apiConfigId" | "acmeConfigId"
> & {
estConfig?: TEstConfigData; estConfig?: TEstConfigData;
apiConfig?: TApiConfigData; apiConfig?: TApiConfigData;
acmeConfig?: TAcmeConfigData;
}; };
type TCertificateProfileServiceFactoryDep = { type TCertificateProfileServiceFactoryDep = {
@@ -105,6 +146,11 @@ type TCertificateProfileServiceFactoryDep = {
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">; kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
@@ -124,6 +170,9 @@ export const certificateProfileServiceFactory = ({
certificateTemplateV2DAL, certificateTemplateV2DAL,
apiEnrollmentConfigDAL, apiEnrollmentConfigDAL,
estEnrollmentConfigDAL, estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
permissionService, permissionService,
kmsService, kmsService,
projectDAL projectDAL
@@ -188,11 +237,14 @@ export const certificateProfileServiceFactory = ({
message: "API enrollment requires API configuration" 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 // Create enrollment configs and profile
const profile = await certificateProfileDAL.transaction(async (tx) => { const profile = await certificateProfileDAL.transaction(async (tx) => {
let estConfigId: string | null = null; let estConfigId: string | null = null;
let apiConfigId: string | null = null; let apiConfigId: string | null = null;
let acmeConfigId: string | null = null;
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -228,16 +280,21 @@ export const certificateProfileServiceFactory = ({
tx tx
); );
apiConfigId = apiConfig.id; 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 // Create the profile with the created config IDs
const { estConfig, apiConfig, ...profileData } = data; const { estConfig, apiConfig, acmeConfig, ...profileData } = data;
const profileResult = await certificateProfileDAL.create( const profileResult = await certificateProfileDAL.create(
{ {
...profileData, ...profileData,
projectId, projectId,
estConfigId, estConfigId,
apiConfigId apiConfigId,
acmeConfigId
}, },
tx tx
); );
@@ -439,6 +496,12 @@ export const certificateProfileServiceFactory = ({
profile.estConfig.caChain = ""; 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 { return {
...profile, ...profile,
@@ -574,7 +637,10 @@ export const certificateProfileServiceFactory = ({
const result: TCertificateProfileWithConfigs = { const result: TCertificateProfileWithConfigs = {
...converted, ...converted,
estConfig: decryptedEstConfig, estConfig: decryptedEstConfig,
apiConfig: profileWithConfigs.apiConfig apiConfig: profileWithConfigs.apiConfig,
acmeConfig: profileWithConfigs.acmeConfig
? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") }
: undefined
}; };
return result; return result;
@@ -674,6 +740,106 @@ export const certificateProfileServiceFactory = ({
return certificates; 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 ( const getEstConfigurationByProfile = async (
params: 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 { return {
createProfile, createProfile,
updateProfile, updateProfile,
@@ -746,6 +965,8 @@ export const certificateProfileServiceFactory = ({
listProfiles, listProfiles,
deleteProfile, deleteProfile,
getProfileCertificates, getProfileCertificates,
getEstConfigurationByProfile getLatestActiveCertificateBundle,
getEstConfigurationByProfile,
revealAcmeEabSecret
}; };
}; };
@@ -6,7 +6,8 @@ import {
export enum EnrollmentType { export enum EnrollmentType {
API = "api", API = "api",
EST = "est" EST = "est",
ACME = "acme"
} }
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & { export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & {
@@ -28,6 +29,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
autoRenew?: boolean; autoRenew?: boolean;
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
acmeConfig?: unknown;
}; };
export type TCertificateProfileWithConfigs = TCertificateProfile & { export type TCertificateProfileWithConfigs = TCertificateProfile & {
@@ -54,6 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
autoRenew: boolean; autoRenew: boolean;
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
acmeConfig?: {
id: string;
directoryUrl: string;
encryptedEabSecret?: Buffer;
};
}; };
export interface TCertificateProfileCertificate { export interface TCertificateProfileCertificate {

Some files were not shown because too many files have changed in this diff Show More