mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 19:28:58 +00:00
Merge branch 'main' into feature/secret-reference-shortcut
This commit is contained in:
@@ -52,3 +52,6 @@ docs/integrations/app-connections/railway.mdx:generic-api-key:156
|
||||
.github/workflows/validate-db-schemas.yml:generic-api-key:21
|
||||
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
|
||||
docs/integrations/app-connections/redis.mdx:generic-api-key:80
|
||||
backend/src/ee/services/app-connections/chef/chef-connection-fns.ts:private-key:42
|
||||
docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
|
||||
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
Generated
+558
@@ -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" },
|
||||
]
|
||||
Generated
+23
-3
@@ -83,6 +83,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"jose": "^6.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsrp": "^0.2.4",
|
||||
@@ -23236,9 +23237,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "4.15.5",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz",
|
||||
"integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==",
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
|
||||
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
@@ -23638,6 +23640,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa/node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jwks-rsa/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -27590,6 +27601,15 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client/node_modules/jose": {
|
||||
"version": "4.15.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
|
||||
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/openssl-wrapper": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz",
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"isomorphic-dompurify": "^2.22.0",
|
||||
"jmespath": "^0.16.0",
|
||||
"jose": "^6.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsrp": "^0.2.4",
|
||||
|
||||
Vendored
+4
@@ -32,6 +32,7 @@ import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-re
|
||||
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
import { TPkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-types";
|
||||
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
|
||||
import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types";
|
||||
import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service";
|
||||
@@ -101,6 +102,7 @@ import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-r
|
||||
import { TOrgServiceFactory } from "@app/services/org/org-service";
|
||||
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
||||
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
||||
import { TPkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service";
|
||||
import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
||||
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
|
||||
@@ -294,6 +296,7 @@ declare module "fastify" {
|
||||
certificateAuthority: TCertificateAuthorityServiceFactory;
|
||||
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
|
||||
certificateEst: TCertificateEstServiceFactory;
|
||||
pkiAcme: TPkiAcmeServiceFactory;
|
||||
certificateEstV3: TCertificateEstV3ServiceFactory;
|
||||
pkiCollection: TPkiCollectionServiceFactory;
|
||||
pkiSubscriber: TPkiSubscriberServiceFactory;
|
||||
@@ -353,6 +356,7 @@ declare module "fastify" {
|
||||
role: TRoleServiceFactory;
|
||||
convertor: TConvertorServiceFactory;
|
||||
subOrganization: TSubOrgServiceFactory;
|
||||
pkiAlertV2: TPkiAlertV2ServiceFactory;
|
||||
};
|
||||
// this is exclusive use for middlewares in which we need to inject data
|
||||
// everywhere else access using service layer
|
||||
|
||||
Vendored
+72
@@ -266,9 +266,39 @@ import {
|
||||
TOrgRoles,
|
||||
TOrgRolesInsert,
|
||||
TOrgRolesUpdate,
|
||||
TPkiAcmeAccounts,
|
||||
TPkiAcmeAccountsInsert,
|
||||
TPkiAcmeAccountsUpdate,
|
||||
TPkiAcmeAuths,
|
||||
TPkiAcmeAuthsInsert,
|
||||
TPkiAcmeAuthsUpdate,
|
||||
TPkiAcmeChallenges,
|
||||
TPkiAcmeChallengesInsert,
|
||||
TPkiAcmeChallengesUpdate,
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate,
|
||||
TPkiAcmeOrderAuths,
|
||||
TPkiAcmeOrderAuthsInsert,
|
||||
TPkiAcmeOrderAuthsUpdate,
|
||||
TPkiAcmeOrders,
|
||||
TPkiAcmeOrdersInsert,
|
||||
TPkiAcmeOrdersUpdate,
|
||||
TPkiAlertChannels,
|
||||
TPkiAlertChannelsInsert,
|
||||
TPkiAlertChannelsUpdate,
|
||||
TPkiAlertHistory,
|
||||
TPkiAlertHistoryCertificate,
|
||||
TPkiAlertHistoryCertificateInsert,
|
||||
TPkiAlertHistoryCertificateUpdate,
|
||||
TPkiAlertHistoryInsert,
|
||||
TPkiAlertHistoryUpdate,
|
||||
TPkiAlerts,
|
||||
TPkiAlertsInsert,
|
||||
TPkiAlertsUpdate,
|
||||
TPkiAlertsV2,
|
||||
TPkiAlertsV2Insert,
|
||||
TPkiAlertsV2Update,
|
||||
TPkiApiEnrollmentConfigs,
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
TPkiApiEnrollmentConfigsUpdate,
|
||||
@@ -709,6 +739,32 @@ declare module "knex/types/tables" {
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
TPkiApiEnrollmentConfigsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeEnrollmentConfig]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeAccount]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeAccounts,
|
||||
TPkiAcmeAccountsInsert,
|
||||
TPkiAcmeAccountsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeOrder]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeOrders,
|
||||
TPkiAcmeOrdersInsert,
|
||||
TPkiAcmeOrdersUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType<TPkiAcmeAuths, TPkiAcmeAuthsInsert, TPkiAcmeAuthsUpdate>;
|
||||
[TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeOrderAuths,
|
||||
TPkiAcmeOrderAuthsInsert,
|
||||
TPkiAcmeOrderAuthsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeChallenges,
|
||||
TPkiAcmeChallengesInsert,
|
||||
TPkiAcmeChallengesUpdate
|
||||
>;
|
||||
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
|
||||
TCertificateTemplateEstConfigs,
|
||||
TCertificateTemplateEstConfigsInsert,
|
||||
@@ -725,6 +781,22 @@ declare module "knex/types/tables" {
|
||||
TCertificateSecretsUpdate
|
||||
>;
|
||||
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<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<
|
||||
TPkiCollections,
|
||||
TPkiCollectionsInsert,
|
||||
|
||||
+31
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,4 @@ import { Knex } from "knex";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
export const dropConstraintIfExists = (tableName: TableName, constraintName: string, knex: Knex) =>
|
||||
knex.raw(`ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${constraintName};`);
|
||||
knex.raw("ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??;", [tableName, constraintName]);
|
||||
|
||||
@@ -92,7 +92,17 @@ export * from "./pam-accounts";
|
||||
export * from "./pam-folders";
|
||||
export * from "./pam-resources";
|
||||
export * from "./pam-sessions";
|
||||
export * from "./pki-acme-accounts";
|
||||
export * from "./pki-acme-auths";
|
||||
export * from "./pki-acme-challenges";
|
||||
export * from "./pki-acme-enrollment-configs";
|
||||
export * from "./pki-acme-order-auths";
|
||||
export * from "./pki-acme-orders";
|
||||
export * from "./pki-alert-channels";
|
||||
export * from "./pki-alert-history";
|
||||
export * from "./pki-alert-history-certificate";
|
||||
export * from "./pki-alerts";
|
||||
export * from "./pki-alerts-v2";
|
||||
export * from "./pki-api-enrollment-configs";
|
||||
export * from "./pki-certificate-profiles";
|
||||
export * from "./pki-certificate-templates-v2";
|
||||
|
||||
@@ -27,8 +27,13 @@ export enum TableName {
|
||||
PkiCertificateProfile = "pki_certificate_profiles",
|
||||
PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
|
||||
PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
|
||||
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
|
||||
PkiSubscriber = "pki_subscribers",
|
||||
PkiAlert = "pki_alerts",
|
||||
PkiAlertsV2 = "pki_alerts_v2",
|
||||
PkiAlertChannels = "pki_alert_channels",
|
||||
PkiAlertHistory = "pki_alert_history",
|
||||
PkiAlertHistoryCertificate = "pki_alert_history_certificate",
|
||||
PkiCollection = "pki_collections",
|
||||
PkiCollectionItem = "pki_collection_items",
|
||||
Groups = "groups",
|
||||
@@ -210,7 +215,14 @@ export enum TableName {
|
||||
PamAccount = "pam_accounts",
|
||||
PamSession = "pam_sessions",
|
||||
|
||||
VaultExternalMigrationConfig = "vault_external_migration_configs"
|
||||
VaultExternalMigrationConfig = "vault_external_migration_configs",
|
||||
|
||||
// PKI ACME
|
||||
PkiAcmeAccount = "pki_acme_accounts",
|
||||
PkiAcmeOrder = "pki_acme_orders",
|
||||
PkiAcmeOrderAuth = "pki_acme_order_auths",
|
||||
PkiAcmeAuth = "pki_acme_auths",
|
||||
PkiAcmeChallenge = "pki_acme_challenges"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||
|
||||
@@ -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>>;
|
||||
@@ -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>>;
|
||||
@@ -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>>;
|
||||
@@ -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(),
|
||||
apiConfigId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
acmeConfigId: z.string().uuid().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
|
||||
|
||||
@@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({
|
||||
isSecretRequestNotificationEnabled: z.boolean().default(false),
|
||||
secretRequestChannels: z.string().default(""),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
isSecretSyncErrorNotificationEnabled: z.boolean().default(false),
|
||||
secretSyncErrorChannels: z.string().default("")
|
||||
});
|
||||
|
||||
export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
|
||||
import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router";
|
||||
import { registerAccessApprovalRequestRouter } from "./access-approval-request-router";
|
||||
@@ -30,6 +31,7 @@ import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers";
|
||||
import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router";
|
||||
import { registerPamSessionRouter } from "./pam-session-router";
|
||||
import { registerPITRouter } from "./pit-router";
|
||||
import { registerPkiAcmeRouter } from "./pki-acme-router";
|
||||
import { registerProjectRoleRouter } from "./project-role-router";
|
||||
import { registerProjectRouter } from "./project-router";
|
||||
import { registerRateLimitRouter } from "./rate-limit-router";
|
||||
@@ -107,6 +109,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
|
||||
await server.register(
|
||||
async (pkiRouter) => {
|
||||
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" });
|
||||
// Notice: current this feature is still in development and is not yet ready for production.
|
||||
if (getConfig().isAcmeFeatureEnabled === true) {
|
||||
await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" });
|
||||
}
|
||||
},
|
||||
{ prefix: "/pki" }
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
status: z.string(),
|
||||
comment: z.string().optional(),
|
||||
createdAt: z.date(),
|
||||
isOrgMembershipActive: z.boolean().nullable().optional()
|
||||
})
|
||||
.array(),
|
||||
|
||||
@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
);
|
||||
|
||||
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||
const approvalPath = `/projects/secret-management/${project.id}/approval`;
|
||||
const projectPath = `/projects/secret-management/${project.id}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
await triggerWorkflowIntegrationNotification({
|
||||
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
type: TriggerFeature.ACCESS_REQUEST,
|
||||
payload: {
|
||||
projectName: project.name,
|
||||
projectPath,
|
||||
requesterFullName,
|
||||
isTemporary,
|
||||
requesterEmail: requestedByUser.email as string,
|
||||
@@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
|
||||
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
|
||||
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
|
||||
const approvalPath = `/projects/secret-management/${project.id}/approval`;
|
||||
const projectPath = `/projects/secret-management/${project.id}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
await triggerWorkflowIntegrationNotification({
|
||||
@@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
approvalUrl,
|
||||
editNote,
|
||||
editorEmail: editedByUser.email as string,
|
||||
editorFullName
|
||||
editorFullName,
|
||||
projectPath
|
||||
}
|
||||
},
|
||||
projectId: project.id
|
||||
|
||||
@@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi
|
||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||
import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types";
|
||||
import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types";
|
||||
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
||||
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||
import {
|
||||
@@ -370,6 +371,7 @@ export enum EventType {
|
||||
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
|
||||
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
|
||||
RENEW_CERTIFICATE = "renew-certificate",
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
|
||||
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
|
||||
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
|
||||
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
|
||||
@@ -2318,10 +2320,11 @@ interface CreatePkiAlert {
|
||||
type: EventType.CREATE_PKI_ALERT;
|
||||
metadata: {
|
||||
pkiAlertId: string;
|
||||
pkiCollectionId: string;
|
||||
pkiCollectionId?: string;
|
||||
name: string;
|
||||
alertBeforeDays: number;
|
||||
recipientEmails: string;
|
||||
alertBefore: string;
|
||||
eventType: PkiAlertEventType;
|
||||
recipientEmails?: string;
|
||||
};
|
||||
}
|
||||
interface GetPkiAlert {
|
||||
@@ -2337,7 +2340,8 @@ interface UpdatePkiAlert {
|
||||
pkiAlertId: string;
|
||||
pkiCollectionId?: string;
|
||||
name?: string;
|
||||
alertBeforeDays?: number;
|
||||
alertBefore?: string;
|
||||
eventType?: PkiAlertEventType;
|
||||
recipientEmails?: string;
|
||||
};
|
||||
}
|
||||
@@ -2749,6 +2753,17 @@ interface OrderCertificateFromProfile {
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCertificateProfileLatestActiveBundle {
|
||||
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE;
|
||||
metadata: {
|
||||
certificateProfileId: string;
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileName: string;
|
||||
serialNumber: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RenewCertificate {
|
||||
type: EventType.RENEW_CERTIFICATE;
|
||||
metadata: {
|
||||
@@ -4279,6 +4294,7 @@ export type Event =
|
||||
| DeleteCertificateProfile
|
||||
| GetCertificateProfile
|
||||
| ListCertificateProfiles
|
||||
| GetCertificateProfileLatestActiveBundle
|
||||
| IssueCertificateFromProfile
|
||||
| SignCertificateFromProfile
|
||||
| OrderCertificateFromProfile
|
||||
|
||||
@@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionCertificateProfileActions.Edit,
|
||||
ProjectPermissionCertificateProfileActions.Create,
|
||||
ProjectPermissionCertificateProfileActions.Delete,
|
||||
ProjectPermissionCertificateProfileActions.IssueCert
|
||||
ProjectPermissionCertificateProfileActions.IssueCert,
|
||||
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
|
||||
ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret
|
||||
],
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
@@ -116,7 +116,9 @@ export enum ProjectPermissionCertificateProfileActions {
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
IssueCert = "issue-cert"
|
||||
IssueCert = "issue-cert",
|
||||
RevealAcmeEabSecret = "reveal-acme-eab-secret",
|
||||
RotateAcmeEabSecret = "rotate-acme-eab-secret"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretSyncActions {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -166,6 +166,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
|
||||
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
|
||||
tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"),
|
||||
tx.ref("createdAt").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerCreatedAt"),
|
||||
tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
|
||||
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
|
||||
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
|
||||
@@ -240,6 +241,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
reviewerUsername: username,
|
||||
reviewerFirstName: firstName,
|
||||
reviewerComment: comment,
|
||||
reviewerCreatedAt: createdAt,
|
||||
reviewerIsOrgMembershipActive: isOrgMembershipActive
|
||||
}) =>
|
||||
userId
|
||||
@@ -251,6 +253,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
lastName,
|
||||
username,
|
||||
comment: comment ?? "",
|
||||
createdAt,
|
||||
isOrgMembershipActive
|
||||
}
|
||||
: undefined
|
||||
|
||||
@@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({
|
||||
type: NotificationType.SECRET_CHANGE_REQUEST,
|
||||
title: "Secret Change Request",
|
||||
body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`,
|
||||
link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
|
||||
link: `/projects/secret-management/${project.id}/approval`
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export const sendApprovalEmailsFn = async ({
|
||||
firstName: reviewerUser.firstName,
|
||||
projectName: project.name,
|
||||
organizationName: project.organization.name,
|
||||
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
|
||||
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval}`
|
||||
},
|
||||
template: SmtpTemplates.SecretApprovalRequestNeedsReview
|
||||
});
|
||||
|
||||
@@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||
const user = await userDAL.findById(actorId);
|
||||
|
||||
const projectPath = `/projects/secret-management/${projectId}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const cfg = getConfig();
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
await triggerWorkflowIntegrationNotification({
|
||||
input: {
|
||||
projectId,
|
||||
@@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretPath,
|
||||
projectId,
|
||||
requestId: secretApprovalRequest.id,
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))],
|
||||
approvalUrl
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1786,6 +1792,11 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
const user = await userDAL.findById(actorId);
|
||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||
|
||||
const projectPath = `/projects/secret-management/${project.id}`;
|
||||
const approvalPath = `${projectPath}/approval`;
|
||||
const cfg = getConfig();
|
||||
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
|
||||
|
||||
await triggerWorkflowIntegrationNotification({
|
||||
input: {
|
||||
projectId,
|
||||
@@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
secretPath,
|
||||
projectId,
|
||||
requestId: secretApprovalRequest.id,
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
|
||||
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))],
|
||||
approvalUrl
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -77,7 +77,9 @@ export const KeyStorePrefixes = {
|
||||
UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const,
|
||||
IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const,
|
||||
GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) =>
|
||||
`group-member-project-permission:${projectId}:${groupId}:*` as const
|
||||
`group-member-project-permission:${projectId}:${groupId}:*` as const,
|
||||
|
||||
PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const
|
||||
};
|
||||
|
||||
export const KeyStoreTtls = {
|
||||
|
||||
@@ -62,6 +62,7 @@ export enum ApiDocsTags {
|
||||
PkiCertificateCollections = "PKI Certificate Collections",
|
||||
PkiAlerting = "PKI Alerting",
|
||||
PkiSubscribers = "PKI Subscribers",
|
||||
PkiAcme = "PKI ACME",
|
||||
SshCertificates = "SSH Certificates",
|
||||
SshCertificateAuthorities = "SSH Certificate Authorities",
|
||||
SshCertificateTemplates = "SSH Certificate Templates",
|
||||
@@ -2308,7 +2309,10 @@ export const AppConnections = {
|
||||
code: "The OAuth code to use to connect with Azure Client Secrets.",
|
||||
tenantId: "The Tenant ID to use to connect with Azure Client Secrets.",
|
||||
clientId: "The Client ID to use to connect with Azure Client Secrets.",
|
||||
clientSecret: "The Client Secret to use to connect with Azure Client Secrets."
|
||||
clientSecret: "The Client Secret to use to connect with Azure Client Secrets.",
|
||||
certificateBody: "The certificate body in PEM format to use to connect with Azure Client Secrets.",
|
||||
privateKey:
|
||||
"The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with."
|
||||
},
|
||||
AZURE_DEVOPS: {
|
||||
code: "The OAuth code to use to connect with Azure DevOps.",
|
||||
|
||||
@@ -106,6 +106,21 @@ const envSchema = z
|
||||
HTTPS_ENABLED: zodStrBool,
|
||||
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
// Note: The ACME feature is still in development and is not yet ready for production.
|
||||
// This is the feature flag to enable/disable the ACME feature.
|
||||
// It's not intended to be used by users outside of the development team yet.
|
||||
ACME_FEATURE_ENABLED: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
|
||||
ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr(
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (!val) return {};
|
||||
return JSON.parse(val) as Record<string, string>;
|
||||
})
|
||||
.default("{}")
|
||||
),
|
||||
// smtp options
|
||||
SMTP_HOST: zpStr(z.string().optional()),
|
||||
SMTP_IGNORE_TLS: zodStrBool.default("false"),
|
||||
@@ -384,6 +399,8 @@ const envSchema = z
|
||||
(data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test",
|
||||
isDailyResourceCleanUpDevelopmentMode:
|
||||
data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE,
|
||||
isAcmeFeatureEnabled: data.NODE_ENV === "development" && data.ACME_FEATURE_ENABLED === true,
|
||||
isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE,
|
||||
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
|
||||
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
|
||||
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
|
||||
|
||||
@@ -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 { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types";
|
||||
import { triggerMicrosoftTeamsNotification } from "./notification-handlers/microsoft-teams";
|
||||
import { triggerSlackNotification } from "./notification-handlers/slack";
|
||||
import { TTriggerWorkflowNotificationDTO } from "./types";
|
||||
|
||||
export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => {
|
||||
try {
|
||||
@@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
|
||||
return;
|
||||
}
|
||||
|
||||
const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId);
|
||||
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
|
||||
const handlerPromises = [
|
||||
triggerSlackNotification({
|
||||
projectId,
|
||||
notification,
|
||||
orgId: project.orgId,
|
||||
projectSlackConfigDAL,
|
||||
kmsService
|
||||
}),
|
||||
|
||||
if (slackConfig) {
|
||||
if (
|
||||
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||
) {
|
||||
const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || [];
|
||||
if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) {
|
||||
await sendSlackNotification({
|
||||
orgId: project.orgId,
|
||||
notification,
|
||||
kmsService,
|
||||
targetChannelIds,
|
||||
slackIntegration: slackConfig
|
||||
}).catch((error) => {
|
||||
logger.error(error, "Error sending Slack notification");
|
||||
});
|
||||
}
|
||||
} else if (notification.type === TriggerFeature.SECRET_APPROVAL) {
|
||||
const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || [];
|
||||
if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) {
|
||||
await sendSlackNotification({
|
||||
orgId: project.orgId,
|
||||
notification,
|
||||
kmsService,
|
||||
targetChannelIds,
|
||||
slackIntegration: slackConfig
|
||||
}).catch((error) => {
|
||||
logger.error(error, "Error sending Slack notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
triggerMicrosoftTeamsNotification({
|
||||
projectId,
|
||||
notification,
|
||||
orgId: project.orgId,
|
||||
projectMicrosoftTeamsConfigDAL,
|
||||
microsoftTeamsService
|
||||
})
|
||||
];
|
||||
|
||||
if (microsoftTeamsConfig) {
|
||||
if (
|
||||
notification.type === TriggerFeature.ACCESS_REQUEST ||
|
||||
notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED
|
||||
) {
|
||||
if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) {
|
||||
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
||||
microsoftTeamsConfig.accessRequestChannels
|
||||
);
|
||||
|
||||
if (success && data) {
|
||||
await microsoftTeamsService
|
||||
.sendNotification({
|
||||
notification,
|
||||
target: data,
|
||||
tenantId: microsoftTeamsConfig.tenantId,
|
||||
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
|
||||
orgId: project.orgId
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error, "Error sending Microsoft Teams notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (notification.type === TriggerFeature.SECRET_APPROVAL) {
|
||||
if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) {
|
||||
const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse(
|
||||
microsoftTeamsConfig.secretRequestChannels
|
||||
);
|
||||
|
||||
if (success && data) {
|
||||
await microsoftTeamsService
|
||||
.sendNotification({
|
||||
notification,
|
||||
target: data,
|
||||
tenantId: microsoftTeamsConfig.tenantId,
|
||||
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
|
||||
orgId: project.orgId
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error(error, "Error sending Microsoft Teams notification");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.allSettled(handlerPromises);
|
||||
} catch (error) {
|
||||
logger.error(error, "Error triggering workflow integration notification");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
|
||||
export enum TriggerFeature {
|
||||
SECRET_APPROVAL = "secret-approval",
|
||||
ACCESS_REQUEST = "access-request",
|
||||
ACCESS_REQUEST_UPDATED = "access-request-updated"
|
||||
ACCESS_REQUEST_UPDATED = "access-request-updated",
|
||||
SECRET_SYNC_ERROR = "secret-sync-error"
|
||||
}
|
||||
|
||||
export type TNotification =
|
||||
@@ -20,6 +21,7 @@ export type TNotification =
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
secretKeys: string[];
|
||||
approvalUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -31,6 +33,7 @@ export type TNotification =
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
permissions: string[];
|
||||
approvalUrl: string;
|
||||
note?: string;
|
||||
@@ -50,6 +53,21 @@ export type TNotification =
|
||||
editNote?: string;
|
||||
editorFullName?: string;
|
||||
editorEmail?: string;
|
||||
projectPath: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: TriggerFeature.SECRET_SYNC_ERROR;
|
||||
payload: {
|
||||
syncName: string;
|
||||
syncActionLabel: string;
|
||||
syncDestination: string;
|
||||
failureMessage: string;
|
||||
syncUrl: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export enum QueueName {
|
||||
AuditLogPrune = "audit-log-prune",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||
PkiSyncCleanup = "pki-sync-cleanup",
|
||||
PkiSubscriber = "pki-subscriber",
|
||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||
@@ -90,6 +91,7 @@ export enum QueueJobs {
|
||||
AuditLogPrune = "audit-log-prune-job",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||
PkiSyncCleanup = "pki-sync-cleanup-job",
|
||||
SecWebhook = "secret-webhook-trigger",
|
||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||
@@ -159,6 +161,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.DailyExpiringPkiItemAlert;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.DailyPkiAlertV2Processing]: {
|
||||
name: QueueJobs.DailyPkiAlertV2Processing;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PkiSyncCleanup]: {
|
||||
name: QueueJobs.PkiSyncCleanup;
|
||||
payload: undefined;
|
||||
|
||||
@@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas";
|
||||
const isScimRoutes = (pathname: string) =>
|
||||
pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups");
|
||||
|
||||
const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/pki/acme/");
|
||||
|
||||
export const addErrorsToResponseSchemas = fp(async (server) => {
|
||||
server.addHook("onRoute", (routeOptions) => {
|
||||
if (routeOptions.schema && routeOptions.schema.response && !isScimRoutes(routeOptions.path)) {
|
||||
if (
|
||||
routeOptions.schema &&
|
||||
routeOptions.schema.response &&
|
||||
!isScimRoutes(routeOptions.path) &&
|
||||
!isAcmeRoutes(routeOptions.path)
|
||||
) {
|
||||
routeOptions.schema.response = {
|
||||
...DefaultResponseErrorsSchema,
|
||||
...routeOptions.schema.response
|
||||
|
||||
@@ -5,6 +5,7 @@ import fastifyPlugin from "fastify-plugin";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { AcmeError } from "@app/ee/services/pki-acme/pki-acme-errors";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import {
|
||||
BadRequestError,
|
||||
@@ -242,6 +243,19 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
|
||||
error: "TokenError",
|
||||
message: errorMessage
|
||||
});
|
||||
} else if (error instanceof AcmeError) {
|
||||
void res
|
||||
.type("application/problem+json")
|
||||
.status(error.status)
|
||||
.send({
|
||||
reqId: req.id,
|
||||
error: error.name,
|
||||
status: error.status,
|
||||
type: `urn:ietf:params:acme:error:${error.type}`,
|
||||
detail: error.detail,
|
||||
message: error.message
|
||||
// TODO: add subproblems if they exist
|
||||
});
|
||||
} else {
|
||||
void res.status(HttpStatusCodes.InternalServerError).send({
|
||||
reqId: req.id,
|
||||
|
||||
@@ -31,7 +31,10 @@ export const registerServeUI = async (
|
||||
CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY,
|
||||
POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY,
|
||||
INTERCOM_ID: appCfg.INTERCOM_ID,
|
||||
TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED
|
||||
TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED,
|
||||
// The feature flag to enable/disable the ACME feature.
|
||||
// Will be removed once the feature is ready for production.
|
||||
ACME_FEATURE_ENABLED: appCfg.isAcmeFeatureEnabled
|
||||
};
|
||||
const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`;
|
||||
return res.send(js);
|
||||
|
||||
@@ -74,6 +74,13 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi
|
||||
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
|
||||
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
|
||||
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
|
||||
import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
|
||||
import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal";
|
||||
import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-dal";
|
||||
import { pkiAcmeChallengeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-service";
|
||||
import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal";
|
||||
import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal";
|
||||
import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service";
|
||||
import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal";
|
||||
import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
|
||||
import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal";
|
||||
@@ -182,6 +189,7 @@ import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/c
|
||||
import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
|
||||
import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
|
||||
import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
|
||||
import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal";
|
||||
import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal";
|
||||
import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
|
||||
import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal";
|
||||
@@ -267,6 +275,11 @@ import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rota
|
||||
import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
|
||||
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
|
||||
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
||||
import { pkiAlertChannelDALFactory } from "@app/services/pki-alert-v2/pki-alert-channel-dal";
|
||||
import { pkiAlertHistoryDALFactory } from "@app/services/pki-alert-v2/pki-alert-history-dal";
|
||||
import { pkiAlertV2DALFactory } from "@app/services/pki-alert-v2/pki-alert-v2-dal";
|
||||
import { pkiAlertV2QueueServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-queue";
|
||||
import { pkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service";
|
||||
import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
|
||||
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
||||
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||
@@ -547,6 +560,9 @@ export const registerRoutes = async (
|
||||
const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db);
|
||||
const membershipRoleDAL = membershipRoleDALFactory(db);
|
||||
const roleDAL = roleDALFactory(db);
|
||||
const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db);
|
||||
const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db);
|
||||
const pkiAlertV2DAL = pkiAlertV2DALFactory(db);
|
||||
|
||||
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
|
||||
|
||||
@@ -1061,7 +1077,12 @@ export const registerRoutes = async (
|
||||
const certificateProfileDAL = certificateProfileDALFactory(db);
|
||||
const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db);
|
||||
const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db);
|
||||
|
||||
const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db);
|
||||
const acmeAccountDAL = pkiAcmeAccountDALFactory(db);
|
||||
const acmeOrderDAL = pkiAcmeOrderDALFactory(db);
|
||||
const acmeAuthDAL = pkiAcmeAuthDALFactory(db);
|
||||
const acmeOrderAuthDAL = pkiAcmeOrderAuthDALFactory(db);
|
||||
const acmeChallengeDAL = pkiAcmeChallengeDALFactory(db);
|
||||
const certificateDAL = certificateDALFactory(db);
|
||||
const certificateBodyDAL = certificateBodyDALFactory(db);
|
||||
const certificateSecretDAL = certificateSecretDALFactory(db);
|
||||
@@ -1156,6 +1177,11 @@ export const registerRoutes = async (
|
||||
certificateTemplateV2DAL,
|
||||
apiEnrollmentConfigDAL,
|
||||
estEnrollmentConfigDAL,
|
||||
acmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
permissionService,
|
||||
kmsService,
|
||||
projectDAL
|
||||
@@ -1251,7 +1277,10 @@ export const registerRoutes = async (
|
||||
licenseService,
|
||||
gatewayService,
|
||||
gatewayV2Service,
|
||||
notificationService
|
||||
notificationService,
|
||||
projectSlackConfigDAL,
|
||||
projectMicrosoftTeamsConfigDAL,
|
||||
microsoftTeamsService
|
||||
});
|
||||
|
||||
const secretQueueService = secretQueueFactory({
|
||||
@@ -1801,6 +1830,21 @@ export const registerRoutes = async (
|
||||
groupDAL
|
||||
});
|
||||
|
||||
const pkiAlertV2Service = pkiAlertV2ServiceFactory({
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertChannelDAL,
|
||||
pkiAlertHistoryDAL,
|
||||
permissionService,
|
||||
smtpService
|
||||
});
|
||||
|
||||
const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({
|
||||
queueService,
|
||||
pkiAlertV2Service,
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertHistoryDAL
|
||||
});
|
||||
|
||||
const dynamicSecretProviders = buildDynamicSecretProviders({
|
||||
gatewayService,
|
||||
gatewayV2Service
|
||||
@@ -2152,6 +2196,7 @@ export const registerRoutes = async (
|
||||
certificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
certificateTemplateV2Service,
|
||||
acmeAccountDAL,
|
||||
internalCaService: internalCertificateAuthorityService,
|
||||
permissionService,
|
||||
certificateSyncDAL,
|
||||
@@ -2178,6 +2223,24 @@ export const registerRoutes = async (
|
||||
estEnrollmentConfigDAL
|
||||
});
|
||||
|
||||
const acmeChallengeService = pkiAcmeChallengeServiceFactory({
|
||||
acmeChallengeDAL
|
||||
});
|
||||
const pkiAcmeService = pkiAcmeServiceFactory({
|
||||
projectDAL,
|
||||
certificateProfileDAL,
|
||||
certificateBodyDAL,
|
||||
acmeAccountDAL,
|
||||
acmeOrderDAL,
|
||||
acmeAuthDAL,
|
||||
acmeOrderAuthDAL,
|
||||
acmeChallengeDAL,
|
||||
keyStore,
|
||||
kmsService,
|
||||
certificateV3Service,
|
||||
acmeChallengeService
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
@@ -2355,6 +2418,7 @@ export const registerRoutes = async (
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await pkiAlertV2Queue.init();
|
||||
await certificateV3Queue.init();
|
||||
await kmsService.startService(hsmStatus);
|
||||
await microsoftTeamsService.start();
|
||||
@@ -2433,6 +2497,7 @@ export const registerRoutes = async (
|
||||
certificateProfile: certificateProfileService,
|
||||
certificateAuthorityCrl: certificateAuthorityCrlService,
|
||||
certificateEst: certificateEstService,
|
||||
pkiAcme: pkiAcmeService,
|
||||
pit: pitService,
|
||||
pkiAlert: pkiAlertService,
|
||||
pkiCollection: pkiCollectionService,
|
||||
@@ -2486,7 +2551,8 @@ export const registerRoutes = async (
|
||||
role: roleService,
|
||||
additionalPrivilege: additionalPrivilegeService,
|
||||
identityProject: identityProjectService,
|
||||
convertor: convertorService
|
||||
convertor: convertorService,
|
||||
pkiAlertV2: pkiAlertV2Service
|
||||
});
|
||||
|
||||
const cronJobs: CronJob[] = [];
|
||||
|
||||
@@ -44,7 +44,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean().default(false),
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -55,6 +56,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.API) {
|
||||
if (!data.apiConfig) {
|
||||
@@ -63,12 +67,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.ACME) {
|
||||
if (!data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
|
||||
"EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration."
|
||||
}
|
||||
),
|
||||
response: {
|
||||
@@ -150,6 +168,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean(),
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
acmeConfig: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
directoryUrl: z.string()
|
||||
})
|
||||
.optional()
|
||||
}).array(),
|
||||
totalCount: z.number()
|
||||
@@ -473,4 +497,101 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
return { certificates };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/certificates/latest-active-bundle",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateProfiles],
|
||||
description: "Get latest active certificate bundle for a profile",
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z.string().nullable(),
|
||||
certificateChain: z.string().nullable(),
|
||||
privateKey: z.string().nullable(),
|
||||
serialNumber: z.string().nullable()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const response = await server.services.certificateProfile.getLatestActiveCertificateBundle({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: req.params.id
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
return {
|
||||
certificate: null,
|
||||
certificateChain: null,
|
||||
privateKey: null,
|
||||
serialNumber: null
|
||||
};
|
||||
}
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: response.certObj.projectId,
|
||||
event: {
|
||||
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE,
|
||||
metadata: {
|
||||
certificateProfileId: response.profile.id,
|
||||
certificateId: response.certObj.id,
|
||||
commonName: response.certObj.commonName,
|
||||
profileName: response.profile.slug,
|
||||
serialNumber: response.certObj.serialNumber
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: response.certificate,
|
||||
certificateChain: response.certificateChain,
|
||||
privateKey: response.privateKey,
|
||||
serialNumber: response.certObj.serialNumber
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:id/acme/eab-secret/reveal",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateProfiles],
|
||||
params: z.object({
|
||||
id: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
eabKid: z.string(),
|
||||
eabSecret: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { eabKid, eabSecret } = await server.services.certificateProfile.revealAcmeEabSecret({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: req.params.id
|
||||
});
|
||||
return { eabKid, eabSecret };
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -614,8 +614,10 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
integrationId: z.string(),
|
||||
accessRequestChannels: validateSlackChannelsField,
|
||||
secretRequestChannels: validateSlackChannelsField,
|
||||
secretSyncErrorChannels: validateSlackChannelsField,
|
||||
isAccessRequestNotificationEnabled: z.boolean(),
|
||||
isSecretRequestNotificationEnabled: z.boolean()
|
||||
isSecretRequestNotificationEnabled: z.boolean(),
|
||||
isSecretSyncErrorNotificationEnabled: z.boolean()
|
||||
}),
|
||||
z.object({
|
||||
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
|
||||
@@ -633,7 +635,9 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
isAccessRequestNotificationEnabled: true,
|
||||
accessRequestChannels: true,
|
||||
isSecretRequestNotificationEnabled: true,
|
||||
secretRequestChannels: true
|
||||
secretRequestChannels: true,
|
||||
isSecretSyncErrorNotificationEnabled: true,
|
||||
secretSyncErrorChannels: true
|
||||
}).merge(
|
||||
z.object({
|
||||
integration: z.literal(WorkflowIntegration.SLACK),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types";
|
||||
|
||||
export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
@@ -52,7 +53,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
||||
pkiAlertId: alert.id,
|
||||
pkiCollectionId: alert.pkiCollectionId,
|
||||
name: alert.name,
|
||||
alertBeforeDays: alert.alertBeforeDays,
|
||||
alertBefore: alert.alertBeforeDays.toString(),
|
||||
eventType: PkiAlertEventType.EXPIRATION,
|
||||
recipientEmails: alert.recipientEmails
|
||||
}
|
||||
}
|
||||
@@ -152,7 +154,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
||||
pkiAlertId: alert.id,
|
||||
pkiCollectionId: alert.pkiCollectionId,
|
||||
name: alert.name,
|
||||
alertBeforeDays: alert.alertBeforeDays,
|
||||
alertBefore: alert.alertBeforeDays.toString(),
|
||||
eventType: PkiAlertEventType.EXPIRATION,
|
||||
recipientEmails: alert.recipientEmails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,7 +769,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
isAccessRequestNotificationEnabled: true,
|
||||
accessRequestChannels: true,
|
||||
isSecretRequestNotificationEnabled: true,
|
||||
secretRequestChannels: true
|
||||
secretRequestChannels: true,
|
||||
isSecretSyncErrorNotificationEnabled: true,
|
||||
secretSyncErrorChannels: true
|
||||
}).merge(
|
||||
z.object({
|
||||
integration: z.literal(WorkflowIntegration.SLACK),
|
||||
@@ -873,7 +875,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
accessRequestChannels: validateSlackChannelsField,
|
||||
secretRequestChannels: validateSlackChannelsField,
|
||||
isAccessRequestNotificationEnabled: z.boolean(),
|
||||
isSecretRequestNotificationEnabled: z.boolean()
|
||||
isSecretRequestNotificationEnabled: z.boolean(),
|
||||
secretSyncErrorChannels: validateSlackChannelsField,
|
||||
isSecretSyncErrorNotificationEnabled: z.boolean()
|
||||
}),
|
||||
z.object({
|
||||
integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS),
|
||||
@@ -891,7 +895,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
isAccessRequestNotificationEnabled: true,
|
||||
accessRequestChannels: true,
|
||||
isSecretRequestNotificationEnabled: true,
|
||||
secretRequestChannels: true
|
||||
secretRequestChannels: true,
|
||||
isSecretSyncErrorNotificationEnabled: true,
|
||||
secretSyncErrorChannels: true
|
||||
}).merge(
|
||||
z.object({
|
||||
integration: z.literal(WorkflowIntegration.SLACK),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router";
|
||||
import { registerMfaRouter } from "./mfa-router";
|
||||
import { registerOrgRouter } from "./organization-router";
|
||||
import { registerPasswordRouter } from "./password-router";
|
||||
import { registerPkiAlertRouter } from "./pki-alert-router";
|
||||
import { registerPkiTemplatesRouter } from "./pki-templates-router";
|
||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||
import { registerSecretImportRouter } from "./secret-import-router";
|
||||
@@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
|
||||
async (pkiRouter) => {
|
||||
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
|
||||
await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" });
|
||||
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
|
||||
},
|
||||
{ prefix: "/pki" }
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
|
||||
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
|
||||
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
|
||||
|
||||
interface CertificateRequestForService {
|
||||
@@ -204,7 +205,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
enrollmentType: EnrollmentType.API
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
|
||||
@@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
|
||||
await server.register(registerUserRouter, { prefix: "/users" });
|
||||
await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" });
|
||||
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
|
||||
await server.register(registerCertificatesRouter, { prefix: "/certificates" });
|
||||
await server.register(registerCertificatesRouter, { prefix: "/pki/certificates" });
|
||||
};
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
export enum AzureClientSecretsConnectionMethod {
|
||||
OAuth = "oauth",
|
||||
ClientSecret = "client-secret"
|
||||
ClientSecret = "client-secret",
|
||||
Certificate = "certificate"
|
||||
}
|
||||
|
||||
+179
-5
@@ -1,9 +1,14 @@
|
||||
/* eslint-disable no-case-declarations */
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import type { KeyObject } from "crypto";
|
||||
import RE2 from "re2";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { crypto } from "@app/lib/crypto";
|
||||
import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import {
|
||||
decryptAppConnectionCredentials,
|
||||
encryptAppConnectionCredentials,
|
||||
@@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums";
|
||||
import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums";
|
||||
import {
|
||||
ExchangeCodeAzureResponse,
|
||||
TAzureClientSecretsConnectionCertificateCredentials,
|
||||
TAzureClientSecretsConnectionClientSecretCredentials,
|
||||
TAzureClientSecretsConnectionConfig,
|
||||
TAzureClientSecretsConnectionCredentials
|
||||
} from "./azure-client-secrets-connection-types";
|
||||
|
||||
const generateClientAssertion = (
|
||||
clientId: string,
|
||||
tenantId: string,
|
||||
privateKey: string,
|
||||
certificate: string
|
||||
): string => {
|
||||
const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
|
||||
|
||||
const certBuffer = Buffer.from(
|
||||
certificate
|
||||
.replace(new RE2("-----BEGIN CERTIFICATE-----"), "")
|
||||
.replace(new RE2("-----END CERTIFICATE-----"), "")
|
||||
.replace(new RE2("\\s", "g"), ""),
|
||||
"base64"
|
||||
);
|
||||
|
||||
// thumbprint of the certificate is used for the jwt header
|
||||
const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex");
|
||||
const x5t = Buffer.from(thumbprint, "hex").toString("base64url");
|
||||
|
||||
// JWT Header
|
||||
const header = {
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
x5t
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
aud: tokenEndpoint,
|
||||
exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself)
|
||||
iss: clientId,
|
||||
jti: uuidv4(), // random ID for the JWT
|
||||
nbf: now, // not before the jwt is valid
|
||||
sub: clientId
|
||||
};
|
||||
|
||||
// encode header and payload
|
||||
const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
|
||||
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
let keyObject: KeyObject;
|
||||
|
||||
try {
|
||||
if (privateKey.includes("BEGIN PRIVATE KEY")) {
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey(privateKey);
|
||||
} else {
|
||||
// if user forgot to wrap in begin/end private key, decode and use as der format
|
||||
keyObject = crypto.nativeCrypto.createPrivateKey({
|
||||
key: Buffer.from(privateKey, "base64"),
|
||||
format: "der",
|
||||
type: "pkcs8"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid private key format provided. Expected PEM format private key."
|
||||
});
|
||||
}
|
||||
|
||||
// sign with private key
|
||||
const signer = crypto.nativeCrypto.createSign("RSA-SHA256");
|
||||
signer.update(signatureInput);
|
||||
signer.end();
|
||||
const signature = signer.sign(keyObject, "base64url");
|
||||
|
||||
return `${signatureInput}.${signature}`;
|
||||
};
|
||||
|
||||
export const getAzureClientSecretsConnectionListItem = () => {
|
||||
const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig();
|
||||
|
||||
@@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => {
|
||||
app: AppConnection.AzureClientSecrets as const,
|
||||
methods: Object.values(AzureClientSecretsConnectionMethod) as [
|
||||
AzureClientSecretsConnectionMethod.OAuth,
|
||||
AzureClientSecretsConnectionMethod.ClientSecret
|
||||
AzureClientSecretsConnectionMethod.ClientSecret,
|
||||
AzureClientSecretsConnectionMethod.Certificate
|
||||
],
|
||||
oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID
|
||||
};
|
||||
@@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async (
|
||||
const { refreshToken } = credentials;
|
||||
const currentTime = Date.now();
|
||||
switch (appConnection.method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID ||
|
||||
!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET
|
||||
@@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials });
|
||||
|
||||
return data.access_token;
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
}
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
@@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async (
|
||||
await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials });
|
||||
|
||||
return clientData.access_token;
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.Certificate: {
|
||||
const accessTokenCredentials = (await decryptAppConnectionCredentials({
|
||||
orgId: appConnection.orgId,
|
||||
projectId: appConnection.projectId,
|
||||
kmsService,
|
||||
encryptedCredentials: appConnection.encryptedCredentials
|
||||
})) as TAzureClientSecretsConnectionCertificateCredentials;
|
||||
const { accessToken, expiresAt, clientId, tenantId, certificateBody, privateKey } = accessTokenCredentials;
|
||||
if (accessToken && expiresAt && expiresAt > currentTime + 300000) {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody);
|
||||
const { data: clientData } = await request.post<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:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}`
|
||||
@@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
} = getConfig();
|
||||
|
||||
switch (method) {
|
||||
case AzureClientSecretsConnectionMethod.OAuth:
|
||||
case AzureClientSecretsConnectionMethod.OAuth: {
|
||||
if (!SITE_URL) {
|
||||
throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" });
|
||||
}
|
||||
@@ -221,8 +343,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
|
||||
refreshToken: tokenResp.data.refresh_token,
|
||||
expiresAt: Date.now() + tokenResp.data.expires_in * 1000
|
||||
};
|
||||
}
|
||||
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret:
|
||||
case AzureClientSecretsConnectionMethod.ClientSecret: {
|
||||
const { tenantId, clientId, clientSecret } = inputCredentials;
|
||||
try {
|
||||
const { data: clientData } = await request.post<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:
|
||||
throw new InternalServerError({
|
||||
message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}`
|
||||
|
||||
+55
-1
@@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z.
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({
|
||||
tenantId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Tenant ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId),
|
||||
clientId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.trim()
|
||||
.min(1, "Client ID required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId),
|
||||
certificateBody: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Certificate body required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody),
|
||||
privateKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Private Key required")
|
||||
.describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey)
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string(),
|
||||
@@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({
|
||||
clientId: z.string(),
|
||||
tenantId: z.string(),
|
||||
certificateBody: z.string(),
|
||||
privateKey: z.string(),
|
||||
accessToken: z.string(),
|
||||
expiresAt: z.number()
|
||||
});
|
||||
|
||||
export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [
|
||||
z.object({
|
||||
method: z
|
||||
@@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin
|
||||
credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
}),
|
||||
z.object({
|
||||
method: z
|
||||
.literal(AzureClientSecretsConnectionMethod.Certificate)
|
||||
.describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method),
|
||||
credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe(
|
||||
AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials
|
||||
)
|
||||
})
|
||||
]);
|
||||
|
||||
@@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z
|
||||
credentials: z
|
||||
.union([
|
||||
AzureClientSecretsConnectionOAuthInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema
|
||||
AzureClientSecretsConnectionClientSecretInputCredentialsSchema,
|
||||
AzureClientSecretsConnectionCertificateInputCredentialsSchema
|
||||
])
|
||||
.optional()
|
||||
.describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials)
|
||||
@@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection(
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret),
|
||||
credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
}),
|
||||
z.object({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
})
|
||||
])
|
||||
);
|
||||
@@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion(
|
||||
clientId: true,
|
||||
tenantId: true
|
||||
})
|
||||
}),
|
||||
BaseAzureClientSecretsConnectionSchema.extend({
|
||||
method: z.literal(AzureClientSecretsConnectionMethod.Certificate),
|
||||
credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({
|
||||
tenantId: true,
|
||||
clientId: true
|
||||
})
|
||||
})
|
||||
]);
|
||||
|
||||
|
||||
+5
@@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types";
|
||||
|
||||
import { AppConnection } from "../app-connection-enums";
|
||||
import {
|
||||
AzureClientSecretsConnectionCertificateOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionClientSecretOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionOAuthOutputCredentialsSchema,
|
||||
AzureClientSecretsConnectionSchema,
|
||||
@@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export type TAzureClientSecretsConnectionCertificateCredentials = z.infer<
|
||||
typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema
|
||||
>;
|
||||
|
||||
export interface ExchangeCodeAzureResponse {
|
||||
token_type: string;
|
||||
scope: string;
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ...
|
||||
IDENTITY = "identity",
|
||||
Machine = "machine",
|
||||
SCIM_CLIENT = "scimClient",
|
||||
ACME_ACCOUNT = "acmeAccount",
|
||||
UNKNOWN_USER = "unknownUser"
|
||||
}
|
||||
|
||||
|
||||
@@ -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> => {
|
||||
try {
|
||||
const query = (tx || db)(TableName.PkiCertificateProfile)
|
||||
@@ -88,6 +105,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
`${TableName.PkiCertificateProfile}.apiConfigId`,
|
||||
`${TableName.PkiApiEnrollmentConfig}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiAcmeEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.acmeConfigId`,
|
||||
`${TableName.PkiAcmeEnrollmentConfig}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
|
||||
@@ -107,7 +129,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
|
||||
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
|
||||
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays")
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"),
|
||||
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"),
|
||||
db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret")
|
||||
)
|
||||
.where(`${TableName.PkiCertificateProfile}.id`, id)
|
||||
.first();
|
||||
@@ -134,6 +158,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
} as TCertificateProfileWithConfigs["apiConfig"])
|
||||
: undefined;
|
||||
|
||||
const acmeConfig = result.acmeConfigId
|
||||
? ({
|
||||
id: result.acmeConfigId,
|
||||
encryptedEabSecret: result.acmeConfigEncryptedEabSecret
|
||||
} as TCertificateProfileWithConfigs["acmeConfig"])
|
||||
: undefined;
|
||||
|
||||
const certificateAuthority =
|
||||
result.caId && result.caProjectId && result.caStatus && result.caName
|
||||
? ({
|
||||
@@ -164,10 +195,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
enrollmentType: result.enrollmentType as EnrollmentType,
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig,
|
||||
acmeConfig,
|
||||
certificateAuthority,
|
||||
certificateTemplate
|
||||
};
|
||||
@@ -241,6 +274,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
`${TableName.PkiCertificateProfile}.apiConfigId`,
|
||||
`${TableName.PkiApiEnrollmentConfig}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiAcmeEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.acmeConfigId`,
|
||||
`${TableName.PkiAcmeEnrollmentConfig}.id`
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
|
||||
@@ -252,7 +290,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
|
||||
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
|
||||
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays")
|
||||
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
|
||||
db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId")
|
||||
);
|
||||
|
||||
const results = (await query
|
||||
@@ -279,6 +318,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const acmeConfig = result.acmeId
|
||||
? {
|
||||
id: result.acmeId as string
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const baseProfile = {
|
||||
id: result.id,
|
||||
projectId: result.projectId,
|
||||
@@ -292,7 +337,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig
|
||||
apiConfig,
|
||||
acmeConfig
|
||||
};
|
||||
|
||||
return baseProfile as TCertificateProfileWithConfigs;
|
||||
@@ -416,6 +462,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getLatestActiveCertificateForProfile = async (profileId: string, tx?: Knex) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
|
||||
const certificate = await (tx || db)(TableName.Certificate)
|
||||
.where("profileId", profileId)
|
||||
.where("status", "active")
|
||||
.where("notAfter", ">", now)
|
||||
.whereNull("revokedAt")
|
||||
.orderBy("createdAt", "desc")
|
||||
.first();
|
||||
|
||||
return certificate;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Get latest active certificate by profile" });
|
||||
}
|
||||
};
|
||||
|
||||
const isProfileInUse = async (profileId: string, tx?: Knex) => {
|
||||
try {
|
||||
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
|
||||
@@ -432,12 +496,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
updateById,
|
||||
deleteById,
|
||||
findById,
|
||||
findByIdWithOwnerOrgId,
|
||||
findByIdWithConfigs,
|
||||
findBySlugAndProjectId,
|
||||
findByProjectId,
|
||||
countByProjectId,
|
||||
findByNameAndProjectId,
|
||||
getCertificatesByProfile,
|
||||
getLatestActiveCertificateForProfile,
|
||||
isProfileInUse
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z
|
||||
autoRenew: z.boolean().default(false),
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.API) {
|
||||
if (!data.apiConfig) {
|
||||
@@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (data.enrollmentType === EnrollmentType.ACME) {
|
||||
if (!data.acmeConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.estConfig) {
|
||||
return false;
|
||||
}
|
||||
if (data.apiConfig) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,12 @@ import type { TPermissionServiceFactory } from "@app/ee/services/permission/perm
|
||||
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { ActorType, AuthMethod } from "../auth/auth-type";
|
||||
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
|
||||
import type { TKmsServiceFactory } from "../kms/kms-service";
|
||||
@@ -142,6 +147,17 @@ describe("CertificateProfileService", () => {
|
||||
delete: vi.fn()
|
||||
} as unknown as TEstEnrollmentConfigDALFactory;
|
||||
|
||||
const mockAcmeEnrollmentConfigDAL = {
|
||||
create: vi.fn().mockResolvedValue({ id: "acme-config-123" }),
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TAcmeEnrollmentConfigDALFactory;
|
||||
|
||||
const mockPermissionService = {
|
||||
getProjectPermission: vi.fn().mockResolvedValue({
|
||||
permission: {
|
||||
@@ -166,6 +182,54 @@ describe("CertificateProfileService", () => {
|
||||
transaction: vi.fn()
|
||||
} as unknown as Pick<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(() => {
|
||||
vi.spyOn(ForbiddenError, "from").mockReturnValue({
|
||||
throwUnlessCan: vi.fn()
|
||||
@@ -182,6 +246,11 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
|
||||
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
|
||||
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
|
||||
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL: mockCertificateBodyDAL,
|
||||
certificateSecretDAL: mockCertificateSecretDAL,
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
|
||||
permissionService: mockPermissionService,
|
||||
kmsService: mockKmsService,
|
||||
projectDAL: mockProjectDAL
|
||||
@@ -234,6 +303,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateId: "template-123",
|
||||
apiConfigId: "api-config-123",
|
||||
estConfigId: null,
|
||||
acmeConfigId: null,
|
||||
projectId: "project-123"
|
||||
},
|
||||
undefined
|
||||
|
||||
@@ -4,19 +4,26 @@ import * as x509 from "@peculiar/x509";
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns";
|
||||
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
|
||||
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
|
||||
import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types";
|
||||
import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
@@ -31,6 +38,36 @@ import {
|
||||
TCertificateProfileWithConfigs
|
||||
} from "./certificate-profile-types";
|
||||
|
||||
const generateAndEncryptAcmeEabSecret = async (
|
||||
projectId: string,
|
||||
kmsService: Pick<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 (
|
||||
caChain: 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;
|
||||
apiConfig?: TApiConfigData;
|
||||
acmeConfig?: TAcmeConfigData;
|
||||
};
|
||||
|
||||
type TCertificateProfileServiceFactoryDep = {
|
||||
@@ -105,6 +146,11 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
certificateTemplateV2DAL: TCertificateTemplateV2DALFactory;
|
||||
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
|
||||
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
|
||||
acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
@@ -124,6 +170,9 @@ export const certificateProfileServiceFactory = ({
|
||||
certificateTemplateV2DAL,
|
||||
apiEnrollmentConfigDAL,
|
||||
estEnrollmentConfigDAL,
|
||||
acmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
permissionService,
|
||||
kmsService,
|
||||
projectDAL
|
||||
@@ -188,11 +237,14 @@ export const certificateProfileServiceFactory = ({
|
||||
message: "API enrollment requires API configuration"
|
||||
});
|
||||
}
|
||||
// TODO: acme type currently doesn't require config obj, but add a check in the future if
|
||||
// we have options
|
||||
|
||||
// Create enrollment configs and profile
|
||||
const profile = await certificateProfileDAL.transaction(async (tx) => {
|
||||
let estConfigId: string | null = null;
|
||||
let apiConfigId: string | null = null;
|
||||
let acmeConfigId: string | null = null;
|
||||
|
||||
if (data.enrollmentType === EnrollmentType.EST && data.estConfig) {
|
||||
const appCfg = getConfig();
|
||||
@@ -228,16 +280,21 @@ export const certificateProfileServiceFactory = ({
|
||||
tx
|
||||
);
|
||||
apiConfigId = apiConfig.id;
|
||||
} else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) {
|
||||
const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL);
|
||||
const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx);
|
||||
acmeConfigId = acmeConfig.id;
|
||||
}
|
||||
|
||||
// Create the profile with the created config IDs
|
||||
const { estConfig, apiConfig, ...profileData } = data;
|
||||
const { estConfig, apiConfig, acmeConfig, ...profileData } = data;
|
||||
const profileResult = await certificateProfileDAL.create(
|
||||
{
|
||||
...profileData,
|
||||
projectId,
|
||||
estConfigId,
|
||||
apiConfigId
|
||||
apiConfigId,
|
||||
acmeConfigId
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -439,6 +496,12 @@ export const certificateProfileServiceFactory = ({
|
||||
profile.estConfig.caChain = "";
|
||||
}
|
||||
}
|
||||
if (profile.enrollmentType === EnrollmentType.ACME && profile.acmeConfig) {
|
||||
profile.acmeConfig.directoryUrl = buildUrl(profile.id, "/directory");
|
||||
if (profile.acmeConfig.encryptedEabSecret) {
|
||||
profile.acmeConfig.encryptedEabSecret = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
@@ -574,7 +637,10 @@ export const certificateProfileServiceFactory = ({
|
||||
const result: TCertificateProfileWithConfigs = {
|
||||
...converted,
|
||||
estConfig: decryptedEstConfig,
|
||||
apiConfig: profileWithConfigs.apiConfig
|
||||
apiConfig: profileWithConfigs.apiConfig,
|
||||
acmeConfig: profileWithConfigs.acmeConfig
|
||||
? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") }
|
||||
: undefined
|
||||
};
|
||||
|
||||
return result;
|
||||
@@ -674,6 +740,106 @@ export const certificateProfileServiceFactory = ({
|
||||
return certificates;
|
||||
};
|
||||
|
||||
const getLatestActiveCertificateBundle = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
profileId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
profileId: string;
|
||||
}) => {
|
||||
const profile = await certificateProfileDAL.findById(profileId);
|
||||
if (!profile) {
|
||||
throw new NotFoundError({ message: "Certificate profile not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: profile.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateProfileActions.Read,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.ReadPrivateKey,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId);
|
||||
|
||||
if (!cert) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
|
||||
|
||||
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
|
||||
projectId: cert.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKeyId
|
||||
});
|
||||
const decryptedCert = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificate
|
||||
});
|
||||
|
||||
const certObj = new x509.X509Certificate(decryptedCert);
|
||||
const certificate = certObj.toString("pem");
|
||||
|
||||
const decryptedCertChain = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificateChain!
|
||||
});
|
||||
|
||||
const certificateChain = decryptedCertChain.toString();
|
||||
|
||||
let privateKey = null;
|
||||
try {
|
||||
const { certPrivateKey } = await getCertificateCredentials({
|
||||
certId: cert.id,
|
||||
projectId: cert.projectId,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
privateKey = certPrivateKey;
|
||||
} catch (error) {
|
||||
// Private key might not exist for ACME certificates or other external workflows
|
||||
// where the key is generated client-side
|
||||
if (error instanceof NotFoundError) {
|
||||
privateKey = null;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
privateKey,
|
||||
profile,
|
||||
certObj: cert
|
||||
};
|
||||
};
|
||||
|
||||
const getEstConfigurationByProfile = async (
|
||||
params:
|
||||
| {
|
||||
@@ -737,6 +903,59 @@ export const certificateProfileServiceFactory = ({
|
||||
};
|
||||
};
|
||||
|
||||
const revealAcmeEabSecret = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
profileId
|
||||
}: {
|
||||
actor: ActorType;
|
||||
actorId: string;
|
||||
actorAuthMethod: ActorAuthMethod;
|
||||
actorOrgId: string;
|
||||
profileId: string;
|
||||
}) => {
|
||||
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
|
||||
if (!profile) {
|
||||
throw new NotFoundError({ message: "Certificate profile not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: profile.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
|
||||
ProjectPermissionSub.CertificateProfiles
|
||||
);
|
||||
|
||||
if (profile.enrollmentType !== EnrollmentType.ACME) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Profile is not configured for ACME enrollment"
|
||||
});
|
||||
}
|
||||
if (!profile.acmeConfig) {
|
||||
throw new NotFoundError({ message: "ACME configuration not found for this profile" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: profile.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret! });
|
||||
return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") };
|
||||
};
|
||||
|
||||
return {
|
||||
createProfile,
|
||||
updateProfile,
|
||||
@@ -746,6 +965,8 @@ export const certificateProfileServiceFactory = ({
|
||||
listProfiles,
|
||||
deleteProfile,
|
||||
getProfileCertificates,
|
||||
getEstConfigurationByProfile
|
||||
getLatestActiveCertificateBundle,
|
||||
getEstConfigurationByProfile,
|
||||
revealAcmeEabSecret
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
|
||||
export enum EnrollmentType {
|
||||
API = "api",
|
||||
EST = "est"
|
||||
EST = "est",
|
||||
ACME = "acme"
|
||||
}
|
||||
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType"> & {
|
||||
@@ -28,6 +29,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
|
||||
autoRenew?: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: unknown;
|
||||
};
|
||||
|
||||
export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
@@ -54,6 +56,11 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
autoRenew: boolean;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
acmeConfig?: {
|
||||
id: string;
|
||||
directoryUrl: string;
|
||||
encryptedEabSecret?: Buffer;
|
||||
};
|
||||
};
|
||||
|
||||
export interface TCertificateProfileCertificate {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ForbiddenError } from "@casl/ability";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
@@ -69,6 +70,10 @@ describe("CertificateV3Service", () => {
|
||||
getTemplateV2ById: vi.fn()
|
||||
};
|
||||
|
||||
const mockAcmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById"> = {
|
||||
findById: vi.fn()
|
||||
};
|
||||
|
||||
const mockInternalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa"> =
|
||||
{
|
||||
signCertFromCa: vi.fn(),
|
||||
@@ -132,6 +137,7 @@ describe("CertificateV3Service", () => {
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateProfileDAL: mockCertificateProfileDAL,
|
||||
certificateTemplateV2Service: mockCertificateTemplateV2Service,
|
||||
acmeAccountDAL: mockAcmeAccountDAL,
|
||||
internalCaService: mockInternalCaService,
|
||||
permissionService: mockPermissionService,
|
||||
certificateSyncDAL: {
|
||||
@@ -697,6 +703,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
});
|
||||
|
||||
@@ -731,6 +738,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
})
|
||||
).rejects.toThrow(ForbiddenRequestError);
|
||||
@@ -740,6 +748,7 @@ describe("CertificateV3Service", () => {
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
validity: mockValidity,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
...mockActor
|
||||
})
|
||||
).rejects.toThrow("Profile is not configured for api enrollment");
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ProjectPermissionCertificateProfileActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
@@ -70,6 +71,7 @@ type TCertificateV3ServiceFactoryDep = {
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
|
||||
certificateTemplateV2Service: Pick<
|
||||
TCertificateTemplateV2ServiceFactory,
|
||||
"validateCertificateRequest" | "getTemplateV2ById"
|
||||
@@ -93,6 +95,7 @@ const validateProfileAndPermissions = async (
|
||||
actorAuthMethod: ActorAuthMethod,
|
||||
actorOrgId: string,
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">,
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">,
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">,
|
||||
requiredEnrollmentType: EnrollmentType
|
||||
) => {
|
||||
@@ -107,6 +110,19 @@ const validateProfileAndPermissions = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) {
|
||||
const account = await acmeAccountDAL.findById(actorId);
|
||||
if (!account) {
|
||||
throw new NotFoundError({ message: "ACME account not found" });
|
||||
}
|
||||
if (account.profileId !== profile.id) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "ACME account is not associated with this profile"
|
||||
});
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
@@ -336,6 +352,7 @@ export const certificateV3ServiceFactory = ({
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
certificateTemplateV2Service,
|
||||
internalCaService,
|
||||
permissionService,
|
||||
@@ -358,6 +375,7 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
);
|
||||
@@ -484,7 +502,8 @@ export const certificateV3ServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
actorOrgId,
|
||||
enrollmentType
|
||||
}: TSignCertificateFromProfileDTO): Promise<Omit<TCertificateFromProfileResponse, "privateKey">> => {
|
||||
const profile = await validateProfileAndPermissions(
|
||||
profileId,
|
||||
@@ -493,8 +512,9 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
enrollmentType
|
||||
);
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
|
||||
@@ -595,6 +615,7 @@ export const certificateV3ServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
certificateProfileDAL,
|
||||
acmeAccountDAL,
|
||||
permissionService,
|
||||
EnrollmentType.API
|
||||
);
|
||||
@@ -654,7 +675,7 @@ export const certificateV3ServiceFactory = ({
|
||||
status: CertificateOrderStatus.VALID
|
||||
})),
|
||||
authorizations: [],
|
||||
finalize: `/api/v3/certificates/orders/${orderId}/completed`,
|
||||
finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`,
|
||||
certificate: certificateResult.certificate,
|
||||
projectId: certificateResult.projectId,
|
||||
profileName: certificateResult.profileName
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CertKeyUsageType,
|
||||
CertSubjectAlternativeNameType
|
||||
} from "../certificate-common/certificate-constants";
|
||||
import { EnrollmentType } from "../certificate-profile/certificate-profile-types";
|
||||
|
||||
export type TIssueCertificateFromProfileDTO = {
|
||||
profileId: string;
|
||||
@@ -35,6 +36,7 @@ export type TSignCertificateFromProfileDTO = {
|
||||
};
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
enrollmentType: EnrollmentType;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TOrderCertificateFromProfileDTO = {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types";
|
||||
|
||||
export type TAcmeEnrollmentConfigDALFactory = ReturnType<typeof acmeEnrollmentConfigDALFactory>;
|
||||
|
||||
export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => {
|
||||
const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig);
|
||||
|
||||
const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*");
|
||||
const [acmeConfig] = result;
|
||||
|
||||
if (!acmeConfig) {
|
||||
throw new Error("Failed to create ACME enrollment config");
|
||||
}
|
||||
|
||||
return acmeConfig;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create ACME enrollment config" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => {
|
||||
try {
|
||||
const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*");
|
||||
const [acmeConfig] = result;
|
||||
|
||||
if (!acmeConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return acmeConfig;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update ACME enrollment config" });
|
||||
}
|
||||
};
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first();
|
||||
|
||||
return acmeConfig || null;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find ACME enrollment config by id" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...acmeEnrollmentConfigOrm,
|
||||
create,
|
||||
updateById,
|
||||
findById
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate
|
||||
} from "@app/db/schemas/pki-acme-enrollment-configs";
|
||||
import {
|
||||
TPkiApiEnrollmentConfigs,
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
@@ -17,6 +22,10 @@ export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs;
|
||||
export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert;
|
||||
export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate;
|
||||
|
||||
export type TAcmeEnrollmentConfig = TPkiAcmeEnrollmentConfigs;
|
||||
export type TAcmeEnrollmentConfigInsert = TPkiAcmeEnrollmentConfigsInsert;
|
||||
export type TAcmeEnrollmentConfigUpdate = TPkiAcmeEnrollmentConfigsUpdate;
|
||||
|
||||
export interface TEstConfigData {
|
||||
disableBootstrapCaValidation: boolean;
|
||||
passphrase: string;
|
||||
@@ -27,3 +36,5 @@ export interface TApiConfigData {
|
||||
autoRenew: boolean;
|
||||
renewBeforeDays?: number;
|
||||
}
|
||||
|
||||
export interface TAcmeConfigData {}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { TableName, TMicrosoftTeamsIntegrations } from "@app/db/schemas";
|
||||
import { TProjectMicrosoftTeamsConfigs } from "@app/db/schemas/project-microsoft-teams-configs";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType<typeof projectMicrosoftTeamsConfigDALFactory>;
|
||||
export type TProjectMicrosoftTeamsConfigWithIntegrations = TProjectMicrosoftTeamsConfigs & TMicrosoftTeamsIntegrations;
|
||||
|
||||
export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => {
|
||||
const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs);
|
||||
|
||||
const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => {
|
||||
return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs)
|
||||
return (tx || db.replicaNode())<TProjectMicrosoftTeamsConfigWithIntegrations>(
|
||||
TableName.ProjectMicrosoftTeamsConfigs
|
||||
)
|
||||
.join(
|
||||
TableName.MicrosoftTeamsIntegrations,
|
||||
`${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertChannels, TPkiAlertChannelsInsert } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPkiAlertChannelDALFactory = ReturnType<typeof pkiAlertChannelDALFactory>;
|
||||
|
||||
export const pkiAlertChannelDALFactory = (db: TDbClient) => {
|
||||
const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels);
|
||||
|
||||
const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise<TPkiAlertChannels[]> => {
|
||||
try {
|
||||
if (!data.length) return [];
|
||||
|
||||
const serializedData = data.map((item) => ({
|
||||
...item,
|
||||
config: item.config ? JSON.stringify(item.config) : null
|
||||
}));
|
||||
|
||||
const res = await (tx || db)(TableName.PkiAlertChannels).insert(serializedData).returning("*");
|
||||
|
||||
return res as TPkiAlertChannels[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "InsertMany" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByAlertId = async (alertId: string, tx?: Knex): Promise<TPkiAlertChannels[]> => {
|
||||
try {
|
||||
const channels = await (tx || db.replicaNode())(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.orderBy(`${TableName.PkiAlertChannels}.createdAt`, "asc");
|
||||
|
||||
return channels as TPkiAlertChannels[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteByAlertId = async (alertId: string, tx?: Knex): Promise<number> => {
|
||||
try {
|
||||
const deletedCount = await (tx || db)(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)
|
||||
.del();
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "DeleteByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertChannelOrm,
|
||||
insertMany,
|
||||
findByAlertId,
|
||||
deleteByAlertId
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertHistory } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
export type TPkiAlertHistoryDALFactory = ReturnType<typeof pkiAlertHistoryDALFactory>;
|
||||
|
||||
export const pkiAlertHistoryDALFactory = (db: TDbClient) => {
|
||||
const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory);
|
||||
|
||||
const createWithCertificates = async (
|
||||
alertId: string,
|
||||
certificateIds: string[],
|
||||
options?: {
|
||||
hasNotificationSent?: boolean;
|
||||
notificationError?: string;
|
||||
}
|
||||
): Promise<TPkiAlertHistory> => {
|
||||
try {
|
||||
return await db.transaction(async (tx) => {
|
||||
const historyRecords = await tx(TableName.PkiAlertHistory)
|
||||
.insert({
|
||||
alertId,
|
||||
hasNotificationSent: options?.hasNotificationSent || false,
|
||||
notificationError: options?.notificationError
|
||||
})
|
||||
.returning("*");
|
||||
|
||||
const historyRecord = historyRecords[0];
|
||||
|
||||
if (certificateIds.length > 0) {
|
||||
const certificateAssociations = certificateIds.map((certificateId) => ({
|
||||
alertHistoryId: historyRecord.id,
|
||||
certificateId
|
||||
}));
|
||||
|
||||
await tx(TableName.PkiAlertHistoryCertificate).insert(certificateAssociations);
|
||||
}
|
||||
|
||||
return historyRecord;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "CreateWithCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByAlertId = async (
|
||||
alertId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<TPkiAlertHistory[]> => {
|
||||
try {
|
||||
let query = db
|
||||
.replicaNode()
|
||||
.select(selectAllTableCols(TableName.PkiAlertHistory))
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, alertId)
|
||||
.orderBy(`${TableName.PkiAlertHistory}.triggeredAt`, "desc");
|
||||
|
||||
if (options?.limit) {
|
||||
query = query.limit(options.limit);
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
query = query.offset(options.offset);
|
||||
}
|
||||
|
||||
const results = await query;
|
||||
return results as TPkiAlertHistory[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByAlertId" });
|
||||
}
|
||||
};
|
||||
|
||||
const findRecentlyAlertedCertificates = async (
|
||||
alertId: string,
|
||||
certificateIds: string[],
|
||||
withinHours = 24
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
if (certificateIds.length === 0) return [];
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setHours(cutoffDate.getHours() - withinHours);
|
||||
|
||||
const results = (await db
|
||||
.replicaNode()
|
||||
.select("cert.certificateId")
|
||||
.from(`${TableName.PkiAlertHistory} as hist`)
|
||||
.join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId")
|
||||
.where("hist.alertId", alertId)
|
||||
.where("hist.hasNotificationSent", true)
|
||||
.where("hist.triggeredAt", ">=", cutoffDate)
|
||||
.whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>;
|
||||
|
||||
return results.map((row) => row.certificateId);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindRecentlyAlertedCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertHistoryOrm,
|
||||
createWithCertificates,
|
||||
findByAlertId,
|
||||
findRecentlyAlertedCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,556 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
import {
|
||||
applyCaFilters,
|
||||
applyCertificateFilters,
|
||||
requiresProfileJoin,
|
||||
sanitizeLikeInput,
|
||||
shouldIncludeCAs
|
||||
} from "./pki-alert-v2-filter-utils";
|
||||
import { CertificateOrigin, TCertificatePreview, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
export type TPkiAlertV2DALFactory = ReturnType<typeof pkiAlertV2DALFactory>;
|
||||
|
||||
export const pkiAlertV2DALFactory = (db: TDbClient) => {
|
||||
const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2);
|
||||
|
||||
const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise<TPkiAlertsV2> => {
|
||||
try {
|
||||
const serializedData = {
|
||||
...data,
|
||||
filters: data.filters ? JSON.stringify(data.filters) : null
|
||||
};
|
||||
const [res] = await (tx || db)(TableName.PkiAlertsV2).insert(serializedData).returning("*");
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateById = async (id: string, data: TPkiAlertsV2Update, tx?: Knex): Promise<TPkiAlertsV2> => {
|
||||
try {
|
||||
const serializedData: Record<string, unknown> = {
|
||||
...data,
|
||||
filters: data.filters !== undefined ? JSON.stringify(data.filters) : undefined
|
||||
};
|
||||
Object.keys(serializedData).forEach((key) => {
|
||||
if (serializedData[key] === undefined) {
|
||||
delete serializedData[key];
|
||||
}
|
||||
});
|
||||
|
||||
const [res] = await (tx || db)(TableName.PkiAlertsV2).where({ id }).update(serializedData).returning("*");
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "UpdateById" });
|
||||
}
|
||||
};
|
||||
|
||||
const findById = async (id: string, tx?: Knex): Promise<TPkiAlertsV2 | null> => {
|
||||
try {
|
||||
const [res] = await (tx || db.replicaNode())(TableName.PkiAlertsV2).where({ id }).select("*");
|
||||
|
||||
if (!res) return null;
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindById" });
|
||||
}
|
||||
};
|
||||
|
||||
type TChannelResult = {
|
||||
id: string;
|
||||
alertId: string;
|
||||
channelType: string;
|
||||
config: unknown;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type TAlertWithChannels = TPkiAlertsV2 & {
|
||||
channels: TChannelResult[];
|
||||
};
|
||||
|
||||
const findByIdWithChannels = async (alertId: string, tx?: Knex): Promise<TAlertWithChannels | null> => {
|
||||
try {
|
||||
const [alert] = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertsV2))
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.id`, alertId)) as TPkiAlertsV2[];
|
||||
|
||||
if (!alert) return null;
|
||||
|
||||
const channels = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.from(TableName.PkiAlertChannels)
|
||||
.where(`${TableName.PkiAlertChannels}.alertId`, alertId)) as TChannelResult[];
|
||||
|
||||
return {
|
||||
...alert,
|
||||
channels: channels || []
|
||||
} as TAlertWithChannels;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByIdWithChannels" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectIdWithCount = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<{ alerts: TAlertWithChannels[]; total: number }> => {
|
||||
try {
|
||||
let countQuery = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
countQuery = countQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
countQuery = countQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
countQuery = countQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
let alertQuery = (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertsV2))
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
alertQuery = alertQuery.orderBy(`${TableName.PkiAlertsV2}.createdAt`, "desc");
|
||||
|
||||
if (filters?.limit) {
|
||||
alertQuery = alertQuery.limit(filters.limit);
|
||||
}
|
||||
|
||||
if (filters?.offset) {
|
||||
alertQuery = alertQuery.offset(filters.offset);
|
||||
}
|
||||
|
||||
const [countResult, alerts] = await Promise.all([countQuery, alertQuery]);
|
||||
|
||||
const total = parseInt((countResult[0] as { count: string }).count, 10);
|
||||
|
||||
const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id);
|
||||
const channels = (await (tx || db.replicaNode())
|
||||
.select(selectAllTableCols(TableName.PkiAlertChannels))
|
||||
.from(TableName.PkiAlertChannels)
|
||||
.whereIn(`${TableName.PkiAlertChannels}.alertId`, alertIds)) as TChannelResult[];
|
||||
|
||||
const channelsByAlertId = channels.reduce(
|
||||
(acc, channel) => {
|
||||
if (!acc[channel.alertId]) {
|
||||
acc[channel.alertId] = [];
|
||||
}
|
||||
acc[channel.alertId].push(channel);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TChannelResult[]>
|
||||
);
|
||||
|
||||
const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({
|
||||
...alert,
|
||||
channels: channelsByAlertId[alert.id] || []
|
||||
}));
|
||||
|
||||
return { alerts: alertsWithChannels, total };
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindByProjectIdWithCount" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectId = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<TAlertWithChannels[]> => {
|
||||
const result = await findByProjectIdWithCount(projectId, filters, tx);
|
||||
return result.alerts;
|
||||
};
|
||||
|
||||
const countByProjectId = async (
|
||||
projectId: string,
|
||||
filters?: {
|
||||
search?: string;
|
||||
eventType?: string;
|
||||
enabled?: boolean;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<number> => {
|
||||
try {
|
||||
let query = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.PkiAlertsV2)
|
||||
.where(`${TableName.PkiAlertsV2}.projectId`, projectId);
|
||||
|
||||
if (filters?.search) {
|
||||
query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`);
|
||||
}
|
||||
|
||||
if (filters?.eventType) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType);
|
||||
}
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
return parseInt((result[0] as { count: string }).count, 10);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "CountByProjectId" });
|
||||
}
|
||||
};
|
||||
|
||||
const getDistinctProjectIds = async (
|
||||
filters?: {
|
||||
enabled?: boolean;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
let query = (tx || db.replicaNode()).distinct(`${TableName.PkiAlertsV2}.projectId`).from(TableName.PkiAlertsV2);
|
||||
|
||||
if (filters?.enabled !== undefined) {
|
||||
query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled);
|
||||
}
|
||||
|
||||
const result = await query;
|
||||
return result.map((row: { projectId: string }) => row.projectId);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "GetDistinctProjectIds" });
|
||||
}
|
||||
};
|
||||
|
||||
const findMatchingCertificates = async (
|
||||
projectId: string,
|
||||
filters: TPkiFilterRule[] = [],
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
alertBefore?: string;
|
||||
showFutureMatches?: boolean;
|
||||
showCurrentMatches?: boolean;
|
||||
showPreview?: boolean;
|
||||
excludeAlerted?: boolean;
|
||||
alertId?: string;
|
||||
},
|
||||
tx?: Knex
|
||||
): Promise<{ certificates: TCertificatePreview[]; total: number }> => {
|
||||
try {
|
||||
const includeCAs = shouldIncludeCAs(filters);
|
||||
const needsProfileJoin = requiresProfileJoin(filters);
|
||||
const limit = options?.limit || 10;
|
||||
const offset = options?.offset || 0;
|
||||
|
||||
let caTotalCount = 0;
|
||||
let certTotalCount = 0;
|
||||
|
||||
if (includeCAs) {
|
||||
let caCountQuery = (tx || db.replicaNode())
|
||||
.count("* as count")
|
||||
.from(TableName.CertificateAuthority)
|
||||
.innerJoin(
|
||||
`${TableName.InternalCertificateAuthority} as ica`,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.caId`
|
||||
);
|
||||
|
||||
caCountQuery = applyCaFilters(caCountQuery, filters, projectId) as typeof caCountQuery;
|
||||
|
||||
if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`ica."notAfter" > NOW()`);
|
||||
} else if (options.showCurrentMatches) {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
} else {
|
||||
caCountQuery = caCountQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
}
|
||||
}
|
||||
|
||||
const caCountResult = await caCountQuery;
|
||||
caTotalCount = parseInt((caCountResult[0] as { count: string }).count, 10);
|
||||
}
|
||||
|
||||
let certCountQuery = (tx || db.replicaNode()).count("* as count").from(TableName.Certificate);
|
||||
certCountQuery = applyCertificateFilters(certCountQuery, filters, projectId) as typeof certCountQuery;
|
||||
|
||||
if (options?.showPreview) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options.showCurrentMatches) {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else {
|
||||
certCountQuery = certCountQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.excludeAlerted && options?.alertId) {
|
||||
certCountQuery = certCountQuery.whereNotExists(
|
||||
(tx || db.replicaNode())
|
||||
.select("*")
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.join(
|
||||
TableName.PkiAlertHistoryCertificate,
|
||||
`${TableName.PkiAlertHistory}.id`,
|
||||
`${TableName.PkiAlertHistoryCertificate}.alertHistoryId`
|
||||
)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, options.alertId)
|
||||
.whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`)
|
||||
);
|
||||
}
|
||||
|
||||
const certCountResult = await certCountQuery;
|
||||
certTotalCount = parseInt((certCountResult[0] as { count: string }).count, 10);
|
||||
|
||||
const totalCount = caTotalCount + certTotalCount;
|
||||
let results: TCertificatePreview[] = [];
|
||||
|
||||
const fetchCertificates = async (certLimit: number, certOffset: number) => {
|
||||
const selectColumns = [
|
||||
`${TableName.Certificate}.id`,
|
||||
`${TableName.Certificate}.serialNumber`,
|
||||
`${TableName.Certificate}.commonName`,
|
||||
`${TableName.Certificate}.altNames as san`,
|
||||
`${TableName.Certificate}.notBefore`,
|
||||
`${TableName.Certificate}.notAfter`,
|
||||
`${TableName.Certificate}.status`,
|
||||
`${TableName.Certificate}.profileId`,
|
||||
`${TableName.Certificate}.pkiSubscriberId`
|
||||
];
|
||||
|
||||
if (needsProfileJoin) {
|
||||
selectColumns.push("profile.name as profileName");
|
||||
}
|
||||
|
||||
let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate);
|
||||
|
||||
certificateQuery = applyCertificateFilters(certificateQuery, filters, projectId) as typeof certificateQuery;
|
||||
|
||||
if (options?.showPreview) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else if (options.showCurrentMatches) {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
} else {
|
||||
certificateQuery = certificateQuery
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`)
|
||||
.whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereNot(`${TableName.Certificate}.status`, "revoked");
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.excludeAlerted && options?.alertId) {
|
||||
certificateQuery = certificateQuery.whereNotExists(
|
||||
(tx || db.replicaNode())
|
||||
.select("*")
|
||||
.from(TableName.PkiAlertHistory)
|
||||
.join(
|
||||
TableName.PkiAlertHistoryCertificate,
|
||||
`${TableName.PkiAlertHistory}.id`,
|
||||
`${TableName.PkiAlertHistoryCertificate}.alertHistoryId`
|
||||
)
|
||||
.where(`${TableName.PkiAlertHistory}.alertId`, options.alertId)
|
||||
.whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`)
|
||||
);
|
||||
}
|
||||
|
||||
certificateQuery = certificateQuery
|
||||
.orderBy(`${TableName.Certificate}.notAfter`, "asc")
|
||||
.limit(certLimit)
|
||||
.offset(certOffset);
|
||||
|
||||
const certificates = await certificateQuery;
|
||||
const formattedCertificates: TCertificatePreview[] = (
|
||||
certificates as Array<{
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
san: string[] | null;
|
||||
profileId: string | null;
|
||||
pkiSubscriberId: string | null;
|
||||
profileName?: string | null;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
status: string;
|
||||
}>
|
||||
).map((cert) => {
|
||||
let enrollmentType = CertificateOrigin.UNKNOWN;
|
||||
if (cert.profileId) {
|
||||
enrollmentType = CertificateOrigin.PROFILE;
|
||||
} else if (cert.pkiSubscriberId) {
|
||||
enrollmentType = CertificateOrigin.IMPORT;
|
||||
}
|
||||
|
||||
return {
|
||||
id: cert.id,
|
||||
serialNumber: cert.serialNumber,
|
||||
commonName: cert.commonName,
|
||||
san: Array.isArray(cert.san) ? cert.san : [],
|
||||
profileName: cert.profileName || null,
|
||||
enrollmentType,
|
||||
notBefore: cert.notBefore,
|
||||
notAfter: cert.notAfter,
|
||||
status: cert.status
|
||||
};
|
||||
});
|
||||
|
||||
results = [...results, ...formattedCertificates];
|
||||
};
|
||||
|
||||
if (offset < caTotalCount) {
|
||||
const caLimit = Math.min(limit, caTotalCount - offset);
|
||||
const caOffset = offset;
|
||||
|
||||
let caQuery = (tx || db.replicaNode())
|
||||
.select(
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.serialNumber`,
|
||||
`ica.commonName`,
|
||||
`ica.notBefore`,
|
||||
`ica.notAfter`
|
||||
)
|
||||
.from(TableName.CertificateAuthority)
|
||||
.innerJoin(
|
||||
`${TableName.InternalCertificateAuthority} as ica`,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`ica.caId`
|
||||
);
|
||||
|
||||
caQuery = applyCaFilters(caQuery, filters, projectId) as typeof caQuery;
|
||||
|
||||
if (options?.alertBefore) {
|
||||
if (options.showFutureMatches) {
|
||||
caQuery = caQuery
|
||||
.whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore])
|
||||
.whereRaw(`ica."notAfter" > NOW()`);
|
||||
} else {
|
||||
caQuery = caQuery
|
||||
.whereRaw(`ica."notAfter" > NOW()`)
|
||||
.whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]);
|
||||
}
|
||||
}
|
||||
|
||||
caQuery = caQuery.orderBy(`ica.notAfter`, "asc").limit(caLimit).offset(caOffset);
|
||||
|
||||
const cas = await caQuery;
|
||||
const formattedCAs: TCertificatePreview[] = (
|
||||
cas as Array<{
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
commonName: string;
|
||||
notBefore: Date;
|
||||
notAfter: Date;
|
||||
}>
|
||||
).map((ca) => ({
|
||||
id: ca.id,
|
||||
serialNumber: ca.serialNumber,
|
||||
commonName: ca.commonName,
|
||||
san: [],
|
||||
profileName: null,
|
||||
enrollmentType: CertificateOrigin.CA,
|
||||
notBefore: ca.notBefore,
|
||||
notAfter: ca.notAfter,
|
||||
status: "active"
|
||||
}));
|
||||
|
||||
results = [...results, ...formattedCAs];
|
||||
|
||||
const remainingLimit = limit - caLimit;
|
||||
if (remainingLimit > 0 && certTotalCount > 0) {
|
||||
const certOffset = 0;
|
||||
await fetchCertificates(remainingLimit, certOffset);
|
||||
}
|
||||
} else {
|
||||
const certOffset = offset - caTotalCount;
|
||||
await fetchCertificates(limit, certOffset);
|
||||
}
|
||||
|
||||
return {
|
||||
certificates: results,
|
||||
total: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindMatchingCertificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiAlertV2Orm,
|
||||
create,
|
||||
updateById,
|
||||
findById,
|
||||
findByIdWithChannels,
|
||||
findByProjectId,
|
||||
findByProjectIdWithCount,
|
||||
countByProjectId,
|
||||
getDistinctProjectIds,
|
||||
findMatchingCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Knex } from "knex";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { PkiFilterField, PkiFilterOperator, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
export const sanitizeLikeInput = (input: string): string => {
|
||||
const allowedCharsRegex = new RE2("^[a-zA-Z0-9\\s\\-_\\.@\\*]+$");
|
||||
if (!allowedCharsRegex.test(input)) {
|
||||
throw new Error(
|
||||
"Invalid characters in input. Only alphanumeric characters, spaces, hyphens, underscores, dots, @ and * are allowed."
|
||||
);
|
||||
}
|
||||
|
||||
const backslashRegex = new RE2("\\\\", "g");
|
||||
const percentRegex = new RE2("%", "g");
|
||||
const underscoreRegex = new RE2("_", "g");
|
||||
const quoteRegex = new RE2("'", "g");
|
||||
|
||||
return input
|
||||
.replace(backslashRegex, "\\\\\\\\")
|
||||
.replace(percentRegex, "\\%")
|
||||
.replace(underscoreRegex, "\\_")
|
||||
.replace(quoteRegex, "''");
|
||||
};
|
||||
|
||||
export const parseTimeToPostgresInterval = (duration: string): string => {
|
||||
if (duration.length > 32) {
|
||||
throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`);
|
||||
}
|
||||
|
||||
const durationRegex = new RE2("^(\\d+)([dwmy])$");
|
||||
const match = durationRegex.exec(duration);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`);
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const amount = parseInt(value, 10);
|
||||
|
||||
if (amount <= 0 || amount > 9999) {
|
||||
throw new Error(`Duration value out of range: ${duration}. Must be between 1 and 9999.`);
|
||||
}
|
||||
|
||||
const unitMap = {
|
||||
d: "days",
|
||||
w: "weeks",
|
||||
m: "months",
|
||||
y: "years"
|
||||
};
|
||||
|
||||
return `${amount} ${unitMap[unit as keyof typeof unitMap]}`;
|
||||
};
|
||||
|
||||
export const parseTimeToDays = (timeStr: string): number => {
|
||||
const alertBeforeRegex = new RE2("^(\\d+)([dwmy])$");
|
||||
const match = alertBeforeRegex.exec(timeStr);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const amount = parseInt(value, 10);
|
||||
|
||||
if (amount <= 0 || amount > 9999) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return amount;
|
||||
case "w":
|
||||
return amount * 7;
|
||||
case "m":
|
||||
return amount * 30;
|
||||
case "y":
|
||||
return amount * 365;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const applyProfileNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where("profile.slug", value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn("profile.slug", value);
|
||||
}
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = sanitizeLikeInput(String(v));
|
||||
if (index === 0) {
|
||||
void builder.whereILike("profile.slug", `%${sanitizedValue}%`);
|
||||
} else {
|
||||
void builder.orWhereILike("profile.slug", `%${sanitizedValue}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike("profile.slug", `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for profile_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
const applyCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = `${TableName.Certificate}.commonName`;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where(columnName, value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn(columnName, value);
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = sanitizeLikeInput(String(v));
|
||||
if (index === 0) {
|
||||
void builder.whereILike(columnName, `%${sanitizedValue}%`);
|
||||
} else {
|
||||
void builder.orWhereILike(columnName, `%${sanitizedValue}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for common_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = `${TableName.Certificate}.altNames`;
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.whereJsonSupersetOf(columnName, [value as string]);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
const sanitizedValue = `%"${String(v)}"%`;
|
||||
if (index === 0) {
|
||||
void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
} else {
|
||||
void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
{
|
||||
const sanitizedValue = `%"${String(value)}"%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]);
|
||||
}
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
return applySanFilter(query, { ...filter, operator: PkiFilterOperator.MATCHES });
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH: {
|
||||
const startsWithValue = `%"${String(value)}%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]);
|
||||
}
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH: {
|
||||
const endsWithValue = `%${String(value)}"%`;
|
||||
return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, endsWithValue]);
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for SAN: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
export const shouldIncludeCAs = (filters: TPkiFilterRule[]): boolean => {
|
||||
return filters.some((filter) => filter.field === PkiFilterField.INCLUDE_CAS && filter.value === true);
|
||||
};
|
||||
|
||||
const applyCaCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => {
|
||||
const { value } = filter;
|
||||
const columnName = "ica.commonName";
|
||||
|
||||
switch (filter.operator) {
|
||||
case PkiFilterOperator.EQUALS:
|
||||
return query.where(columnName, value as string);
|
||||
|
||||
case PkiFilterOperator.MATCHES:
|
||||
if (Array.isArray(value)) {
|
||||
return query.whereIn(columnName, value);
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.CONTAINS:
|
||||
if (Array.isArray(value)) {
|
||||
return query.where((builder) => {
|
||||
value.forEach((v, index) => {
|
||||
if (index === 0) {
|
||||
void builder.whereILike(columnName, `%${sanitizeLikeInput(String(v))}%`);
|
||||
} else {
|
||||
void builder.orWhereILike(columnName, `%${sanitizeLikeInput(String(v))}%`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.STARTS_WITH:
|
||||
return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`);
|
||||
|
||||
case PkiFilterOperator.ENDS_WITH:
|
||||
return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`);
|
||||
|
||||
default:
|
||||
logger.warn(`Unsupported operator for CA common_name: ${String(filter.operator)}`);
|
||||
return query;
|
||||
}
|
||||
};
|
||||
|
||||
export const applyCaFilters = (
|
||||
query: Knex.QueryBuilder,
|
||||
filters: TPkiFilterRule[],
|
||||
projectId: string
|
||||
): Knex.QueryBuilder => {
|
||||
let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId).whereNotNull("ica.caId"); // Only include CAs that have internal CA data
|
||||
|
||||
filters.forEach((filter) => {
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
filteredQuery = applyCaCommonNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredQuery;
|
||||
};
|
||||
|
||||
export const validateFilterRules = (filters: TPkiFilterRule[]): void => {
|
||||
for (const filter of filters) {
|
||||
if (!Object.values(PkiFilterField).includes(filter.field)) {
|
||||
throw new Error(`Invalid filter field: ${filter.field}`);
|
||||
}
|
||||
|
||||
if (!Object.values(PkiFilterOperator).includes(filter.operator)) {
|
||||
throw new Error(`Invalid filter operator: ${filter.operator}`);
|
||||
}
|
||||
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.INCLUDE_CAS:
|
||||
if (typeof filter.value !== "boolean") {
|
||||
throw new Error("include_cas filter value must be boolean");
|
||||
}
|
||||
break;
|
||||
|
||||
case PkiFilterField.PROFILE_NAME:
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
case PkiFilterField.SAN:
|
||||
if (filter.operator === PkiFilterOperator.CONTAINS || filter.operator === PkiFilterOperator.MATCHES) {
|
||||
if (!Array.isArray(filter.value) && typeof filter.value !== "string") {
|
||||
throw new Error(
|
||||
`${filter.field} filter value must be string or array of strings for ${filter.operator} operator`
|
||||
);
|
||||
}
|
||||
} else if (typeof filter.value !== "string") {
|
||||
throw new Error(`${filter.field} filter value must be string for ${filter.operator} operator`);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const requiresProfileJoin = (filters: TPkiFilterRule[]): boolean => {
|
||||
return filters.some((filter) => filter.field === PkiFilterField.PROFILE_NAME);
|
||||
};
|
||||
|
||||
export const applyCertificateFilters = (
|
||||
query: Knex.QueryBuilder,
|
||||
filters: TPkiFilterRule[],
|
||||
projectId: string
|
||||
): Knex.QueryBuilder => {
|
||||
let filteredQuery = query.where(`${TableName.Certificate}.projectId`, projectId);
|
||||
|
||||
const needsProfileJoin = requiresProfileJoin(filters);
|
||||
if (needsProfileJoin) {
|
||||
filteredQuery = filteredQuery.leftJoin(
|
||||
`${TableName.PkiCertificateProfile} as profile`,
|
||||
`${TableName.Certificate}.profileId`,
|
||||
"profile.id"
|
||||
);
|
||||
}
|
||||
|
||||
filters.forEach((filter) => {
|
||||
switch (filter.field) {
|
||||
case PkiFilterField.PROFILE_NAME:
|
||||
filteredQuery = applyProfileNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.COMMON_NAME:
|
||||
filteredQuery = applyCommonNameFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.SAN:
|
||||
filteredQuery = applySanFilter(filteredQuery, filter);
|
||||
break;
|
||||
|
||||
case PkiFilterField.INCLUDE_CAS:
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(`Unknown filter field: ${String(filter.field)}`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return filteredQuery;
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal";
|
||||
import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal";
|
||||
import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils";
|
||||
import { TPkiAlertV2ServiceFactory } from "./pki-alert-v2-service";
|
||||
import { CertificateOrigin, PkiAlertEventType, TPkiFilterRule } from "./pki-alert-v2-types";
|
||||
|
||||
type TPkiAlertV2QueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiAlertV2Service: Pick<TPkiAlertV2ServiceFactory, "sendAlertNotifications">;
|
||||
pkiAlertV2DAL: Pick<TPkiAlertV2DALFactory, "findByProjectId" | "findMatchingCertificates" | "getDistinctProjectIds">;
|
||||
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "findRecentlyAlertedCertificates">;
|
||||
};
|
||||
|
||||
export type TPkiAlertV2QueueServiceFactory = ReturnType<typeof pkiAlertV2QueueServiceFactory>;
|
||||
|
||||
export const pkiAlertV2QueueServiceFactory = ({
|
||||
queueService,
|
||||
pkiAlertV2Service,
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertHistoryDAL
|
||||
}: TPkiAlertV2QueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
const calculateDeduplicationWindow = (alertBefore: string): number => {
|
||||
const alertDays = parseTimeToDays(alertBefore);
|
||||
|
||||
if (alertDays === 0) {
|
||||
return 24;
|
||||
}
|
||||
|
||||
if (alertDays <= 1) {
|
||||
return 8;
|
||||
}
|
||||
if (alertDays <= 7) {
|
||||
return 24;
|
||||
}
|
||||
if (alertDays <= 30) {
|
||||
return 48;
|
||||
}
|
||||
if (alertDays <= 90) {
|
||||
return 168;
|
||||
}
|
||||
return 720;
|
||||
};
|
||||
|
||||
const getAllProjectsWithAlerts = async (): Promise<string[]> => {
|
||||
try {
|
||||
const projectIds = await pkiAlertV2DAL.getDistinctProjectIds({ enabled: true });
|
||||
|
||||
logger.info(`Found ${projectIds.length} projects with PKI alerts`);
|
||||
return projectIds;
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to get projects with alerts");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateAlert = async (
|
||||
alert: {
|
||||
id: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
},
|
||||
projectId: string
|
||||
): Promise<{ shouldNotify: boolean; certificateIds: string[] }> => {
|
||||
if (alert.eventType !== PkiAlertEventType.EXPIRATION) {
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, alert.filters, {
|
||||
limit: 1000,
|
||||
alertBefore: parseTimeToPostgresInterval(alert.alertBefore),
|
||||
showCurrentMatches: true
|
||||
});
|
||||
|
||||
if (result.certificates.length === 0) {
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
const allCertificateIds = result.certificates
|
||||
.filter((cert) => cert.enrollmentType !== CertificateOrigin.CA)
|
||||
.map((cert) => cert.id);
|
||||
|
||||
const deduplicationHours = calculateDeduplicationWindow(alert.alertBefore);
|
||||
const recentlyAlertedIds = await pkiAlertHistoryDAL.findRecentlyAlertedCertificates(
|
||||
alert.id,
|
||||
allCertificateIds,
|
||||
deduplicationHours
|
||||
);
|
||||
|
||||
const certificateIds = allCertificateIds.filter((certId) => !recentlyAlertedIds.includes(certId));
|
||||
|
||||
if (certificateIds.length === 0) {
|
||||
logger.debug(
|
||||
`All ${allCertificateIds.length} matching certificates for alert ${alert.id} were already alerted within the last ${deduplicationHours} hours`
|
||||
);
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Alert ${alert.id}: Found ${allCertificateIds.length} expiring certificates, ${recentlyAlertedIds.length} already alerted recently, ${certificateIds.length} new to alert`
|
||||
);
|
||||
|
||||
return {
|
||||
shouldNotify: true,
|
||||
certificateIds
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to evaluate alert ${alert.id}`);
|
||||
return { shouldNotify: false, certificateIds: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const processProjectAlerts = async (
|
||||
projectId: string
|
||||
): Promise<{ alertsProcessed: number; notificationsSent: number }> => {
|
||||
logger.info(`Processing alerts for project: ${projectId}`);
|
||||
|
||||
const alerts = await pkiAlertV2DAL.findByProjectId(projectId, {
|
||||
enabled: true,
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
let alertsProcessed = 0;
|
||||
let notificationsSent = 0;
|
||||
|
||||
for (const alert of alerts) {
|
||||
const typedAlert = alert as {
|
||||
id: string;
|
||||
name: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
};
|
||||
try {
|
||||
const { shouldNotify, certificateIds } = await evaluateAlert(typedAlert, projectId);
|
||||
|
||||
if (shouldNotify && certificateIds.length > 0) {
|
||||
await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds);
|
||||
notificationsSent += 1;
|
||||
logger.info(
|
||||
`Sent notification for alert ${typedAlert.id} (${typedAlert.name}) with ${certificateIds.length} certificates`
|
||||
);
|
||||
}
|
||||
|
||||
alertsProcessed += 1;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed processing ${alertsProcessed} alerts for project ${projectId}, sent ${notificationsSent} notifications`
|
||||
);
|
||||
|
||||
return { alertsProcessed, notificationsSent };
|
||||
};
|
||||
|
||||
const processDailyAlerts = async () => {
|
||||
logger.info("Starting daily PKI alert processing...");
|
||||
|
||||
const allProjects = await getAllProjectsWithAlerts();
|
||||
|
||||
let totalAlertsProcessed = 0;
|
||||
let totalNotificationsSent = 0;
|
||||
|
||||
for (const projectId of allProjects) {
|
||||
try {
|
||||
const { alertsProcessed, notificationsSent } = await processProjectAlerts(projectId);
|
||||
totalAlertsProcessed += alertsProcessed;
|
||||
totalNotificationsSent += notificationsSent;
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to process alerts for project ${projectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Daily PKI alert processing completed. Processed ${totalAlertsProcessed} alerts, sent ${totalNotificationsSent} notifications.`
|
||||
);
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queueService.startPg<QueueName.DailyPkiAlertV2Processing>(
|
||||
QueueJobs.DailyPkiAlertV2Processing,
|
||||
async () => {
|
||||
try {
|
||||
logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task started`);
|
||||
await processDailyAlerts();
|
||||
logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task completed successfully`);
|
||||
} catch (error) {
|
||||
logger.error(error, `${QueueJobs.DailyPkiAlertV2Processing}: queue task failed`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 1,
|
||||
pollingIntervalSeconds: 60
|
||||
}
|
||||
);
|
||||
|
||||
await queueService.schedulePg(QueueJobs.DailyPkiAlertV2Processing, "0 0 * * *", undefined, { tz: "UTC" });
|
||||
};
|
||||
|
||||
return {
|
||||
init
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,507 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
|
||||
|
||||
import { TPkiAlertChannelDALFactory } from "./pki-alert-channel-dal";
|
||||
import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal";
|
||||
import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal";
|
||||
import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils";
|
||||
import {
|
||||
CertificateOrigin,
|
||||
PkiAlertChannelType,
|
||||
PkiAlertEventType,
|
||||
TAlertV2Response,
|
||||
TChannelConfig,
|
||||
TCreateAlertV2DTO,
|
||||
TDeleteAlertV2DTO,
|
||||
TEmailChannelConfig,
|
||||
TGetAlertV2DTO,
|
||||
TListAlertsV2DTO,
|
||||
TListAlertsV2Response,
|
||||
TListCurrentMatchingCertificatesDTO,
|
||||
TListMatchingCertificatesDTO,
|
||||
TListMatchingCertificatesResponse,
|
||||
TPkiFilterRule,
|
||||
TUpdateAlertV2DTO
|
||||
} from "./pki-alert-v2-types";
|
||||
|
||||
type TPkiAlertV2ServiceFactoryDep = {
|
||||
pkiAlertV2DAL: Pick<
|
||||
TPkiAlertV2DALFactory,
|
||||
| "create"
|
||||
| "findById"
|
||||
| "findByIdWithChannels"
|
||||
| "updateById"
|
||||
| "deleteById"
|
||||
| "findByProjectId"
|
||||
| "findByProjectIdWithCount"
|
||||
| "countByProjectId"
|
||||
| "findMatchingCertificates"
|
||||
| "transaction"
|
||||
>;
|
||||
pkiAlertChannelDAL: Pick<TPkiAlertChannelDALFactory, "create" | "findByAlertId" | "deleteByAlertId" | "insertMany">;
|
||||
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "createWithCertificates" | "findByAlertId">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
smtpService: Pick<TSmtpService, "sendMail">;
|
||||
};
|
||||
|
||||
export type TPkiAlertV2ServiceFactory = ReturnType<typeof pkiAlertV2ServiceFactory>;
|
||||
|
||||
export const pkiAlertV2ServiceFactory = ({
|
||||
pkiAlertV2DAL,
|
||||
pkiAlertChannelDAL,
|
||||
pkiAlertHistoryDAL,
|
||||
permissionService,
|
||||
smtpService
|
||||
}: TPkiAlertV2ServiceFactoryDep) => {
|
||||
type TAlertWithChannels = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
eventType: string;
|
||||
alertBefore: string;
|
||||
filters: TPkiFilterRule[];
|
||||
enabled: boolean;
|
||||
projectId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
channels?: Array<{
|
||||
id: string;
|
||||
channelType: string;
|
||||
config: unknown;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => {
|
||||
return {
|
||||
id: alert.id,
|
||||
name: alert.name,
|
||||
description: alert.description,
|
||||
eventType: alert.eventType as PkiAlertEventType,
|
||||
alertBefore: alert.alertBefore,
|
||||
filters: alert.filters,
|
||||
enabled: alert.enabled,
|
||||
projectId: alert.projectId,
|
||||
channels: (alert.channels || []).map((channel) => ({
|
||||
id: channel.id,
|
||||
channelType: channel.channelType as PkiAlertChannelType,
|
||||
config: channel.config as TChannelConfig,
|
||||
enabled: channel.enabled,
|
||||
createdAt: channel.createdAt,
|
||||
updatedAt: channel.updatedAt
|
||||
})),
|
||||
createdAt: alert.createdAt,
|
||||
updatedAt: alert.updatedAt
|
||||
};
|
||||
};
|
||||
|
||||
const createAlert = async ({
|
||||
projectId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled = true,
|
||||
channels,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TCreateAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
|
||||
return pkiAlertV2DAL.transaction(async (tx) => {
|
||||
const alert = await pkiAlertV2DAL.create(
|
||||
{
|
||||
projectId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
const channelInserts = channels.map((channel) => ({
|
||||
alertId: alert.id,
|
||||
channelType: channel.channelType,
|
||||
config: channel.config,
|
||||
enabled: channel.enabled
|
||||
}));
|
||||
|
||||
await pkiAlertChannelDAL.insertMany(channelInserts, tx);
|
||||
|
||||
const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id, tx);
|
||||
if (!completeAlert) {
|
||||
throw new NotFoundError({ message: "Failed to retrieve created alert" });
|
||||
}
|
||||
|
||||
return formatAlertResponse(completeAlert as TAlertWithChannels);
|
||||
});
|
||||
};
|
||||
|
||||
const getAlertById = async ({
|
||||
alertId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TGetAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
return formatAlertResponse(alert as TAlertWithChannels);
|
||||
};
|
||||
|
||||
const listAlerts = async ({
|
||||
projectId,
|
||||
search,
|
||||
eventType,
|
||||
enabled,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListAlertsV2DTO): Promise<TListAlertsV2Response> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const filters = { search, eventType, enabled, limit, offset };
|
||||
|
||||
const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters);
|
||||
|
||||
return {
|
||||
alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)),
|
||||
total
|
||||
};
|
||||
};
|
||||
|
||||
const updateAlert = async ({
|
||||
alertId,
|
||||
name,
|
||||
description,
|
||||
eventType,
|
||||
alertBefore,
|
||||
filters,
|
||||
enabled,
|
||||
channels,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TUpdateAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
let alert = await pkiAlertV2DAL.findById(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
if (alertBefore) {
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
eventType?: PkiAlertEventType;
|
||||
alertBefore?: string;
|
||||
filters?: TPkiFilterRule[];
|
||||
enabled?: boolean;
|
||||
} = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (description !== undefined) updateData.description = description;
|
||||
if (eventType !== undefined) updateData.eventType = eventType;
|
||||
if (alertBefore !== undefined) updateData.alertBefore = alertBefore;
|
||||
if (filters !== undefined) updateData.filters = filters;
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
|
||||
return pkiAlertV2DAL.transaction(async (tx) => {
|
||||
alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx);
|
||||
|
||||
if (channels) {
|
||||
await pkiAlertChannelDAL.deleteByAlertId(alertId, tx);
|
||||
|
||||
const channelInserts = channels.map((channel) => ({
|
||||
alertId,
|
||||
channelType: channel.channelType,
|
||||
config: channel.config,
|
||||
enabled: channel.enabled
|
||||
}));
|
||||
|
||||
await pkiAlertChannelDAL.insertMany(channelInserts, tx);
|
||||
}
|
||||
|
||||
const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx);
|
||||
if (!completeAlert) {
|
||||
throw new NotFoundError({ message: "Failed to retrieve updated alert" });
|
||||
}
|
||||
|
||||
return formatAlertResponse(completeAlert as TAlertWithChannels);
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAlert = async ({
|
||||
alertId,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TDeleteAlertV2DTO): Promise<TAlertV2Response> => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const formattedAlert = formatAlertResponse(alert as TAlertWithChannels);
|
||||
await pkiAlertV2DAL.deleteById(alertId);
|
||||
|
||||
return formattedAlert;
|
||||
};
|
||||
|
||||
const listMatchingCertificates = async ({
|
||||
alertId,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListMatchingCertificatesDTO): Promise<TListMatchingCertificatesResponse> => {
|
||||
const alert = await pkiAlertV2DAL.findById(alertId);
|
||||
if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` });
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
const options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
showPreview?: boolean;
|
||||
excludeAlerted?: boolean;
|
||||
alertId?: string;
|
||||
} = {
|
||||
limit,
|
||||
offset,
|
||||
showPreview: true,
|
||||
excludeAlerted: (alert as { eventType: string }).eventType === PkiAlertEventType.EXPIRATION,
|
||||
alertId
|
||||
};
|
||||
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(
|
||||
(alert as { projectId: string }).projectId,
|
||||
(alert as { filters: TPkiFilterRule[] }).filters,
|
||||
options
|
||||
);
|
||||
|
||||
return {
|
||||
certificates: result.certificates,
|
||||
total: result.total
|
||||
};
|
||||
};
|
||||
|
||||
const listCurrentMatchingCertificates = async ({
|
||||
projectId,
|
||||
filters,
|
||||
alertBefore,
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actor,
|
||||
actorOrgId
|
||||
}: TListCurrentMatchingCertificatesDTO): Promise<TListMatchingCertificatesResponse> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts);
|
||||
|
||||
try {
|
||||
parseTimeToPostgresInterval(alertBefore);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" });
|
||||
}
|
||||
|
||||
const options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
showPreview?: boolean;
|
||||
alertBefore?: string;
|
||||
} = {
|
||||
limit,
|
||||
offset,
|
||||
showPreview: true,
|
||||
alertBefore: parseTimeToPostgresInterval(alertBefore)
|
||||
};
|
||||
|
||||
const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, filters, options);
|
||||
|
||||
return {
|
||||
certificates: result.certificates,
|
||||
total: result.total
|
||||
};
|
||||
};
|
||||
|
||||
const sendAlertNotifications = async (alertId: string, certificateIds: string[]) => {
|
||||
const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId);
|
||||
if (!alert || !(alert as { enabled: boolean }).enabled) return;
|
||||
|
||||
const channels =
|
||||
(alert as { channels?: Array<{ enabled: boolean; channelType: string; config: unknown }> }).channels?.filter(
|
||||
(channel: { enabled: boolean; channelType: string; config: unknown }) => channel.enabled
|
||||
) || [];
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const { certificates } = await pkiAlertV2DAL.findMatchingCertificates(
|
||||
(alert as { projectId: string }).projectId,
|
||||
(alert as { filters: TPkiFilterRule[] }).filters,
|
||||
{
|
||||
alertBefore: parseTimeToPostgresInterval((alert as { alertBefore: string }).alertBefore)
|
||||
}
|
||||
);
|
||||
|
||||
const matchingCertificates = certificates.filter(
|
||||
(cert) => certificateIds.includes(cert.id) && cert.enrollmentType !== CertificateOrigin.CA
|
||||
);
|
||||
|
||||
if (matchingCertificates.length === 0) return;
|
||||
|
||||
let hasNotificationSent = false;
|
||||
let notificationError: string | undefined;
|
||||
|
||||
try {
|
||||
const emailChannels = channels.filter(
|
||||
(channel: { enabled: boolean; channelType: string; config: unknown }) =>
|
||||
channel.channelType === PkiAlertChannelType.EMAIL
|
||||
);
|
||||
|
||||
const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore);
|
||||
const alertName = (alert as { name: string }).name;
|
||||
|
||||
const emailPromises = emailChannels.map((channel) => {
|
||||
const config = channel.config as TEmailChannelConfig;
|
||||
|
||||
return smtpService.sendMail({
|
||||
recipients: config.recipients,
|
||||
subjectLine: `Infisical Certificate Alert - ${alertName}`,
|
||||
substitutions: {
|
||||
alertName,
|
||||
alertBeforeDays,
|
||||
projectId: (alert as { projectId: string }).projectId,
|
||||
items: matchingCertificates.map((cert) => ({
|
||||
type: "Certificate",
|
||||
friendlyName: cert.commonName,
|
||||
serialNumber: cert.serialNumber,
|
||||
expiryDate: cert.notAfter.toLocaleDateString()
|
||||
}))
|
||||
},
|
||||
template: SmtpTemplates.PkiExpirationAlert
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(emailPromises);
|
||||
|
||||
hasNotificationSent = true;
|
||||
} catch (error) {
|
||||
notificationError = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
logger.error(error, `Failed to send notifications for alert ${alertId}`);
|
||||
}
|
||||
|
||||
await pkiAlertHistoryDAL.createWithCertificates(alertId, certificateIds, {
|
||||
hasNotificationSent,
|
||||
notificationError
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
createAlert,
|
||||
getAlertById,
|
||||
listAlerts,
|
||||
updateAlert,
|
||||
deleteAlert,
|
||||
listMatchingCertificates,
|
||||
listCurrentMatchingCertificates,
|
||||
sendAlertNotifications
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user