Merge remote-tracking branch 'origin/main' into feat/pki-alerting

This commit is contained in:
Carlos Monastyrski
2025-11-07 16:43:14 -03:00
508 changed files with 13840 additions and 10621 deletions

14
backend/bdd/.env.example Normal file
View File

@@ -0,0 +1,14 @@
# API URL to the Infisical server
INFISICAL_API_URL="http://localhost:8080"
# JWT token with admin permission for the cert projects
INFISICAL_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoTWV0aG9kIjoiZW1haWwiLCJhdXRoVG9rZW5UeXBlIjoiYWNjZXNzVG9rZW4iLCJ1c2VySWQiOiJkOWZlMzMwZi00OTQwLTQ3ZmYtYmE4Yy0zZGUxYTVlYjYzNGEiLCJ0b2tlblZlcnNpb25JZCI6ImM4MWZhODY1LTFjYTAtNGNmZS1iNjM5LThlMDI3M2E2N2JjYyIsImFjY2Vzc1ZlcnNpb24iOjEsIm9yZ2FuaXphdGlvbklkIjoiM2I5OTRkNTktMjE5Ny00MjcwLWE3MGMtOTczMzdmZjlmYTRkIiwiaWF0IjoxNzYyMjAzMjQwLCJleHAiOjE3NjMwNjcyNDB9.KFYeMYAv3Ceis0hp-pTa8fsLLWbT-JcqhuWyIY0DWU0"
# PKI project id
PROJECT_ID="c051e74c-48a7-4724-832c-d5b496698546"
# Certificate CA id
CERT_CA_ID="2f0d9820-e5a8-48bb-aac8-deed9d868a1e"
# Certificate template id
CERT_TEMPLATE_ID="4dbf6bb0-6e86-4ee6-8550-9171428c8e82"
# ACME profile ID
PROFILE_ID="108c6303-ab8c-4986-ab88-eefe11bb5553"
# ACME profile EAB secret
EAB_SECRET="JHYxJDEwJFJldE9tb3dkUU9XVnJLZWFia3IxVC94L1pIbHRoQnJsNVRKZWFoV1hpNTczVHpwMFNGZzU4OGtuU3NVK1crVGM"

View File

@@ -0,0 +1 @@
3.12

0
backend/bdd/README.md Normal file
View File

View File

@@ -0,0 +1,26 @@
import os
import httpx
from behave.runner import Context
from dotenv import load_dotenv
load_dotenv()
BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080")
PROJECT_ID = os.environ.get("PROJECT_ID")
CERT_CA_ID = os.environ.get("CERT_CA_ID")
CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID")
AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN")
def before_all(context: Context):
context.vars = {
"BASE_URL": BASE_URL,
"PROJECT_ID": PROJECT_ID,
"CERT_CA_ID": CERT_CA_ID,
"CERT_TEMPLATE_ID": CERT_TEMPLATE_ID,
"AUTH_TOKEN": AUTH_TOKEN,
}
context.http_client = httpx.Client(
base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"}
)

View File

@@ -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

View File

@@ -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"
}
"""

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}
"""

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,486 @@
import json
import logging
import os
import re
import threading
import httpx
import jq
import requests
import glom
from faker import Faker
from acme import client
from acme import messages
from acme import standalone
from behave.runner import Context
from behave import given
from behave import when
from behave import then
from josepy.jwk import JWKRSA
from josepy import JSONObjectWithFields
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
ACC_KEY_BITS = 2048
ACC_KEY_PUBLIC_EXPONENT = 65537
logger = logging.getLogger(__name__)
faker = Faker()
class AcmeProfile:
def __init__(self, id: str, eab_kid: str, eab_secret: str):
self.id = id
self.eab_kid = eab_kid
self.eab_secret = eab_secret
def replace_vars(payload: dict | list | int | float | str, vars: dict):
if isinstance(payload, dict):
return {
replace_vars(key, vars): replace_vars(value, vars)
for key, value in payload.items()
}
elif isinstance(payload, list):
return [replace_vars(item, vars) for item in payload]
elif isinstance(payload, str):
return payload.format(**vars)
else:
return payload
def parse_glom_path(path_str: str) -> glom.Path:
"""
Parse a glom path string with 'attr[index]' syntax into a Path object.
Examples:
>>> parse_glom_path('authorizations[0]') == Path('authorizations', 0)
True
>>> parse_glom_path('data.items[1].name') == Path('data', 'items', 1, 'name')
True
>>> parse_glom_path('user.addresses[0].street') == Path('user', 'addresses', 0, 'street')
True
"""
parts = []
# Split by dots, but preserve bracketed content
tokens = re.split(r"(?<!\[)\.(?![^\[]*\])", path_str)
for token in tokens:
token = token.strip()
if not token:
continue
# Check for attr[index] pattern
match = re.match(r"^(.+?)\[([^\]]+)\]$", token)
if match:
attr_name = match.group(1).strip()
index_str = match.group(2).strip()
# Parse index (support integers, slices, etc.)
if index_str.isdigit():
index = int(index_str)
elif "-" in index_str:
# Handle negative indices like [-1]
index = int(index_str)
elif ":" in index_str:
# Handle slices like [0:10]
index = slice(
*map(int, [x.strip() for x in index_str.split(":") if x.strip()])
)
else:
# Treat as string key
index = index_str
parts.extend([attr_name, index])
else:
# Plain attribute/key
parts.append(token)
return glom.Path(*parts)
def eval_var(context: Context, var_path: str, as_json: bool = True):
parts = var_path.split(".", 1)
value = context.vars[parts[0]]
if len(parts) == 2:
value = glom.glom(value, parse_glom_path(parts[1]))
if as_json:
if isinstance(value, JSONObjectWithFields):
value = value.to_json()
elif isinstance(value, requests.Response):
value = value.json()
elif isinstance(value, httpx.Response):
value = value.json()
return value
def prepare_headers(context: Context) -> dict | None:
headers = {}
auth_token = getattr(context, "auth_token", None)
if auth_token is not None:
headers["authorization"] = "Bearer {}".format(auth_token)
if not headers:
return None
return headers
@given("I make a random {faker_type} as {var_name}")
def step_impl(context: Context, faker_type: str, var_name: str):
context.vars[var_name] = getattr(faker, faker_type)()
@given('I have an ACME cert profile as "{profile_var}"')
def step_impl(context: Context, profile_var: str):
# TODO: Fixed value for now, just to make test much easier,
# we should call infisical API to create such profile instead
# in the future
profile_id = os.getenv("PROFILE_ID")
kid = profile_id
secret = os.getenv("EAB_SECRET")
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
eab_secret=secret,
)
@given("I use {token_var} for authentication")
def step_impl(context: Context, token_var: str):
context.auth_token = eval_var(context, token_var)
@when('I send a {method} request to "{url}"')
def step_impl(context: Context, method: str, url: str):
logger.debug("Sending %s request to %s", method, url)
response = context.http_client.request(
method, url.format(**context.vars), headers=prepare_headers(context)
)
context.vars["response"] = response
logger.debug("Response status: %r", response.status_code)
try:
logger.debug("Response JSON payload: %r", response.json())
except json.decoder.JSONDecodeError:
pass
@when('I send a {method} request to "{url}" with JSON payload')
def step_impl(context: Context, method: str, url: str):
json_payload = json.loads(context.text)
json_payload = replace_vars(json_payload, context.vars)
logger.debug(
"Sending %s request to %s with JSON payload: %s",
method,
url,
json.dumps(json_payload),
)
response = context.http_client.request(
method,
url.format(**context.vars),
headers=prepare_headers(context),
json=json_payload,
)
context.vars["response"] = response
logger.debug("Response status: %r", response.status_code)
logger.debug("Response JSON payload: %r", response.json())
@when("I have an ACME client connecting to {url}")
def step_impl(context: Context, url: str):
private_key = rsa.generate_private_key(
public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS
)
pem_bytes = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
acc_jwk = JWKRSA.load(pem_bytes)
net = client.ClientNetwork(acc_jwk)
directory_url = url.format(**context.vars)
directory = client.ClientV2.get_directory(directory_url, net)
context.acme_client = client.ClientV2(directory, net=net)
@then('the response status code should be "{expected_status_code:d}"')
def step_impl(context: Context, expected_status_code: int):
assert context.vars["response"].status_code == expected_status_code, (
f"{context.vars['response'].status_code} != {expected_status_code}"
)
@then('the response header "{header}" should contains non-empty value')
def step_impl(context: Context, header: str):
header_value = context.vars["response"].headers.get(header)
assert header_value is not None, f"Header {header} not found in response"
assert header_value, (
f"Header {header} found in response, but value {header_value:!r} is empty"
)
@then("the response body should match JSON value")
def step_impl(context: Context):
payload = context.vars["response"].json()
expected = json.loads(context.text)
replaced = replace_vars(expected, context.vars)
assert payload == replaced, f"{payload} != {replaced}"
@then(
'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}'
)
def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str):
acme_client = context.acme_client
account_public_key = acme_client.net.key.public_key()
eab = messages.ExternalAccountBinding.from_data(
account_public_key=account_public_key,
kid=replace_vars(kid, context.vars),
hmac_key=replace_vars(secret, context.vars),
directory=acme_client.directory,
hmac_alg="HS256",
)
registration = messages.NewRegistration.from_data(
email=email,
external_account_binding=eab,
)
context.vars[account_var] = acme_client.new_account(registration)
@then(
"I submit the certificate signing request PEM {pem_var} certificate order to the ACME server as {order_var}"
)
def step_impl(context: Context, pem_var: str, order_var: str):
context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var])
@then("I send an ACME post-as-get to {uri_path} as {res_var}")
def step_impl(context: Context, uri_path: str, res_var: str):
uri_value = eval_var(context, uri_path)
context.vars[res_var] = context.acme_client._post_as_get(uri_value)
@when("I create certificate signing request as {csr_var}")
def step_impl(context: Context, csr_var: str):
context.vars[csr_var] = x509.CertificateSigningRequestBuilder()
@then("I add names to certificate signing request {csr_var}")
def step_impl(context: Context, csr_var: str):
names = json.loads(context.text)
builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var]
context.vars[csr_var] = builder.subject_name(
x509.Name(
[
x509.NameAttribute(getattr(NameOID, name), value)
for name, value in names.items()
]
)
)
@then("I add subject alternative name to certificate signing request {csr_var}")
def step_impl(context: Context, csr_var: str):
names = json.loads(context.text)
builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var]
context.vars[csr_var] = builder.add_extension(
x509.SubjectAlternativeName([x509.DNSName(name) for name in names]),
critical=False,
)
@then("I create a RSA private key pair as {rsa_key_var}")
def step_impl(context: Context, rsa_key_var: str):
context.vars[rsa_key_var] = rsa.generate_private_key(
# TODO: make them configurable if we need to
public_exponent=65537,
key_size=2048,
)
@then(
"I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format"
)
def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str):
context.vars[pem_var] = (
context.vars[csr_var]
.sign(context.vars[pk_var], hashes.SHA256())
.public_bytes(serialization.Encoding.PEM)
)
@then("the value {var_path} should be true for jq {query}")
def step_impl(context: Context, var_path: str, query: str):
value = eval_var(context, var_path)
result = jq.compile(replace_vars(query, context.vars)).input_value(value).first()
assert result, f"{value} does not match {query}"
def apply_value_with_jq(context: Context, var_path: str, jq_query: str):
value = eval_var(context, var_path)
return value, jq.compile(replace_vars(jq_query, context.vars)).input_value(
value
).first()
@then('the value {var_path} with jq "{jq_query}" should be equal to json')
def step_impl(context: Context, var_path: str, jq_query: str):
value, result = apply_value_with_jq(
context=context,
var_path=var_path,
jq_query=jq_query,
)
expected_value = json.loads(context.text)
assert result == expected_value, (
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}"
)
@then('the value {var_path} with jq "{jq_query}" should be present')
def step_impl(context: Context, var_path: str, jq_query: str):
value, result = apply_value_with_jq(
context=context,
var_path=var_path,
jq_query=jq_query,
)
assert result, (
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} is not present"
)
@then('the value {var_path} with jq "{jq_query}" should be equal to {expected}')
def step_impl(context: Context, var_path: str, jq_query: str, expected: str):
value, result = apply_value_with_jq(
context=context,
var_path=var_path,
jq_query=jq_query,
)
expected_value = replace_vars(json.loads(expected), context.vars)
assert result == expected_value, (
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}"
)
@then('the value {var_path} with jq "{jq_query}" should match pattern {regex}')
def step_impl(context: Context, var_path: str, jq_query: str, regex: str):
value, result = apply_value_with_jq(
context=context,
var_path=var_path,
jq_query=jq_query,
)
assert re.match(replace_vars(regex, context.vars), result), (
f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {regex!r}"
)
@then("the value {var_path} should be equal to json")
def step_impl(context: Context, var_path: str):
value = eval_var(context, var_path)
expected_value = json.loads(context.text)
assert value == expected_value, f"{value!r} does not match {expected_value!r}"
@then("the value {var_path} should be equal to {expected}")
def step_impl(context: Context, var_path: str, expected: str):
value = eval_var(context, var_path)
expected_value = json.loads(expected)
assert value == expected_value, f"{value!r} does not match {expected_value!r}"
@then('I memorize {var_path} with jq "{jq_query}" as {var_name}')
def step_impl(context: Context, var_path: str, jq_query, var_name: str):
_, value = apply_value_with_jq(
context=context,
var_path=var_path,
jq_query=jq_query,
)
context.vars[var_name] = value
@then("I memorize {var_path} as {var_name}")
def step_impl(context: Context, var_path: str, var_name: str):
value = eval_var(context, var_path)
context.vars[var_name] = value
@then("I print the value {var_path}")
def step_impl(context: Context, var_path: str):
value = eval_var(context, var_path)
print(json.dumps(value.json(), indent=2))
@then(
"I select challenge with type {challenge_type} for domain {domain} from order at {var_path} as {challenge_var}"
)
def step_impl(
context: Context,
challenge_type: str,
domain: str,
var_path: str,
challenge_var: str,
):
order = eval_var(context, var_path, as_json=False)
if not isinstance(order, messages.OrderResource):
raise ValueError(
f"Expected OrderResource but got {type(order)!r} at {var_path!r}"
)
auths = list(
filter(lambda o: o.body.identifier.value == domain, order.authorizations)
)
if not auths:
raise ValueError(
f"Authorization for domain {domain!r} not found in {var_path!r}"
)
if len(auths) > 1:
raise ValueError(
f"More than one order for domain {domain!r} found in {var_path!r}"
)
auth = auths[0]
challenges = list(filter(lambda a: a.typ == challenge_type, auth.body.challenges))
if not challenges:
raise ValueError(
f"Authorization type {challenge_type!r} not found in {var_path!r}"
)
if len(challenges) > 1:
raise ValueError(
f"More than one authorization for type {challenge_type!r} found in {var_path!r}"
)
context.vars[challenge_var] = challenges[0]
@then("I serve challenge response for {var_path} at {hostname}")
def step_impl(context: Context, var_path: str, hostname: str):
if hostname != "localhost":
raise ValueError("Currently only localhost is supported")
challenge = eval_var(context, var_path, as_json=False)
response, validation = challenge.response_and_validation(
context.acme_client.net.key
)
resource = standalone.HTTP01RequestHandler.HTTP01Resource(
chall=challenge.chall, response=response, validation=validation
)
# TODO: make port configurable
servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource})
# Start client standalone web server.
web_server = threading.Thread(name="web_server", target=servers.serve_forever)
web_server.daemon = True
web_server.start()
context.web_server = web_server
@then("I tell ACME server that {var_path} is ready to be verified")
def step_impl(context: Context, var_path: str):
challenge = eval_var(context, var_path, as_json=False)
acme_client = context.acme_client
response, validation = challenge.response_and_validation(acme_client.net.key)
acme_client.answer_challenge(challenge, response)
@then("I poll and finalize the ACME order {var_path} as {finalized_var}")
def step_impl(context: Context, var_path: str, finalized_var: str):
order = eval_var(context, var_path, as_json=False)
acme_client = context.acme_client
finalized_order = acme_client.poll_and_finalize(order)
context.vars[finalized_var] = finalized_order

View File

@@ -0,0 +1,16 @@
[project]
name = "bdd"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"acme>=5.1.0",
"behave>=1.3.3",
"dotenv>=0.9.9",
"faker>=37.12.0",
"glom>=24.11.0",
"httpx>=0.28.1",
"josepy>=2.2.0",
"jq>=1.10.0",
]

558
backend/bdd/uv.lock generated Normal file
View File

@@ -0,0 +1,558 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "acme"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "josepy" },
{ name = "pyopenssl" },
{ name = "pyrfc3339" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/f6/897be0abeb0e64f0e6136a8a6369a54d2a603a44cb7a411f6d77dbafb4ac/acme-5.1.0.tar.gz", hash = "sha256:7b97820857d9baffed98bca50ab82bb6a636e447865d7a013a7bdd7972f03cda", size = 89982, upload-time = "2025-10-07T17:30:38.579Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/0b/4d0421412bb063f4393ae7ebf3a9a6fde621aed187a1140ccf7f9e22b823/acme-5.1.0-py3-none-any.whl", hash = "sha256:80e9c315d82302bb97279f4516ff31230d29195ab9d4a6c9411ceec20481b61e", size = 94151, upload-time = "2025-10-07T17:30:15.994Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "bdd"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "acme" },
{ name = "behave" },
{ name = "dotenv" },
{ name = "faker" },
{ name = "glom" },
{ name = "httpx" },
{ name = "josepy" },
{ name = "jq" },
]
[package.metadata]
requires-dist = [
{ name = "acme", specifier = ">=5.1.0" },
{ name = "behave", specifier = ">=1.3.3" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "faker", specifier = ">=37.12.0" },
{ name = "glom", specifier = ">=24.11.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "josepy", specifier = ">=2.2.0" },
{ name = "jq", specifier = ">=1.10.0" },
]
[[package]]
name = "behave"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
{ name = "cucumber-expressions" },
{ name = "cucumber-tag-expressions" },
{ name = "parse" },
{ name = "parse-type" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" },
]
[[package]]
name = "boltons"
version = "25.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "cucumber-expressions"
version = "18.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/f4e231167b23b3d7348aa1c90117ce8854fae186d6984ad66d705df24061/cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60", size = 22232, upload-time = "2024-10-28T11:38:48.672Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/e0/31ce90dad5234c3d52432bfce7562aa11cda4848aea90936a4be6c67d7ab/cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42", size = 20211, upload-time = "2024-10-28T11:38:47.101Z" },
]
[[package]]
name = "cucumber-tag-expressions"
version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/77/b8868653e9c7d432433d4d4d5e99d5923b309b89c8b08bc7f0cb5657ba0b/cucumber_tag_expressions-8.0.0.tar.gz", hash = "sha256:4af80282ff0349918c332428176089094019af6e2a381a2fd8f1c62a7a6bb7e8", size = 8427, upload-time = "2025-10-14T17:01:27.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" },
]
[[package]]
name = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
]
[[package]]
name = "face"
version = "24.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boltons" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/79/2484075a8549cd64beae697a8f664dee69a5ccf3a7439ee40c8f93c1978a/face-24.0.0.tar.gz", hash = "sha256:611e29a01ac5970f0077f9c577e746d48c082588b411b33a0dd55c4d872949f6", size = 62732, upload-time = "2024-11-02T05:24:26.095Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" },
]
[[package]]
name = "faker"
version = "37.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" },
]
[[package]]
name = "glom"
version = "24.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "boltons" },
{ name = "face" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/89/b57cfbc448189426f2e01b244fbe9226b059ef5423a9d49c1d335a1f1026/glom-24.11.0.tar.gz", hash = "sha256:4325f96759a912044af7b6c6bd0dba44ad8c1eb6038aab057329661d2021bb27", size = 195120, upload-time = "2024-11-02T23:17:50.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "josepy"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" },
]
[[package]]
name = "jq"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308, upload-time = "2025-07-14T18:54:53.679Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103, upload-time = "2025-07-14T18:52:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325, upload-time = "2025-07-14T18:52:40.654Z" },
{ url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212, upload-time = "2025-07-14T18:52:42.637Z" },
{ url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068, upload-time = "2025-07-14T18:52:44.709Z" },
{ url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259, upload-time = "2025-07-14T18:52:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075, upload-time = "2025-07-14T18:52:50.038Z" },
{ url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475, upload-time = "2025-07-14T18:52:53.047Z" },
{ url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416, upload-time = "2025-07-14T18:52:55.858Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113, upload-time = "2025-07-14T18:52:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316, upload-time = "2025-07-14T18:52:59.605Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325, upload-time = "2025-07-14T18:53:01.854Z" },
{ url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344, upload-time = "2025-07-14T18:53:03.528Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874, upload-time = "2025-07-14T18:53:05.406Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355, upload-time = "2025-07-14T18:53:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546, upload-time = "2025-07-14T18:53:11.756Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777, upload-time = "2025-07-14T18:53:14.856Z" },
{ url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307, upload-time = "2025-07-14T18:53:17.611Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830, upload-time = "2025-07-14T18:53:19.937Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285, upload-time = "2025-07-14T18:53:21.631Z" },
{ url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094, upload-time = "2025-07-14T18:53:23.687Z" },
]
[[package]]
name = "parse"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" },
]
[[package]]
name = "parse-type"
version = "0.6.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parse" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pyopenssl"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
]
[[package]]
name = "pyrfc3339"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]

View File

@@ -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",

View File

@@ -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",

View File

@@ -106,6 +106,7 @@ import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-c
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
import { TPkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-types";
import { TProjectServiceFactory } from "@app/services/project/project-service";
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service";
@@ -295,6 +296,7 @@ declare module "fastify" {
certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
pkiAcme: TPkiAcmeServiceFactory;
certificateEstV3: TCertificateEstV3ServiceFactory;
pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory;

View File

@@ -266,6 +266,24 @@ 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,
@@ -721,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,

View File

@@ -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");
});
}
}

View File

@@ -0,0 +1,245 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists";
// 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);
}
}

View File

@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project);
});
}
}

View File

@@ -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]);

View File

@@ -92,6 +92,12 @@ 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";

View File

@@ -27,6 +27,7 @@ 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",
@@ -214,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";

View File

@@ -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>>;

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeAuthsSchema = z.object({
id: z.string().uuid(),
accountId: z.string().uuid(),
status: z.string(),
token: z.string().nullable().optional(),
identifierType: z.string(),
identifierValue: z.string(),
expiresAt: z.date(),
certificateId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
export type TPkiAcmeAuthsInsert = Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>;
export type TPkiAcmeAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>>;

View File

@@ -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>>;

View File

@@ -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>
>;

View File

@@ -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>>;

View File

@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeOrdersSchema = z.object({
id: z.string().uuid(),
accountId: z.string().uuid(),
certificateId: z.string().uuid().nullable().optional(),
notBefore: z.date().nullable().optional(),
notAfter: z.date().nullable().optional(),
expiresAt: z.date(),
csr: z.string().nullable().optional(),
error: z.string().nullable().optional(),
status: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeOrders = z.infer<typeof PkiAcmeOrdersSchema>;
export type TPkiAcmeOrdersInsert = Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>;
export type TPkiAcmeOrdersUpdate = Partial<Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>>;

View File

@@ -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>;

View File

@@ -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>;

View File

@@ -1,17 +1,16 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateChefConnectionSchema,
SanitizedChefConnectionSchema,
UpdateChefConnectionSchema
} from "@app/services/app-connection/chef";
} from "@app/ee/services/app-connections/chef";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { registerAppConnectionEndpoints } from "@app/server/routes/v1/app-connection-routers/app-connection-endpoints";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerChefConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Chef,

View File

@@ -1,5 +1,6 @@
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";
import { registerAssumePrivilegeRouter } from "./assume-privilege-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" }
);

View File

@@ -92,7 +92,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(),
relayHost: z.string()
relayHost: z.string(),
metadata: z.record(z.string(), z.string()).optional()
})
}
},

View File

@@ -468,7 +468,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
.optional(),
secretComment: z.string().trim().optional().default(""),
skipMultilineEncoding: z.boolean().optional(),
skipMultilineEncoding: z
.boolean()
.nullish()
.transform((val) => (val === null ? false : val)),
metadata: z.record(z.string()).optional(),
secretMetadata: ResourceMetadataSchema.optional(),
tagIds: z.string().array().optional()

View File

@@ -0,0 +1,461 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types";
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 { 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
})
);
}
});
};

View File

@@ -1,8 +1,7 @@
import { ChefSyncSchema, CreateChefSyncSchema, UpdateChefSyncSchema } from "@app/services/secret-sync/chef";
import { ChefSyncSchema, CreateChefSyncSchema, UpdateChefSyncSchema } from "@app/ee/services/secret-sync/chef";
import { registerSyncSecretsEndpoints } from "@app/server/routes/v1/secret-sync-routers/secret-sync-endpoints";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerChefSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Chef,

View File

@@ -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

View File

@@ -5,10 +5,10 @@ import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TChefDataBagItemContent } from "../../secret-sync/chef/chef-sync-types";
import { AppConnection } from "../app-connection-enums";
import { ChefConnectionMethod } from "./chef-connection-enums";
import {
TChefConnection,

View File

@@ -1,7 +1,8 @@
import { ForbiddenRequestError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { AppConnection } from "../../../../services/app-connection/app-connection-enums";
import { TLicenseServiceFactory } from "../../license/license-service";
import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns";
import { TChefConnection } from "./chef-connection-types";
@@ -11,8 +12,23 @@ type TGetAppConnectionFunc = (
actor: OrgServiceActor
) => Promise<TChefConnection>;
export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
// Enterprise check
export const checkPlan = async (licenseService: Pick<TLicenseServiceFactory, "getPlan">, orgId: string) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.enterpriseAppConnections)
throw new BadRequestError({
message:
"Failed to use app connection due to plan restriction. Upgrade plan to access enterprise app connections."
});
};
export const chefConnectionService = (
getAppConnection: TGetAppConnectionFunc,
licenseService: Pick<TLicenseServiceFactory, "getPlan">
) => {
const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => {
await checkPlan(licenseService, actor.orgId);
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {
@@ -23,6 +39,8 @@ export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) =
};
const listDataBagItems = async (appConnectionId: string, dataBagName: string, actor: OrgServiceActor) => {
await checkPlan(licenseService, actor.orgId);
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {

View File

@@ -1,9 +1,9 @@
import z from "zod";
import { TChefDataBagItemContent } from "@app/ee/services/secret-sync/chef";
import { DiscriminativePick } from "@app/lib/types";
import { TChefDataBagItemContent } from "@app/services/secret-sync/chef";
import { AppConnection } from "../app-connection-enums";
import { AppConnection } from "../../../../services/app-connection/app-connection-enums";
import {
ChefConnectionSchema,
CreateChefConnectionSchema,

View File

@@ -3,7 +3,7 @@ import net from "node:net";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas";
import { OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { DatabaseErrorCode } from "@app/lib/error-codes";
@@ -909,7 +909,9 @@ export const gatewayV2ServiceFactory = ({
for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) {
try {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin);
const admins = (await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin)).filter(
(admin) => admin.status !== OrgMembershipStatus.Invited
);
if (admins.length === 0) {
logger.warn({ orgId }, "Organization has no admins to notify about unhealthy gateway.");
// eslint-disable-next-line no-continue

View File

@@ -480,6 +480,36 @@ export const pamAccountServiceFactory = ({
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
}
let metadata;
switch (resourceType) {
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId: account.projectId
});
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
});
metadata = {
username: credentials.username,
database: connectionCredentials.database,
accountName: account.name,
accountPath
};
}
break;
default:
break;
}
return {
sessionId: session.id,
resourceType,
@@ -491,7 +521,8 @@ export const pamAccountServiceFactory = ({
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId,
account
account,
metadata
};
};

View File

@@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionCertificateProfileActions.IssueCert
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret
],
ProjectPermissionSub.CertificateProfiles
);

View File

@@ -201,11 +201,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
if (actorType === ActorType.USER) {
void queryBuilder
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`)
.on(`${TableName.IdentityMetadata}.userId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
} else if (actorType === ActorType.IDENTITY) {
void queryBuilder
.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`)
.on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
}
})
@@ -488,7 +488,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
})
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`)
.on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`)
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
})
.where(`${TableName.Membership}.scopeOrgId`, orgId)

View File

@@ -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 {

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -0,0 +1,175 @@
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 { Knex } from "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
};
};

View File

@@ -0,0 +1,133 @@
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 };
};

View File

@@ -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";
}
}

View File

@@ -0,0 +1,17 @@
import { getConfig } from "@app/lib/config/env";
import { z } from "zod";
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));
};

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -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()
});

View File

@@ -0,0 +1,840 @@
import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import * as x509 from "@peculiar/x509";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { ActorType } from "@app/services/auth/auth-type";
import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
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 {
calculateJwkThumbprint,
errors,
flattenedVerify,
FlattenedVerifyResult,
importJWK,
JWSHeaderParameters
} from "jose";
import { z, ZodError } from "zod";
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
};
};

View File

@@ -0,0 +1,180 @@
import { z } from "zod";
import { JWSHeaderParameters } from "jose";
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>;
};

View File

@@ -3,7 +3,7 @@ import { isIP } from "node:net";
import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas";
import { OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
@@ -996,7 +996,9 @@ export const relayServiceFactory = ({
);
if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) {
return relayDAL.updateById(existingRelay.id, { host, name }, tx);
throw new BadRequestError({
message: `Machine identity already has an existing relay with the name "${existingRelay.name}" and host "${existingRelay.host}". Delete the existing relay or use a different machine identity.`
});
}
if (!existingRelay) {
@@ -1248,7 +1250,9 @@ export const relayServiceFactory = ({
});
}
} else {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin);
const admins = (await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin)).filter(
(admin) => admin.status !== OrgMembershipStatus.Invited
);
if (admins.length === 0) {
// eslint-disable-next-line no-continue
continue;

View File

@@ -670,6 +670,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.select(
db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.ref("name").withSchema(TableName.Environment).as("environmentName"),
db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerId"),
db.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
@@ -699,30 +700,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
)
.as("inner");
const countQuery = (await (tx || db)
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{
total_count: number;
}>;
const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery;
if (search) {
void query.where((qb) => {
void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"),
db.ref("lastName").withSchema("committerUser"),
db.ref("committerUserFirstName"),
db.ref("committerUserLastName"),
`%${search}%`
])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`);
.orWhereRaw(`?? ilike ?`, [db.ref("committerUserUsername"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("committerUserEmail"), `%${search}%`])
.orWhereILike(`environmentName`, `%${search}%`)
.orWhereILike(`environment`, `%${search}%`)
.orWhereILike(`policySecretPath`, `%${search}%`);
});
}
const countQuery = (await (tx || db)
.select(db.raw("count(*) OVER() as total_count"))
.from(query.clone().as("outer"))) as Array<{
total_count: number;
}>;
const rankOffset = offset + 1;
const docs = await (tx || db)
.with("w", query)

View File

@@ -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
});

View File

@@ -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
}
}
},

View File

@@ -6,5 +6,6 @@ export const CHEF_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Chef",
destination: SecretSync.Chef,
connection: AppConnection.Chef,
canImportSecrets: true
canImportSecrets: true,
enterprise: true
};

View File

@@ -1,4 +1,4 @@
import { getChefDataBagItem, updateChefDataBagItem } from "@app/services/app-connection/chef";
import { getChefDataBagItem, updateChefDataBagItem } from "@app/ee/services/app-connections/chef";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";

View File

@@ -42,5 +42,6 @@ export const ChefSyncListItemSchema = z.object({
name: z.literal("Chef"),
connection: z.literal(AppConnection.Chef),
destination: z.literal(SecretSync.Chef),
canImportSecrets: z.literal(true)
canImportSecrets: z.literal(true),
enterprise: z.boolean()
});

View File

@@ -1,6 +1,6 @@
import z from "zod";
import { TChefConnection } from "@app/services/app-connection/chef";
import { TChefConnection } from "@app/ee/services/app-connections/chef";
import { ChefSyncListItemSchema, ChefSyncSchema, CreateChefSyncSchema } from "./chef-sync-schemas";

View File

@@ -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 = {

View File

@@ -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",

View File

@@ -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()

View File

@@ -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}`);
}
};

View File

@@ -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}`);
}
};

View File

@@ -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");
}

View File

@@ -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;
};
};

View File

@@ -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

View File

@@ -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,

View File

@@ -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);

View File

@@ -17,30 +17,30 @@ import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approva
import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal";
import { accessApprovalRequestServiceFactory } from "@app/ee/services/access-approval-request/access-approval-request-service";
import { assumePrivilegeServiceFactory } from "@app/ee/services/assume-privilege/assume-privilege-service";
import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal";
import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
import { auditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal";
import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-log-queue";
import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service";
import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal";
import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service";
import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service";
import { certificateEstServiceFactory } from "@app/ee/services/certificate-est/certificate-est-service";
import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal";
import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers";
import { dynamicSecretLeaseDALFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-dal";
import { dynamicSecretLeaseQueueServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue";
import { dynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service";
import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal";
import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service";
import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers";
import { eventBusFactory } from "@app/ee/services/event/event-bus-service";
import { sseServiceFactory } from "@app/ee/services/event/event-sse-service";
import { externalKmsDALFactory } from "@app/ee/services/external-kms/external-kms-dal";
import { externalKmsServiceFactory } from "@app/ee/services/external-kms/external-kms-service";
import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal";
import { gatewayV2DalFactory } from "@app/ee/services/gateway-v2/gateway-v2-dal";
import { gatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { orgGatewayConfigV2DalFactory } from "@app/ee/services/gateway-v2/org-gateway-config-v2-dal";
import { gatewayDALFactory } from "@app/ee/services/gateway/gateway-dal";
import { gatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { orgGatewayConfigDALFactory } from "@app/ee/services/gateway/org-gateway-config-dal";
import { githubOrgSyncDALFactory } from "@app/ee/services/github-org-sync/github-org-sync-dal";
import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/github-org-sync-service";
import { groupDALFactory } from "@app/ee/services/group/group-dal";
@@ -74,6 +74,10 @@ 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 { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-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 { 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";
@@ -98,39 +102,39 @@ import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret
import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal";
import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service";
import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service";
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal";
import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue";
import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service";
import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal";
import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue";
import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service";
import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal";
import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue";
import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service";
import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal";
import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal";
import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal";
import { secretScanningQueueFactory } from "@app/ee/services/secret-scanning/secret-scanning-queue";
import { secretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service";
import { secretScanningV2DALFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-dal";
import { secretScanningV2QueueServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-queue";
import { secretScanningV2ServiceFactory } from "@app/ee/services/secret-scanning-v2/secret-scanning-v2-service";
import { secretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service";
import { snapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal";
import { snapshotFolderDALFactory } from "@app/ee/services/secret-snapshot/snapshot-folder-dal";
import { snapshotSecretDALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-dal";
import { snapshotSecretV2DALFactory } from "@app/ee/services/secret-snapshot/snapshot-secret-v2-dal";
import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal";
import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
import { sshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal";
import { sshCertificateTemplateServiceFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-service";
import { sshCertificateBodyDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-body-dal";
import { sshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal";
import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal";
import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service";
import { sshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal";
import { sshHostLoginUserMappingDALFactory } from "@app/ee/services/ssh-host/ssh-host-login-user-mapping-dal";
import { sshHostServiceFactory } from "@app/ee/services/ssh-host/ssh-host-service";
import { sshHostLoginUserDALFactory } from "@app/ee/services/ssh-host/ssh-login-user-dal";
import { sshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal";
import { sshHostGroupMembershipDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-membership-dal";
import { sshHostGroupServiceFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-service";
import { sshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal";
import { sshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal";
import { sshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service";
import { subOrgServiceFactory } from "@app/ee/services/sub-org/sub-org-service";
import { trustedIpDALFactory } from "@app/ee/services/trusted-ip/trusted-ip-dal";
import { trustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service";
@@ -150,16 +154,12 @@ import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service";
import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal";
import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { authDALFactory } from "@app/services/auth/auth-dal";
import { authLoginServiceFactory } from "@app/services/auth/auth-login-service";
import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service";
import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service";
import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal";
import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service";
import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { certificateDALFactory } from "@app/services/certificate/certificate-dal";
import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { certificateServiceFactory } from "@app/services/certificate/certificate-service";
import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue";
@@ -172,16 +172,21 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif
import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/certificate-v3-queue";
import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal";
import { certificateDALFactory } from "@app/services/certificate/certificate-dal";
import { certificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { certificateServiceFactory } from "@app/services/certificate/certificate-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";
@@ -189,21 +194,17 @@ import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/externa
import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue";
import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service";
import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal";
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal";
import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal";
import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal";
import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
import { folderCommitQueueServiceFactory } from "@app/services/folder-commit/folder-commit-queue";
import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal";
import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal";
import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal";
import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal";
import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal";
import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service";
import { healthAlertServiceFactory } from "@app/services/health-alert/health-alert-queue";
import { identityDALFactory } from "@app/services/identity/identity-dal";
import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { identityServiceFactory } from "@app/services/identity/identity-service";
import { identityAccessTokenDALFactory } from "@app/services/identity-access-token/identity-access-token-dal";
import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service";
import { identityAliCloudAuthDALFactory } from "@app/services/identity-alicloud-auth/identity-alicloud-auth-dal";
@@ -233,23 +234,27 @@ import { identityTokenAuthServiceFactory } from "@app/services/identity-token-au
import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal";
import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal";
import { identityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { integrationDALFactory } from "@app/services/integration/integration-dal";
import { integrationServiceFactory } from "@app/services/integration/integration-service";
import { identityDALFactory } from "@app/services/identity/identity-dal";
import { identityMetadataDALFactory } from "@app/services/identity/identity-metadata-dal";
import { identityOrgDALFactory } from "@app/services/identity/identity-org-dal";
import { identityServiceFactory } from "@app/services/identity/identity-service";
import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal";
import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { integrationDALFactory } from "@app/services/integration/integration-dal";
import { integrationServiceFactory } from "@app/services/integration/integration-service";
import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal";
import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal";
import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal";
import { kmsServiceFactory } from "@app/services/kms/kms-service";
import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types";
import { membershipDALFactory } from "@app/services/membership/membership-dal";
import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal";
import { membershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal";
import { membershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service";
import { membershipIdentityDALFactory } from "@app/services/membership-identity/membership-identity-dal";
import { membershipIdentityServiceFactory } from "@app/services/membership-identity/membership-identity-service";
import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
import { membershipUserServiceFactory } from "@app/services/membership-user/membership-user-service";
import { membershipDALFactory } from "@app/services/membership/membership-dal";
import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal";
import { microsoftTeamsIntegrationDALFactory } from "@app/services/microsoft-teams/microsoft-teams-integration-dal";
import { microsoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import { projectMicrosoftTeamsConfigDALFactory } from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
@@ -258,11 +263,11 @@ import { notificationServiceFactory } from "@app/services/notification/notificat
import { userNotificationDALFactory } from "@app/services/notification/user-notification-dal";
import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal";
import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service";
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal";
import { orgDALFactory } from "@app/services/org/org-dal";
import { orgServiceFactory } from "@app/services/org/org-service";
import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue";
import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
@@ -284,10 +289,6 @@ import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal";
import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
import { projectDALFactory } from "@app/services/project/project-dal";
import { projectQueueFactory } from "@app/services/project/project-queue";
import { projectServiceFactory } from "@app/services/project/project-service";
import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal";
import { projectBotDALFactory } from "@app/services/project-bot/project-bot-dal";
import { projectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { projectEnvDALFactory } from "@app/services/project-env/project-env-dal";
@@ -296,19 +297,18 @@ import { projectKeyDALFactory } from "@app/services/project-key/project-key-dal"
import { projectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { projectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { projectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { projectDALFactory } from "@app/services/project/project-dal";
import { projectQueueFactory } from "@app/services/project/project-queue";
import { projectServiceFactory } from "@app/services/project/project-service";
import { projectSshConfigDALFactory } from "@app/services/project/project-ssh-config-dal";
import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal";
import { reminderDALFactory } from "@app/services/reminder/reminder-dal";
import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue";
import { reminderServiceFactory } from "@app/services/reminder/reminder-service";
import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal";
import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue";
import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
import { roleDALFactory } from "@app/services/role/role-dal";
import { roleServiceFactory } from "@app/services/role/role-service";
import { secretDALFactory } from "@app/services/secret/secret-dal";
import { secretQueueFactory } from "@app/services/secret/secret-queue";
import { secretServiceFactory } from "@app/services/secret/secret-service";
import { secretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { secretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { secretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal";
import { secretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
@@ -328,6 +328,11 @@ import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-
import { secretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service";
import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal";
import { secretVersionV2TagBridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal";
import { secretDALFactory } from "@app/services/secret/secret-dal";
import { secretQueueFactory } from "@app/services/secret/secret-queue";
import { secretServiceFactory } from "@app/services/secret/secret-service";
import { secretVersionDALFactory } from "@app/services/secret/secret-version-dal";
import { secretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal";
import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal";
import { serviceTokenServiceFactory } from "@app/services/service-token/service-token-service";
import { projectSlackConfigDALFactory } from "@app/services/slack/project-slack-config-dal";
@@ -343,15 +348,18 @@ import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-servi
import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal";
import { totpServiceFactory } from "@app/services/totp/totp-service";
import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service";
import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service";
import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
import { userEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service";
import { userDALFactory } from "@app/services/user/user-dal";
import { userServiceFactory } from "@app/services/user/user-service";
import { webhookDALFactory } from "@app/services/webhook/webhook-dal";
import { webhookServiceFactory } from "@app/services/webhook/webhook-service";
import { workflowIntegrationDALFactory } from "@app/services/workflow-integration/workflow-integration-dal";
import { workflowIntegrationServiceFactory } from "@app/services/workflow-integration/workflow-integration-service";
import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-dal";
import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal";
import { injectAuditLogInfo } from "../plugins/audit-log";
import { injectAssumePrivilege } from "../plugins/auth/inject-assume-privilege";
import { injectIdentity } from "../plugins/auth/inject-identity";
@@ -1069,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);
@@ -1164,6 +1177,7 @@ export const registerRoutes = async (
certificateTemplateV2DAL,
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
permissionService,
kmsService,
projectDAL
@@ -1259,7 +1273,10 @@ export const registerRoutes = async (
licenseService,
gatewayService,
gatewayV2Service,
notificationService
notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
});
const secretQueueService = secretQueueFactory({
@@ -2175,6 +2192,7 @@ export const registerRoutes = async (
certificateAuthorityDAL,
certificateProfileDAL,
certificateTemplateV2Service,
acmeAccountDAL,
internalCaService: internalCertificateAuthorityService,
permissionService,
certificateSyncDAL,
@@ -2201,6 +2219,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,
@@ -2457,6 +2493,7 @@ export const registerRoutes = async (
certificateProfile: certificateProfileService,
certificateAuthorityCrl: certificateAuthorityCrlService,
certificateEst: certificateEstService,
pkiAcme: pkiAcmeService,
pit: pitService,
pkiAlert: pkiAlertService,
pkiCollection: pkiCollectionService,

View File

@@ -141,7 +141,8 @@ export const secretRawSchema = z.object({
actorId: z.string().nullable().optional(),
actorType: z.string().nullable().optional(),
name: z.string().nullable().optional(),
membershipId: z.string().nullable().optional()
membershipId: z.string().nullable().optional(),
groupId: z.string().nullable().optional()
})
.optional()
.nullable(),

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { ProjectType } from "@app/db/schemas";
import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/ee/services/app-connections/chef";
import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci";
import {
OracleDBConnectionListItemSchema,
@@ -48,7 +49,6 @@ import {
ChecklyConnectionListItemSchema,
SanitizedChecklyConnectionSchema
} from "@app/services/app-connection/checkly";
import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/services/app-connection/chef";
import {
CloudflareConnectionListItemSchema,
SanitizedCloudflareConnectionSchema

View File

@@ -1,3 +1,4 @@
import { registerChefConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/chef-connection-router";
import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router";
import { registerOracleDBConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oracledb-connection-router";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
@@ -13,7 +14,6 @@ import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connect
import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router";
import { registerCamundaConnectionRouter } from "./camunda-connection-router";
import { registerChecklyConnectionRouter } from "./checkly-connection-router";
import { registerChefConnectionRouter } from "./chef-connection-router";
import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router";
import { registerDatabricksConnectionRouter } from "./databricks-connection-router";
import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router";

View File

@@ -7,8 +7,8 @@ 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 { CertStatus } from "@app/services/certificate/certificate-types";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { CertStatus } from "@app/services/certificate/certificate-types";
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
server.route({
@@ -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,36 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
return { certificates };
}
});
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 };
}
});
};

View File

@@ -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),

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import {
AccessScope,
OrgMembershipRole,
ProjectMembershipRole,
ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema,
@@ -266,6 +267,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const usernamesAndEmails = [...req.body.emails, ...req.body.usernames];
await server.services.membershipUser.createMembership({
permission: req.permission,
scopeData: {
scope: AccessScope.Organization,
orgId: req.permission.orgId
},
data: {
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
usernames: usernamesAndEmails
}
});
const { memberships } = await server.services.membershipUser.createMembership({
permission: req.permission,
scopeData: {

View File

@@ -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),

View File

@@ -1,3 +1,4 @@
import { registerChefSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/chef-sync-router";
import { registerOCIVaultSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/oci-vault-sync-router";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
@@ -10,7 +11,6 @@ import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router";
import { registerBitbucketSyncRouter } from "./bitbucket-sync-router";
import { registerCamundaSyncRouter } from "./camunda-sync-router";
import { registerChecklySyncRouter } from "./checkly-sync-router";
import { registerChefSyncRouter } from "./chef-sync-router";
import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router";
import { registerCloudflareWorkersSyncRouter } from "./cloudflare-workers-sync-router";
import { registerDatabricksSyncRouter } from "./databricks-sync-router";

View File

@@ -1,6 +1,7 @@
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ChefSyncListItemSchema, ChefSyncSchema } from "@app/ee/services/secret-sync/chef";
import { OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "@app/ee/services/secret-sync/oci-vault";
import { ApiDocsTags, SecretSyncs } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
@@ -24,7 +25,6 @@ import { AzureKeyVaultSyncListItemSchema, AzureKeyVaultSyncSchema } from "@app/s
import { BitbucketSyncListItemSchema, BitbucketSyncSchema } from "@app/services/secret-sync/bitbucket";
import { CamundaSyncListItemSchema, CamundaSyncSchema } from "@app/services/secret-sync/camunda";
import { ChecklySyncListItemSchema, ChecklySyncSchema } from "@app/services/secret-sync/checkly/checkly-sync-schemas";
import { ChefSyncListItemSchema, ChefSyncSchema } from "@app/services/secret-sync/chef";
import {
CloudflarePagesSyncListItemSchema,
CloudflarePagesSyncSchema

View File

@@ -550,12 +550,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => {
providerAuthToken: req.body.providerAuthToken
});
void res.setCookie("jid", data.token.refresh, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
if ([AuthMethod.GOOGLE, AuthMethod.GITHUB, AuthMethod.GITLAB].includes(data.decodedProviderToken.authMethod)) {
void res.setCookie("jid", data.token.refresh, {
httpOnly: true,
path: "/",
sameSite: "strict",
secure: appCfg.HTTPS_ENABLED
});
}
addAuthOriginDomainCookie(res);

View File

@@ -6,12 +6,6 @@ import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import {
ACMESANType,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
import {
CertExtendedKeyUsageType,
@@ -20,7 +14,14 @@ 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";
import {
ACMESANType,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
} from "@app/services/certificate/certificate-types";
interface CertificateRequestForService {
commonName?: string;
@@ -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({

View File

@@ -1,5 +1,10 @@
import { ProjectType } from "@app/db/schemas";
import { TAppConnections } from "@app/db/schemas/app-connections";
import {
ChefConnectionMethod,
getChefConnectionListItem,
validateChefConnectionCredentials
} from "@app/ee/services/app-connections/chef";
import {
getOCIConnectionListItem,
OCIConnectionMethod,
@@ -68,7 +73,6 @@ import {
} from "./bitbucket";
import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda";
import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly";
import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef";
import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum";
import {
getCloudflareConnectionListItem,

View File

@@ -86,6 +86,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Netlify]: AppConnectionPlanType.Regular,
[AppConnection.Okta]: AppConnectionPlanType.Regular,
[AppConnection.Redis]: AppConnectionPlanType.Regular,
[AppConnection.Chef]: AppConnectionPlanType.Regular,
[AppConnection.Chef]: AppConnectionPlanType.Enterprise,
[AppConnection.Northflank]: AppConnectionPlanType.Regular
};

View File

@@ -1,6 +1,8 @@
import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, OrganizationActionScope, TAppConnections } from "@app/db/schemas";
import { ValidateChefConnectionCredentialsSchema } from "@app/ee/services/app-connections/chef";
import { chefConnectionService } from "@app/ee/services/app-connections/chef/chef-connection-service";
import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci";
import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service";
import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb";
@@ -67,8 +69,6 @@ import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
import { camundaConnectionService } from "./camunda/camunda-connection-service";
import { ValidateChecklyConnectionCredentialsSchema } from "./checkly";
import { checklyConnectionService } from "./checkly/checkly-connection-service";
import { ValidateChefConnectionCredentialsSchema } from "./chef";
import { chefConnectionService } from "./chef/chef-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
@@ -885,6 +885,6 @@ export const appConnectionServiceFactory = ({
northflank: northflankConnectionService(connectAppConnectionById),
okta: oktaConnectionService(connectAppConnectionById),
laravelForge: laravelForgeConnectionService(connectAppConnectionById),
chef: chefConnectionService(connectAppConnectionById)
chef: chefConnectionService(connectAppConnectionById, licenseService)
};
};

View File

@@ -1,3 +1,9 @@
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "@app/ee/services/app-connections/chef";
import {
TOCIConnection,
TOCIConnectionConfig,
@@ -82,12 +88,6 @@ import {
TChecklyConnectionInput,
TValidateChecklyConnectionCredentialsSchema
} from "./checkly";
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "./chef";
import {
TCloudflareConnection,
TCloudflareConnectionConfig,

View File

@@ -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"
}

View File

@@ -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;
@@ -432,6 +478,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
updateById,
deleteById,
findById,
findByIdWithOwnerOrgId,
findByIdWithConfigs,
findBySlugAndProjectId,
findByProjectId,

View File

@@ -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;
},

View File

@@ -10,6 +10,7 @@ import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorType, AuthMethod } from "../auth/auth-type";
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 +143,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: {
@@ -182,6 +194,7 @@ describe("CertificateProfileService", () => {
certificateTemplateV2DAL: mockCertificateTemplateV2DAL,
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
permissionService: mockPermissionService,
kmsService: mockKmsService,
projectDAL: mockProjectDAL
@@ -234,6 +247,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfigId: "api-config-123",
estConfigId: null,
acmeConfigId: null,
projectId: "project-123"
},
undefined

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