Merge branch 'main' into PAM-10

This commit is contained in:
x032205
2025-11-14 12:35:59 -05:00
533 changed files with 26804 additions and 4769 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Keys
# Required key for platform encryption/decryption ops
# THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION
ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218
ENCRYPTION_KEY=VVHnGZ0w98WLgISK4XSJcagezuG6EWRFTk48KE4Y5Mw=
# JWT
# Required secrets to sign JWT tokens
+1 -1
View File
@@ -1,2 +1,2 @@
DB_CONNECTION_URI=
DB_CONNECTION_URI=postgres://infisical:infisical@localhost:5432/infisical
AUDIT_LOGS_DB_CONNECTION_URI=
-2
View File
@@ -21,5 +21,3 @@
---
- [ ] I have read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview), agreed and acknowledged the [code of conduct](https://infisical.com/docs/contributing/getting-started/code-of-conduct). 📝
<!-- If you have any questions regarding contribution, here's the FAQ : https://infisical.com/docs/contributing/getting-started/faq -->
+109
View File
@@ -0,0 +1,109 @@
name: "Run backend BDD tests"
on:
pull_request:
types: [opened, synchronize]
paths:
- "backend/**"
- "!backend/README.md"
- "!backend/.*"
- "backend/.eslintrc.js"
workflow_call:
jobs:
run-backend-bdd-tests:
name: Run BDD tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
docker system prune -af
- name: ☁️ Checkout source
uses: actions/checkout@v3
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Install Python
run: uv python install
- uses: KengoTODA/actions-setup-docker-compose@v1
if: ${{ env.ACT }}
name: Install `docker compose` for local simulations
with:
version: "2.14.2"
- name: 🔧 Setup Node 20
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
cache-dependency-path: backend/package-lock.json
- name: Install dependencies
run: npm install
working-directory: backend
- name: Output .env file and enable feature flags for BDD tests
run: |
cp .env.example .env
echo "ACME_DEVELOPMENT_MODE=true" >> .env
echo "ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES={\"localhost\": \"host.docker.internal:8087\", \"infisical.com\": \"host.docker.internal:8087\", \"example.com\": \"host.docker.internal:8087\"}" >> .env
echo "BDD_NOCK_API_ENABLED=true" >> .env
# Skip upstream validation, otherwise the ACME client for the upstream will try to
# validate the DNS records, which will fail because the DNS records are not actually created.
echo "ACME_SKIP_UPSTREAM_VALIDATION=true" >> .env
# We are not using FIPS mode, need a different encryption key for BDD tests
NEW_ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218
sed -i "s#ENCRYPTION_KEY=.*#ENCRYPTION_KEY=$NEW_ENCRYPTION_KEY#" .env
# Enable ACME feature in license for BDD tests
sed -i 's/pkiAcme: .*/pkiAcme: true,/g' backend/src/ee/services/license/license-fns.ts
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: |
image=moby/buildkit:latest
- name: Build Infisical backend Docker image with caching
uses: docker/bake-action@v5
timeout-minutes: 30
with:
files: docker-compose.bdd.yml
targets: backend
load: true
# Uncomment this to force a rebuild of the image
# no-cache: true
set: |
*.cache-from=type=gha,scope=infisical-backend-bdd-tests
*.cache-to=type=gha,mode=max,scope=infisical-backend-bdd-tests
- name: Start Infisical
run: docker compose -f docker-compose.bdd.yml up -d
- name: Wait for API to be ready
uses: nick-fields/retry@v3
with:
timeout_seconds: 60
max_attempts: 30
command: |
curl -f -X GET http://localhost:8080/api/v1/admin/config
- name: Run bdd tests
run: npm run test:bdd
working-directory: backend
env:
INFISICAL_API_URL: http://localhost:8080
BOOTSTRAP_INFISICAL: "1"
- name: cleanup
run: |
docker compose -f "docker-compose.bdd.yml" down
- name: Dump backend logs
if: always() # Ensures this runs even if previous steps fail
run: |
mkdir -p logs
docker compose -f docker-compose.bdd.yml logs backend > logs/backend.log 2>&1 || true
- name: Upload backend logs as artifact
if: always() # Always upload, even on failure/cancellation
uses: actions/upload-artifact@v4
with:
name: backend-logs-${{ github.run_id }}
path: logs/backend.log
retention-days: 7
if-no-files-found: warn
+1
View File
@@ -71,5 +71,6 @@ frontend-build
cli/infisical-merge
cli/test/infisical-merge
/backend/binary
backend/bdd/.bdd-infisical-bootstrap-result.json
/npm/bin
+3
View File
@@ -52,3 +52,6 @@ docs/integrations/app-connections/railway.mdx:generic-api-key:156
.github/workflows/validate-db-schemas.yml:generic-api-key:21
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
docs/integrations/app-connections/redis.mdx:generic-api-key:80
backend/src/ee/services/app-connections/chef/chef-connection-fns.ts:private-key:42
docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
+9 -7
View File
@@ -48,7 +48,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus
### Secrets Management:
- **[Dashboard](https://infisical.com/docs/documentation/platform/project)**: Manage secrets across projects and environments (e.g. development, production, etc.) through a user-friendly interface.
- **[Native Integrations](https://infisical.com/docs/integrations/overview)**: Sync secrets to platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and use tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more.
- **[Secret Syncs](https://infisical.com/docs/integrations/secret-syncs/overview)**: Sync secrets to platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and use tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more.
- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)**: Keep track of every secret and project state; roll back when needed.
- **[Secret Rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview)**: Rotate secrets at regular intervals for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/secret-rotation/postgres-credentials), [MySQL](https://infisical.com/docs/documentation/platform/secret-rotation/mysql), [AWS IAM](https://infisical.com/docs/documentation/platform/secret-rotation/aws-iam), and more.
- **[Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview)**: Generate ephemeral secrets on-demand for services like [PostgreSQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/postgresql), [MySQL](https://infisical.com/docs/documentation/platform/dynamic-secrets/mysql), [RabbitMQ](https://infisical.com/docs/documentation/platform/dynamic-secrets/rabbit-mq), and more.
@@ -56,13 +56,15 @@ We're on a mission to make security tooling more accessible to everyone, not jus
- **[Infisical Kubernetes Operator](https://infisical.com/docs/documentation/getting-started/kubernetes)**: Deliver secrets to your Kubernetes workloads and automatically reload deployments.
- **[Infisical Agent](https://infisical.com/docs/infisical-agent/overview)**: Inject secrets into applications without modifying any code logic.
### Infisical (Internal) PKI:
### Certificate Management
- **[Private Certificate Authority](https://infisical.com/docs/documentation/platform/pki/private-ca)**: Create CA hierarchies, configure [certificate templates](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-issuing-certificates) for policy enforcement, and start issuing X.509 certificates.
- **[Certificate Management](https://infisical.com/docs/documentation/platform/pki/certificates)**: Manage the certificate lifecycle from [issuance](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-issuing-certificates) to [revocation](https://infisical.com/docs/documentation/platform/pki/certificates#guide-to-revoking-certificates) with support for CRL.
- **[Internal CA](https://infisical.com/docs/documentation/platform/pki/private-ca)**: Create and manage a private
CA hierarchy directly within Infisical.
- **[External CA](https://infisical.com/docs/documentation/platform/pki/ca/external-ca)**: Integrate with third-party certificate authorities such as Let’s Encrypt, DigiCert, Microsoft AD CS, and more to leverage existing PKI infrastructure
or issue publicly trusted certificates.
- **[Certificate Lifecycle Management](https://infisical.com/docs/documentation/platform/pki/certificates/overview)**: Create certificate [profiles](https://infisical.com/docs/documentation/platform/pki/certificates/profiles) and [templates](https://infisical.com/docs/documentation/platform/pki/certificates/templates) to control how certificates are issued, including [enrollment methods](https://infisical.com/docs/documentation/platform/pki/enrollment-methods/overview) such as API, ACME, or EST. Manage the full lifecycle from issuance to renewal and [revocation](https://infisical.com/docs/documentation/platform/pki/certificates/certificates#guide-to-revoking-certificates) with CRL and inventory tracking.
- **[Certificate Syncs](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/overview)**: Sync certificates to external platforms like [AWS Certificate Manager](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager) and [Azure Key Vault](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/azure-key-vault).
- **[Alerting](https://infisical.com/docs/documentation/platform/pki/alerting)**: Configure alerting for expiring CA and end-entity certificates.
- **[Infisical PKI Issuer for Kubernetes](https://infisical.com/docs/documentation/platform/pki/pki-issuer)**: Deliver TLS certificates to your Kubernetes workloads with automatic renewal.
- **[Enrollment over Secure Transport](https://infisical.com/docs/documentation/platform/pki/est)**: Enroll and manage certificates via EST protocol.
### Infisical Key Management System (KMS):
@@ -85,7 +87,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus
## Getting started
Check out the [Quickstart Guides](https://infisical.com/docs/getting-started/introduction)
Check out the [Quickstart Guides](https://infisical.com/docs/documentation/getting-started/overview)
| Use Infisical Cloud | Deploy Infisical on premise |
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
+14
View File
@@ -0,0 +1,14 @@
# API URL to the Infisical server
INFISICAL_API_URL="http://localhost:8080"
# JWT token with admin permission for the cert projects
INFISICAL_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoTWV0aG9kIjoiZW1haWwiLCJhdXRoVG9rZW5UeXBlIjoiYWNjZXNzVG9rZW4iLCJ1c2VySWQiOiJkOWZlMzMwZi00OTQwLTQ3ZmYtYmE4Yy0zZGUxYTVlYjYzNGEiLCJ0b2tlblZlcnNpb25JZCI6ImM4MWZhODY1LTFjYTAtNGNmZS1iNjM5LThlMDI3M2E2N2JjYyIsImFjY2Vzc1ZlcnNpb24iOjEsIm9yZ2FuaXphdGlvbklkIjoiM2I5OTRkNTktMjE5Ny00MjcwLWE3MGMtOTczMzdmZjlmYTRkIiwiaWF0IjoxNzYyMjAzMjQwLCJleHAiOjE3NjMwNjcyNDB9.KFYeMYAv3Ceis0hp-pTa8fsLLWbT-JcqhuWyIY0DWU0"
# PKI project id
PROJECT_ID="c051e74c-48a7-4724-832c-d5b496698546"
# Certificate CA id
CERT_CA_ID="2f0d9820-e5a8-48bb-aac8-deed9d868a1e"
# Certificate template id
CERT_TEMPLATE_ID="4dbf6bb0-6e86-4ee6-8550-9171428c8e82"
# ACME profile ID
PROFILE_ID="108c6303-ab8c-4986-ab88-eefe11bb5553"
# ACME profile EAB secret
EAB_SECRET="JHYxJDEwJFJldE9tb3dkUU9XVnJLZWFia3IxVC94L1pIbHRoQnJsNVRKZWFoV1hpNTczVHpwMFNGZzU4OGtuU3NVK1crVGM"
+1
View File
@@ -0,0 +1 @@
3.12
View File
+214
View File
@@ -0,0 +1,214 @@
import json
import os
import pathlib
import typing
import httpx
from behave.runner import Context
from dotenv import load_dotenv
from faker import Faker
import logging
from features.steps.utils import clean_all_nock, restore_nock
load_dotenv()
logger = logging.getLogger(__name__)
BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080")
PEBBLE_URL = os.environ.get("PEBBLE_URL", "https://pebble:14000/dir")
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")
BOOTSTRAP_INFISICAL = int(os.environ.get("BOOTSTRAP_INFISICAL", 0))
# Called mostly from a CI to setup the new Infisical instance to get it ready for BDD tests
def bootstrap_infisical(context: Context):
bootstrap_result_file = pathlib.Path.cwd() / ".bdd-infisical-bootstrap-result.json"
if bootstrap_result_file.exists():
logger.info(
"Bootstrap result file exists at %s, loading it now", bootstrap_result_file
)
return json.loads(bootstrap_result_file.read_text())
faker = Faker()
with httpx.Client(base_url=BASE_URL) as client:
resp = client.post(
"/api/v1/admin/signup",
json={
"email": f"{faker.user_name()}@infisical.com",
"password": faker.password(),
"firstName": faker.first_name(),
"lastName": faker.last_name(),
},
)
resp.raise_for_status()
body = resp.json()
org = body["organization"]
user = body["user"]
temp_token = body["token"]
resp = client.post(
"/api/v3/auth/select-organization",
headers={"Authorization": f"Bearer {temp_token}"},
json={"organizationId": org["id"]},
)
resp.raise_for_status()
body = resp.json()
temp_token = body["token"]
resp = client.post(
"/api/v1/auth/token",
headers={"Authorization": f"Bearer {temp_token}"},
json={},
)
resp.raise_for_status()
body = resp.json()
auth_token = body["token"]
headers = dict(authorization=f"Bearer {auth_token}")
project_slug = faker.slug()
resp = client.post(
"/api/v1/projects",
headers=headers,
json={
"projectName": project_slug,
"projectDescription": faker.paragraph(),
"template": "default",
"type": "cert-manager",
},
)
resp.raise_for_status()
body = resp.json()
project = body["project"]
ca_slug = faker.slug()
resp = client.post(
"/api/v1/pki/ca/internal",
headers=headers,
json={
"projectId": project["id"],
"name": ca_slug,
"type": "internal",
"status": "active",
"enableDirectIssuance": True,
"configuration": {
"type": "root",
"organization": "Infisican Inc",
"ou": "",
"country": "",
"province": "",
"locality": "",
"commonName": "",
"notAfter": "2035-11-07",
"maxPathLength": -1,
"keyAlgorithm": "RSA_2048",
},
},
)
resp.raise_for_status()
body = resp.json()
ca = body
cert_template_slug = faker.slug()
resp = client.post(
"/api/v2/certificate-templates",
headers=headers,
json={
"projectId": project["id"],
"name": cert_template_slug,
"description": "",
"subject": [{"type": "common_name", "allowed": ["*"]}],
"sans": [{"type": "dns_name", "allowed": ["*"]}],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"non_repudiation",
"key_encipherment",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only",
],
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping",
],
},
"algorithms": {
"signature": [
"SHA256-RSA",
"SHA512-RSA",
"SHA384-ECDSA",
"SHA384-RSA",
"SHA256-ECDSA",
"SHA512-ECDSA",
],
"keyAlgorithm": [
"RSA-2048",
"RSA-4096",
"ECDSA-P384",
"RSA-3072",
"ECDSA-P256",
"ECDSA-P521",
],
},
"validity": {"max": "365d"},
},
)
resp.raise_for_status()
body = resp.json()
cert_template = body["certificateTemplate"]
bootstrap_result = dict(
org=org,
user=user,
project=project,
ca=ca,
cert_template=cert_template,
auth_token=auth_token,
)
bootstrap_result_file.write_text(json.dumps(bootstrap_result))
return bootstrap_result
def before_all(context: Context):
if BOOTSTRAP_INFISICAL:
details = bootstrap_infisical(context)
context.vars = {
"BASE_URL": BASE_URL,
"PEBBLE_URL": PEBBLE_URL,
"PROJECT_ID": details["project"]["id"],
"CERT_CA_ID": details["ca"]["id"],
"CERT_TEMPLATE_ID": details["cert_template"]["id"],
"AUTH_TOKEN": details["auth_token"],
}
else:
context.vars = {
"BASE_URL": BASE_URL,
"PEBBLE_URL": PEBBLE_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)
def after_scenario(context: Context, scenario: typing.Any):
if hasattr(context, "web_server"):
context.web_server.shutdown_and_server_close()
clean_all_nock(context)
restore_nock(context)
@@ -0,0 +1,273 @@
Feature: Access Control
Scenario Outline: Access resources across different 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_account0
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
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 peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account0.uri}"
},
"payload": {"invalid": "payload"}
}
"""
# With original owner account, the invalid payload is going to trigger other errors instead of 404, this is to make sure
# that our URLs are actually correct
Then the value response.status_code should not be equal to 404
And I put away current ACME client as client0
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 maidu@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account1
Then I peak and memorize the next nonce as nonce
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account1.uri}"
},
"raw_payload": "<payload>"
}
"""
Then the value response.status_code should be equal to 404
Examples: Endpoints
| src_var | jq | dest_var | url | payload |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
| order | . | not_used | {order.uri} | |
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
| order | . | not_used | {order.uri}/certificate | |
| order | .authorizations[0].uri | auth_uri | {auth_uri} | |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} |
Scenario Outline: Access resources across a different profiles
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_account0
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
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 peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account0.uri}"
},
"payload": {"invalid": "payload"}
}
"""
# With original owner account under their profile, the invalid payload is going to trigger other errors instead of
# 404, this is to make sure that our URLs are actually correct
Then the value response.status_code should not be equal to 404
And I put away current ACME client as client0
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 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 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 maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1
Then I peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account1.uri}"
},
"payload": {}
}
"""
Then the value response.status_code should be equal to 404
Examples: Endpoints
| src_var | jq | dest_var | url | payload |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
| order | . | not_used | {order.uri} | |
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
| order | . | not_used | {order.uri}/certificate | |
| order | .authorizations[0].uri | auth_uri | {auth_uri} | |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} |
Scenario Outline: Access resources across a different profile with the same key pair
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_account0
Then I memorize acme_account0.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account0_id
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 peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account0.uri}"
},
"payload": {"invalid": "payload"}
}
"""
# With original owner account under their profile, the invalid payload is going to trigger other errors instead of
# 404, this is to make sure that our URLs are actually correct
Then the value response.status_code should not be equal to 404
And I put away current ACME client as client0
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 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 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" with the key pair from client0
Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1
Then I peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<url>",
"kid": "{acme_account1.uri}"
},
"raw_payload": "<payload>"
}
"""
Then the value response.status_code should be equal to 404
Examples: Endpoints
| src_var | jq | dest_var | url | payload |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | |
| order | . | not_used | {order.uri} | |
| order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} |
| order | . | not_used | {order.uri}/certificate | |
| order | .authorizations[0].uri | auth_uri | {auth_uri} | |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} |
Scenario Outline: URL mismatch
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
Then I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
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 peak and memorize the next nonce as nonce
Then I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<actual_url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "<bad_url>",
"kid": "{acme_account.uri}"
},
"payload": {}
}
"""
Then the value response.status_code should be equal to 400
Then the value response with jq ".status" should be equal to 400
Then the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:malformed"
Then the value response with jq ".detail" should be equal to "<error_detail>"
Examples: Endpoints
| src_var | jq | dest_var | actual_url | bad_url | error_detail |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | URL mismatch in the protected header |
| order | . | not_used | {order.uri} | BAD | Invalid URL in the protected header |
| order | . | not_used | {order.uri} | https://example.com/acmes/orders/FOOBAR | URL mismatch in the protected header |
| order | . | not_used | {order.uri}/finalize | BAD | Invalid URL in the protected header |
| order | . | not_used | {order.uri}/finalize | https://example.com/acmes/orders/FOOBAR/finalize | URL mismatch in the protected header |
| order | . | not_used | {order.uri}/certificate | BAD | Invalid URL in the protected header |
| order | . | not_used | {order.uri}/certificate | https://example.com/acmes/orders/FOOBAR/certificate | URL mismatch in the protected header |
| order | .authorizations[0].uri | auth_uri | {auth_uri} | BAD | Invalid URL in the protected header |
| order | .authorizations[0].uri | auth_uri | {auth_uri} | https://example.com/acmes/auths/FOOBAR | URL mismatch in the protected header |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | BAD | Invalid URL in the protected header |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | https://example.com/acmes/challenges/FOOBAR | URL mismatch in the protected header |
@@ -0,0 +1,54 @@
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
And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/(.+)
Scenario: Find an existing 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
And I memorize acme_account.uri as account_uri
And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
And the value acme_account.uri should be equal to "{account_uri}"
Scenario: Create a new account without EAB
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 without EAB
And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired"
Scenario Outline: Scenario: Create a new account with bad EAB credentials
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 "<eab_kid>" with secret "<eab_secret>" as acme_account
And the value error with jq ".type" should be equal to "<error_type>"
And the value error with jq ".detail" should be equal to "<error_msg>"
Examples: Bad Credentials
| eab_kid | eab_secret | error_type | error_msg |
| bad | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature |
| {acme_profile.eab_kid} | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature |
| {acme_profile.eab_kid} | YmFkLXNjcmV0Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature |
| {acme_profile.eab_kid} | ABC{acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature |
| bad | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch |
| 4bc7959c-fe2d-4447-ae91-0cd893667af6 | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch |
Scenario Outline: Scenario: Create a new account with bad EAB url
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"
And I use a different new-account URL "<url>" for EAB signature
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
And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired"
And the value error with jq ".detail" should be equal to "External account binding URL mismatch"
Examples: Bad URLs
| url |
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad |
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account?foo=bar |
| {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account#foobar |
| {BASE_URL}/acme/new-account |
| https://example.com/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad |
| bad |
@@ -0,0 +1,35 @@
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"
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
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+)
And the value order.authorizations[0].body with jq ".status" should be equal to "pending"
And 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"
}
]
"""
And the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"]
And the value order.authorizations[0].body with jq ".identifier" should be equal to json
"""
{
"type": "dns",
"value": "localhost"
}
"""
@@ -0,0 +1,49 @@
Feature: ACME Cert Profile
Scenario: Create a cert profile
Given I make a random slug as profile_slug
And 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 the value response with jq ".certificateProfile.id" should be present
And the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}"
And the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}"
And the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}"
And 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
And 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
And the value response with jq ".eabKid" should be equal to "{profile_id}"
And the value response with jq ".eabSecret" should be present
And I memorize response with jq ".eabKid" as eab_kid
And I memorize response with jq ".eabSecret" as eab_secret
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account
@@ -0,0 +1,208 @@
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"
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"
}
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I select challenge with type http-01 for domain localhost from order in order as challenge
And I serve challenge response for challenge at localhost
And I tell ACME server that challenge is ready to be verified
And I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"
And I parse the full-chain certificate from order finalized_order as cert
And the value cert with jq ".subject.common_name" should be equal to "localhost"
Scenario: Validate challenges for multiple domains
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
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "localhost"
}
"""
And I add subject alternative name to certificate signing request csr
"""
[
"infisical.com",
"example.com"
]
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I pass all challenges with type http-01 for order in order
And I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"
And I parse the full-chain certificate from order finalized_order as cert
And the value cert with jq ".subject.common_name" should be equal to "localhost"
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
"""
[
"example.com",
"infisical.com"
]
"""
Scenario: Did not finish all challenges
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
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "localhost"
}
"""
And I add subject alternative name to certificate signing request csr
"""
[
"infisical.com"
]
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I select challenge with type http-01 for domain localhost from order in order as challenge
And I serve challenge response for challenge at localhost
And I tell ACME server that challenge is ready to be verified
# the localhost auth should be valid
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "localhost")) | first | .uri" as localhost_auth
And I peak and memorize the next nonce as nonce
When I send a raw ACME request to "{localhost_auth}"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{localhost_auth}",
"kid": "{acme_account.uri}"
}
}
"""
Then the value response.status_code should be equal to 200
And the value response with jq ".status" should be equal to "valid"
# the infisical.com auth should still be pending
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "infisical.com")) | first | .uri" as infisical_auth
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
When I send a raw ACME request to "{infisical_auth}"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{infisical_auth}",
"kid": "{acme_account.uri}"
}
}
"""
Then the value response.status_code should be equal to 200
And the value response with jq ".status" should be equal to "pending"
# the order should be pending as well
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
When I send a raw ACME request to "{order.uri}"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{order.uri}",
"kid": "{acme_account.uri}"
}
}
"""
Then the value response.status_code should be equal to 200
And the value response with jq ".status" should be equal to "pending"
# finalize should not be allowed when all auths are not valid yet
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
When I send a raw ACME request to "{order.body.finalize}"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{order.body.finalize}",
"kid": "{acme_account.uri}"
},
"payload": {
"csr": "{csr_pem}"
}
}
"""
Then the value response.status_code should be equal to 400
Then the value response with jq ".status" should be equal to 400
Then the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:orderNotReady"
Then the value response with jq ".detail" should be equal to "ACME order is not ready"
Scenario: CSR names mismatch with order identifier
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
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "example.com"
}
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
Then I peak and memorize the next nonce as nonce
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
"kid": "{acme_account.uri}"
},
"payload": {
"identifiers": [
{ "type": "dns", "value": "localhost" },
{ "type": "dns", "value": "infisical.com" }
]
}
}
"""
Then the value response.status_code should be equal to 201
And I memorize response with jq ".finalize" as finalize_url
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
And I memorize response as order
And I pass all challenges with type http-01 for order in order
And I encode CSR csr_pem as JOSE Base-64 DER as base64_csr_der
When I send a raw ACME request to "{finalize_url}"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{finalize_url}",
"kid": "{acme_account.uri}"
},
"payload": {
"csr": "{base64_csr_der}"
}
}
"""
Then the value response.status_code should be equal to 400
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR"
And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers"
@@ -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"
And the response body should match JSON value
"""
{
"newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce",
"newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account",
"newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
}
"""
@@ -0,0 +1,180 @@
Feature: External CA
Scenario: Issue a certificate from an external CA
Given I create a Cloudflare connection as cloudflare
Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id
Given I create a external ACME CA with the following config as ext_ca
"""
{
"dnsProviderConfig": {
"provider": "cloudflare",
"hostedZoneId": "MOCK_ZONE_ID"
},
"directoryUrl": "{PEBBLE_URL}",
"accountEmail": "fangpen@infisical.com",
"dnsAppConnectionId": "{app_conn_id}",
"eabKid": "",
"eabHmacKey": ""
}
"""
Then I memorize ext_ca with jq ".id" as ext_ca_id
Given I create a certificate template with the following config as cert_template
"""
{
"subject": [
{
"type": "common_name",
"allowed": [
"*"
]
}
],
"sans": [
{
"type": "dns_name",
"allowed": [
"*"
]
}
],
"keyUsages": {
"required": [],
"allowed": [
"digital_signature",
"key_encipherment",
"non_repudiation",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
"encipher_only",
"decipher_only"
]
},
"extendedKeyUsages": {
"required": [],
"allowed": [
"client_auth",
"server_auth",
"code_signing",
"email_protection",
"ocsp_signing",
"time_stamping"
]
},
"algorithms": {
"signature": [
"SHA256-RSA",
"SHA512-RSA",
"SHA384-ECDSA",
"SHA384-RSA",
"SHA256-ECDSA",
"SHA512-ECDSA"
],
"keyAlgorithm": [
"RSA-2048",
"RSA-4096",
"ECDSA-P384",
"RSA-3072",
"ECDSA-P256",
"ECDSA-P521"
]
},
"validity": {
"max": "365d"
}
}
"""
Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id
Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile"
When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory"
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
When I create certificate signing request as csr
Then I add names to certificate signing request csr
"""
{
"COMMON_NAME": "localhost"
}
"""
# Pebble has a strict rule to only takes SANs
Then I add subject alternative name to certificate signing request csr
"""
[
"localhost"
]
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I select challenge with type http-01 for domain localhost from order in order as challenge
And I serve challenge response for challenge at localhost
And I tell ACME server that challenge is ready to be verified
Given I intercept outgoing requests
"""
[
{
"scope": "https://api.cloudflare.com:443",
"method": "POST",
"path": "/client/v4/zones/MOCK_ZONE_ID/dns_records",
"status": 200,
"response": {
"result": {
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
"name": "Mock Account",
"type": "standard",
"settings": {
"enforce_twofactor": false,
"api_access_enabled": null,
"access_approval_expiry": null,
"abuse_contact_email": null,
"user_groups_ui_beta": false
},
"legacy_flags": {
"enterprise_zone_quota": {
"maximum": 0,
"current": 0,
"available": 0
}
},
"created_on": "2013-04-18T00:41:02.215243Z"
},
"success": true,
"errors": [],
"messages": []
},
"responseIsBinary": false
},
{
"scope": "https://api.cloudflare.com:443",
"method": "GET",
"path": {
"regex": "/client/v4/zones/[^/]+/dns_records\\?"
},
"status": 200,
"response": {
"result": [],
"success": true,
"errors": [],
"messages": [],
"result_info": {
"page": 1,
"per_page": 100,
"count": 0,
"total_count": 0,
"total_pages": 1
}
},
"responseIsBinary": false
}
]
"""
Then I poll and finalize the ACME order order as finalized_order
And the value finalized_order.body with jq ".status" should be equal to "valid"
And I parse the full-chain certificate from order finalized_order as cert
# Note: somehow Pebble is issuing a cert without common name but just SANs
And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json
"""
[
"localhost"
]
"""
+107
View File
@@ -0,0 +1,107 @@
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"
And the response header "Replay-Nonce" should contains non-empty value
Scenario Outline: Send a bad nonce to account endpoints
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
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
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
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "oFvnlFP1wIhRlYS2jTaXbA",
"url": "<url>",
"kid": "{acme_account.uri}"
},
"payload": {}
}
"""
Then the value response.status_code should be equal to 400
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce"
And the value response with jq ".detail" should be equal to "Invalid nonce"
Examples: Endpoints
| src_var | jq | dest_var | url |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order |
| order | . | not_used | {order.uri} |
| order | . | not_used | {order.uri}/finalize |
| order | . | not_used | {order.uri}/certificate |
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
Scenario Outline: Send the same nonce twice
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
And I memorize acme_account.uri with jq "capture("/(?<id>[^/]+)$") | .id" as account_id
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
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I peak and memorize the next nonce as nonce_value
When I send a raw ACME request to "/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce_value}",
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders",
"kid": "{acme_account.uri}"
},
"payload": {}
}
"""
Then the value response.status_code should be equal to 200
And I memorize <src_var> with jq "<jq>" as <dest_var>
When I send a raw ACME request to "<url>"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce_value}",
"url": "<url>",
"kid": "{acme_account.uri}"
},
"payload": {}
}
"""
Then the value response.status_code should be equal to 400
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce"
And the value response with jq ".detail" should be equal to "Invalid nonce"
Examples: Endpoints
| src_var | jq | dest_var | url |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders |
| order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order |
| order | . | not_used | {order.uri} |
| order | . | not_used | {order.uri}/finalize |
| order | . | not_used | {order.uri}/certificate |
| order | .authorizations[0].uri | auth_uri | {auth_uri} |
| order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} |
+142
View File
@@ -0,0 +1,142 @@
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"
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
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)
And the value order.body with jq ".status" should be equal to "pending"
And the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
And 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"
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"
}
"""
And I add subject alternative name to certificate signing request csr
"""
[
"example.com",
"infisical.com"
]
"""
And I create a RSA private key pair as cert_key
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And 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"
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
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
And I send an ACME post-as-get to order.uri as fetched_order
And the value fetched_order with jq ".status" should be equal to "pending"
And the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}]
And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize
And 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
Scenario Outline: Create an order with invalid identifier types
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
And I peak and memorize the next nonce as nonce
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
"kid": "{acme_account.uri}"
},
"payload": {
"identifiers": [
{ "type": "<identifier_type>", "value": "www.example.org" }
]
}
}
"""
Then the value response.status_code should be equal to 400
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier"
And the value response with jq ".detail" should be equal to "Only DNS identifiers are supported"
Examples: Bad Identifier Types
| identifier_type |
| bad |
| ip |
| email |
Scenario Outline: Create an order with invalid identifier values
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
And I peak and memorize the next nonce as nonce
When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order"
"""
{
"protected": {
"alg": "RS256",
"nonce": "{nonce}",
"url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order",
"kid": "{acme_account.uri}"
},
"payload": {
"identifiers": [
{ "type": "dns", "value": "<identifier_value>" }
]
}
}
"""
Then the value response.status_code should be equal to 400
And the value response with jq ".status" should be equal to 400
And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier"
And the value response with jq ".detail" should be equal to "Invalid DNS identifier"
Examples: Bad Identifier Vluaes
| identifier_value |
| 127.0.0.1 |
| 192.168.123.111 |
| 169.254.169.254 |
| ../../etc/passwd |
| !@#$ |
| ! |
| https://evil.com |
+856
View File
@@ -0,0 +1,856 @@
import json
import logging
import re
import urllib.parse
import acme.client
import jq
from faker import Faker
from acme import client
from acme import messages
from acme import standalone
from acme.jws import Signature
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 json_util
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
from features.steps.utils import define_nock, clean_all_nock, restore_nock
from utils import replace_vars, with_nocks
from utils import eval_var
from utils import prepare_headers
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
@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):
profile_id = context.vars.get("PROFILE_ID")
secret = context.vars.get("EAB_SECRET")
if profile_id is not None and secret is not None:
kid = profile_id
else:
profile_slug = faker.slug()
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/pki/certificate-profiles",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"slug": profile_slug,
"description": "ACME Profile created by BDD test",
"enrollmentType": "acme",
"caId": context.vars["CERT_CA_ID"],
"certificateTemplateId": context.vars["CERT_TEMPLATE_ID"],
"acmeConfig": {},
},
)
response.raise_for_status()
resp_json = response.json()
profile_id = resp_json["certificateProfile"]["id"]
kid = profile_id
response = context.http_client.get(
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
headers=dict(authorization="Bearer {}".format(jwt_token)),
)
response.raise_for_status()
resp_json = response.json()
secret = resp_json["eabSecret"]
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
eab_secret=secret,
)
@given("I create a Cloudflare connection as {var_name}")
def step_impl(context: Context, var_name: str):
jwt_token = context.vars["AUTH_TOKEN"]
conn_slug = faker.slug()
mock_account_id = "MOCK_ACCOUNT_ID"
with with_nocks(
context,
definitions=[
{
"scope": "https://api.cloudflare.com:443",
"method": "GET",
"path": f"/client/v4/accounts/{mock_account_id}",
"status": 200,
"response": {
"result": {
"id": "A2A6347F-88B5-442D-9798-95E408BC7701",
"name": "Mock Account",
"type": "standard",
"settings": {
"enforce_twofactor": True,
"api_access_enabled": None,
"access_approval_expiry": None,
"abuse_contact_email": None,
"user_groups_ui_beta": False,
},
"legacy_flags": {
"enterprise_zone_quota": {
"maximum": 0,
"current": 0,
"available": 0,
}
},
"created_on": "2013-04-18T00:41:02.215243Z",
},
"success": True,
"errors": [],
"messages": [],
},
"responseIsBinary": False,
}
],
):
response = context.http_client.post(
"/api/v1/app-connections/cloudflare",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"name": conn_slug,
"description": "",
"method": "api-token",
"credentials": {
"apiToken": "MOCK_API_TOKEN",
"accountId": mock_account_id,
},
},
)
response.raise_for_status()
context.vars[var_name] = response
@given("I create a external ACME CA with the following config as {var_name}")
def step_impl(context: Context, var_name: str):
jwt_token = context.vars["AUTH_TOKEN"]
ca_slug = faker.slug()
config = replace_vars(json.loads(context.text), context.vars)
response = context.http_client.post(
"/api/v1/pki/ca/acme",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"name": ca_slug,
"type": "acme",
"status": "active",
"enableDirectIssuance": True,
"configuration": config,
},
)
response.raise_for_status()
context.vars[var_name] = response
@given("I create a certificate template with the following config as {var_name}")
def step_impl(context: Context, var_name: str):
jwt_token = context.vars["AUTH_TOKEN"]
template_slug = faker.slug()
config = replace_vars(json.loads(context.text), context.vars)
response = context.http_client.post(
"/api/v2/certificate-templates",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"name": template_slug,
"description": "",
}
| config,
)
response.raise_for_status()
context.vars[var_name] = response
@given(
'I create an ACME profile with ca {ca_id} and template {template_id} as "{profile_var}"'
)
def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str):
profile_slug = faker.slug()
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/pki/certificate-profiles",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"slug": profile_slug,
"description": "ACME Profile created by BDD test",
"enrollmentType": "acme",
"caId": replace_vars(ca_id, context.vars),
"certificateTemplateId": replace_vars(template_id, context.vars),
"acmeConfig": {},
},
)
response.raise_for_status()
resp_json = response.json()
profile_id = resp_json["certificateProfile"]["id"]
kid = profile_id
response = context.http_client.get(
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
headers=dict(authorization="Bearer {}".format(jwt_token)),
)
response.raise_for_status()
resp_json = response.json()
secret = resp_json["eabSecret"]
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
eab_secret=secret,
)
@given('I have an ACME cert profile with external ACME CA as "{profile_var}"')
def step_impl(context: Context, profile_var: str):
profile_id = context.vars.get("PROFILE_ID")
secret = context.vars.get("EAB_SECRET")
if profile_id is not None and secret is not None:
kid = profile_id
else:
profile_slug = faker.slug()
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/pki/certificate-profiles",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json={
"projectId": context.vars["PROJECT_ID"],
"slug": profile_slug,
"description": "ACME Profile created by BDD test",
"enrollmentType": "acme",
"caId": context.vars["CERT_CA_ID"],
"certificateTemplateId": context.vars["CERT_TEMPLATE_ID"],
"acmeConfig": {},
},
)
response.raise_for_status()
resp_json = response.json()
profile_id = resp_json["certificateProfile"]["id"]
kid = profile_id
response = context.http_client.get(
f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal",
headers=dict(authorization="Bearer {}".format(jwt_token)),
)
response.raise_for_status()
resp_json = response.json()
secret = resp_json["eabSecret"]
context.vars[profile_var] = AcmeProfile(
profile_id,
eab_kid=kid,
eab_secret=secret,
)
@given("I intercept outgoing requests")
def step_impl(context: Context):
definitions = replace_vars(json.loads(context.text), context.vars)
define_nock(context, definitions)
@then("I reset requests interceptions")
def step_impl(context: Context):
clean_all_nock(context)
restore_nock(context)
@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())
def create_acme_client(context: Context, url: str, acc_jwk: JWKRSA | None = None):
if acc_jwk is None:
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)
@when('I have an ACME client connecting to "{url}"')
def step_impl(context: Context, url: str):
create_acme_client(context, url)
@when('I have an ACME client connecting to "{url}" with the key pair from {client_var}')
def step_impl(context: Context, url: str, client_var: str):
another_client = eval_var(context, client_var, as_json=False)
create_acme_client(context, url, acc_jwk=another_client.net.key)
@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}"
@when('I use a different new-account URL "{url}" for EAB signature')
def step_impl(context: Context, url: str):
context.alt_eab_url = replace_vars(url, context.vars)
def register_account_with_eab(
context: Context,
email: str,
kid: str,
secret: str,
account_var: str,
only_return_existing: bool = False,
):
acme_client = context.acme_client
account_public_key = acme_client.net.key.public_key()
if hasattr(context, "alt_eab_url"):
eab_directory = messages.Directory.from_json(
{"newAccount": context.alt_eab_url}
)
else:
eab_directory = acme_client.directory
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=eab_directory,
hmac_alg="HS256",
)
registration = messages.NewRegistration.from_data(
email=email,
external_account_binding=eab,
only_return_existing=only_return_existing,
)
try:
context.vars[account_var] = acme_client.new_account(registration)
except Exception as exp:
context.vars["error"] = exp
@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):
register_account_with_eab(
context=context, email=email, kid=kid, secret=secret, account_var=account_var
)
@then(
'I find the existing 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):
register_account_with_eab(
context=context,
email=email,
kid=kid,
secret=secret,
account_var=account_var,
only_return_existing=True,
)
@then("I register a new ACME account with email {email} without EAB")
def step_impl(context: Context, email: str):
acme_client = context.acme_client
registration = messages.NewRegistration.from_data(
email=email,
)
try:
context.vars["error"] = acme_client.new_account(registration)
except Exception as exp:
context.vars["error"] = exp
def send_raw_acme_req(context: Context, url: str):
acme_client = context.acme_client
content = json.loads(context.text)
protected = replace_vars(content["protected"], context.vars)
alg = acme_client.net.alg
if "raw_payload" in content:
encoded_payload = content["raw_payload"].encode("utf-8")
elif "payload" in content:
payload = (
replace_vars(content["payload"], context.vars)
if "payload" in content
else None
)
encoded_payload = json.dumps(payload).encode() if payload is not None else b""
else:
encoded_payload = b""
protected_headers = json.dumps(protected)
signature = alg.sign(
key=acme_client.net.key.key,
msg=Signature._msg(protected_headers, encoded_payload),
)
jws = json.dumps(
{
"protected": json_util.encode_b64jose(protected_headers.encode()),
"payload": json_util.encode_b64jose(encoded_payload),
"signature": json_util.encode_b64jose(signature),
}
)
base_url = context.vars["BASE_URL"]
actual_url = urllib.parse.urljoin(base_url, replace_vars(url, context.vars))
response = acme_client.net._send_request(
"POST",
actual_url,
data=jws,
headers={"Content-Type": acme.client.ClientNetwork.JOSE_CONTENT_TYPE},
)
context.vars["response"] = response
@when('I send a raw ACME request to "{url}"')
def step_impl(context: Context, url: str):
send_raw_acme_req(context, url)
@then(
"I encode CSR {pem_var} as JOSE Base-64 DER as {var_name}",
)
def step_impl(context: Context, pem_var: str, var_name: str):
csr = eval_var(context, pem_var)
parsed_csr = x509.load_pem_x509_csr(csr)
context.vars[var_name] = json_util.encode_csr(parsed_csr)
@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 = replace_vars(json.loads(expected), context.vars)
assert value == expected_value, f"{value!r} does not match {expected_value!r}"
@then("the value {var_path} should not be equal to {expected}")
def step_impl(context: Context, var_path: str, expected: str):
value = eval_var(context, var_path)
expected_value = replace_vars(json.loads(expected), context.vars)
assert value != expected_value, f"{value!r} does 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 peak and memorize the next nonce as {var_name}")
def step_impl(context: Context, var_name: str):
acme_client = context.acme_client
context.vars[var_name] = json_util.encode_b64jose(list(acme_client.net._nonces)[0])
@then("I put away current ACME client as {var_name}")
def step_impl(context: Context, var_name: str):
acme_client = context.acme_client
del context.acme_client
context.vars[var_name] = acme_client
@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))
def select_challenge(
context: Context,
challenge_type: str,
order_var_path: str,
domain: str,
):
acme_client = context.acme_client
order = eval_var(context, order_var_path, as_json=False)
if isinstance(order, dict):
order_body = messages.Order.from_json(order)
order = messages.OrderResource(
body=order_body,
authorizations=[
acme_client._authzr_from_response(
acme_client._post_as_get(url), uri=url
)
for url in order_body.authorizations
],
)
if not isinstance(order, messages.OrderResource):
raise ValueError(
f"Expected OrderResource but got {type(order)!r} at {order_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 {order_var_path!r}"
)
if len(auths) > 1:
raise ValueError(
f"More than one order for domain {domain!r} found in {order_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 {order_var_path!r}"
)
if len(challenges) > 1:
raise ValueError(
f"More than one authorization for type {challenge_type!r} found in {order_var_path!r}"
)
return challenges[0]
def serve_challenge(
context: Context,
challenge: messages.ChallengeBody,
):
if hasattr(context, "web_server"):
context.web_server.shutdown_and_server_close()
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})
servers.serve_forever()
context.web_server = servers
def notify_challenge_ready(context: Context, challenge: messages.ChallengeBody):
acme_client = context.acme_client
response, validation = challenge.response_and_validation(acme_client.net.key)
acme_client.answer_challenge(challenge, response)
@then(
"I select challenge with type {challenge_type} for domain {domain} from order in {var_path} as {challenge_var}"
)
def step_impl(
context: Context,
challenge_type: str,
domain: str,
var_path: str,
challenge_var: str,
):
challenge = select_challenge(
context=context,
challenge_type=challenge_type,
domain=domain,
order_var_path=var_path,
)
context.vars[challenge_var] = challenge
@then("I pass all challenges with type {challenge_type} for order in {order_var_path}")
def step_impl(
context: Context,
challenge_type: str,
order_var_path: str,
):
acme_client = context.acme_client
order = eval_var(context, order_var_path, as_json=False)
if isinstance(order, dict):
order_body = messages.Order.from_json(order)
order = messages.OrderResource(
body=order_body,
authorizations=[
acme_client._authzr_from_response(
acme_client._post_as_get(url), uri=url
)
for url in order_body.authorizations
],
)
if not isinstance(order, messages.OrderResource):
raise ValueError(
f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}"
)
for domain in order.body.identifiers:
logger.info(
"Selecting challenge for domain %s with type %s ...",
domain.value,
challenge_type,
)
challenge = select_challenge(
context=context,
challenge_type=challenge_type,
domain=domain.value,
order_var_path=order_var_path,
)
logger.info(
"Found challenge for domain %s with type %s, challenge=%s",
domain.value,
challenge_type,
challenge.uri,
)
logger.info(
"Serving challenge for domain %s with type %s ...",
domain.value,
challenge_type,
)
serve_challenge(context=context, challenge=challenge)
logger.info(
"Notifying challenge for domain %s with type %s ...", domain, challenge_type
)
notify_challenge_ready(context=context, challenge=challenge)
@then("I serve challenge response for {var_path} at {hostname}")
def step_impl(context: Context, var_path: str, hostname: str):
challenge = eval_var(context, var_path, as_json=False)
serve_challenge(context=context, challenge=challenge)
@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)
notify_challenge_ready(context=context, challenge=challenge)
@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
@then("I parse the full-chain certificate from order {order_var_path} as {cert_var}")
def step_impl(context: Context, order_var_path: str, cert_var: str):
order = eval_var(context, order_var_path, as_json=False)
cert = x509.load_pem_x509_certificate(order.fullchain_pem.encode())
context.vars[cert_var] = cert
+302
View File
@@ -0,0 +1,302 @@
from cryptography import x509
from cryptography.hazmat.primitives import hashes
from cryptography.x509.oid import NameOID
import logging
import re
import contextlib
import httpx
import requests
import requests.structures
import glom
from faker import Faker
from behave.runner import Context
from josepy import JSONObjectWithFields
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, requests.structures.CaseInsensitiveDict):
value = dict(value.lower_items())
elif isinstance(value, httpx.Response):
value = value.json()
elif isinstance(value, x509.Certificate):
value = x509_cert_to_dict(value)
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
def x509_cert_to_dict(cert: x509.Certificate) -> dict:
"""
Convert a cryptography.x509.Certificate to a JSON-serializable nested dict
with human-readable keys.
"""
def oid_to_name(oid):
# Map known OIDs to human-readable names
mapping = {
NameOID.COMMON_NAME: "common_name",
NameOID.ORGANIZATION_NAME: "organization",
NameOID.ORGANIZATIONAL_UNIT_NAME: "organizational_unit",
NameOID.COUNTRY_NAME: "country",
NameOID.LOCALITY_NAME: "locality",
NameOID.STATE_OR_PROVINCE_NAME: "state_or_province",
NameOID.EMAIL_ADDRESS: "email_address",
NameOID.SERIAL_NUMBER: "serial_number",
NameOID.SURNAME: "surname",
NameOID.GIVEN_NAME: "given_name",
NameOID.TITLE: "title",
NameOID.JURISDICTION_COUNTRY_NAME: "jurisdiction_country",
NameOID.JURISDICTION_STATE_OR_PROVINCE_NAME: "jurisdiction_state",
NameOID.JURISDICTION_LOCALITY_NAME: "jurisdiction_locality",
NameOID.BUSINESS_CATEGORY: "business_category",
NameOID.POSTAL_CODE: "postal_code",
NameOID.STREET_ADDRESS: "street_address",
NameOID.DOMAIN_COMPONENT: "domain_component",
NameOID.USER_ID: "user_id",
# Add more as needed
}
return mapping.get(oid, oid.dotted_string)
def name_to_dict(name: x509.Name) -> dict:
return {oid_to_name(attr.oid): attr.value for attr in name}
def dns_to_dict(dns: x509.DNSName) -> dict:
return dict(value=dns.value)
def extension_to_dict(ext):
if isinstance(ext.value, x509.SubjectAlternativeName):
return {
"critical": ext.critical,
"general_names": [dns_to_dict(gn) for gn in ext.value],
}
elif isinstance(ext.value, x509.BasicConstraints):
return {
"critical": ext.critical,
"ca": ext.value.ca,
"path_length": ext.value.path_length,
}
elif isinstance(ext.value, x509.KeyUsage):
return {
"critical": ext.critical,
**{
field.lower(): getattr(ext.value, field)
for field in [
"digital_signature",
"content_commitment",
"key_encipherment",
"data_encipherment",
"key_agreement",
"key_cert_sign",
"crl_sign",
# TODO: deal with error: "ValueError: encipher_only is undefined unless key_agreement is true"
# "encipher_only",
# "decipher_only",
]
if getattr(ext.value, field) is not None
},
}
elif isinstance(ext.value, x509.ExtendedKeyUsage):
return {
"critical": ext.critical,
"usages": [eku.dotted_string for eku in ext.value],
}
elif isinstance(ext.value, x509.CRLDistributionPoints):
return {
"critical": ext.critical,
"distribution_points": [
{
"full_name": [str(uri) for uri in dp.full_name]
if dp.full_name
else None,
"crl_issuer": [str(issuer) for issuer in dp.crl_issuer]
if dp.crl_issuer
else None,
"reasons": [r.name for r in dp.reasons] if dp.reasons else None,
}
for dp in ext.value
],
}
elif isinstance(ext.value, x509.AuthorityKeyIdentifier):
return {
"critical": ext.critical,
"key_identifier": ext.value.key_identifier.hex()
if ext.value.key_identifier
else None,
"authority_cert_issuer": [
str(n) for n in ext.value.authority_cert_issuer
]
if ext.value.authority_cert_issuer
else None,
"authority_cert_serial_number": ext.value.authority_cert_serial_number,
}
elif isinstance(ext.value, x509.SubjectKeyIdentifier):
return {"critical": ext.critical, "digest": ext.value.digest.hex()}
else:
return {
"critical": ext.critical,
"oid": ext.oid.dotted_string,
"value": str(ext.value),
}
# Build the main dict
result = dict(
version=cert.version.name,
serial_number=cert.serial_number,
signature_algorithm=cert.signature_algorithm_oid._name,
issuer=name_to_dict(cert.issuer),
subject=name_to_dict(cert.subject),
validity={
"not_valid_before": cert.not_valid_before.isoformat(),
"not_valid_after": cert.not_valid_after.isoformat(),
},
public_key={
"key_size": cert.public_key().key_size,
},
extensions={
ext.oid._name
if hasattr(ext.oid, "_name") and ext.oid._name
else ext.oid.dotted_string: extension_to_dict(ext)
for ext in cert.extensions
},
fingerprint={
"sha1": cert.fingerprint(hashes.SHA1()).hex(),
"sha256": cert.fingerprint(hashes.SHA256()).hex(),
},
)
return result
def define_nock(context: Context, definitions: list[dict]):
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/bdd-nock/define",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json=dict(definitions=definitions),
)
response.raise_for_status()
def restore_nock(context: Context):
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/bdd-nock/restore",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json=dict(),
)
response.raise_for_status()
def clean_all_nock(context: Context):
jwt_token = context.vars["AUTH_TOKEN"]
response = context.http_client.post(
"/api/v1/bdd-nock/clean-all",
headers=dict(authorization="Bearer {}".format(jwt_token)),
json=dict(),
)
response.raise_for_status()
@contextlib.contextmanager
def with_nocks(context: Context, definitions: list[dict]):
try:
define_nock(context, definitions)
yield
finally:
clean_all_nock(context)
restore_nock(context)
+13
View File
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIICBDCCAYmgAwIBAgIIHZvNVJSPdsYwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
bWluaWNhIHJvb3QgY2EgN2ZlMDQwMB4XDTI1MTExMzAwMzAxMloXDTI3MTIxMzAw
MzAxMlowFDESMBAGA1UEAxMJbG9jYWxob3N0MHYwEAYHKoZIzj0CAQYFK4EEACID
YgAE2V5oM5JimqDjzEfH10cKu6L8eQ9rxzkULbIJRFFuuXtKQQwkcAW8L4UuMkmG
lu5hFCBR8saHDpISuAyYLYqsddxwndxmGT3zyw6oU+8oXWX0tThL0KgajmZckOfR
ysYpo4GbMIGYMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI
KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSIDfQe2L6+9aYyBFbd
t0S51xW3UDA4BgNVHREEMTAvgglsb2NhbGhvc3SCBnBlYmJsZYIUaG9zdC5kb2Nr
ZXIuaW50ZXJuYWyHBH8AAAEwCgYIKoZIzj0EAwMDaQAwZgIxAPkeGVzCDKuJYd/1
87+lXXtlMHrW7F+Rn1kyR8SBud2hDt5r3a+ZZ8IQ9aHazRia/AIxAOI4I41jwxf0
86i7fKx8of4s/CBc4+PF0hbCBkmen3aKuiZ7ueYuEsSNT6zHV2xc2w==
-----END CERTIFICATE-----
+6
View File
@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBx7d0VqxwTYcJajFgz
ja0PExBmxdZjEQRfGCMQY8GfHa0WpBUEwVtBD6XOGE5xZB2hZANiAATZXmgzkmKa
oOPMR8fXRwq7ovx5D2vHORQtsglEUW65e0pBDCRwBbwvhS4ySYaW7mEUIFHyxocO
khK4DJgtiqx13HCd3GYZPfPLDqhT7yhdZfS1OEvQqBqOZlyQ59HKxik=
-----END PRIVATE KEY-----
+28
View File
@@ -0,0 +1,28 @@
{
"pebble": {
"listenAddress": "0.0.0.0:14000",
"managementListenAddress": "0.0.0.0:15000",
"certificate": "/var/data/pebble/localhost/cert.pem",
"privateKey": "/var/data/pebble/localhost/key.pem",
"httpPort": 5002,
"tlsPort": 5001,
"ocspResponderURL": "",
"externalAccountBindingRequired": false,
"domainBlocklist": ["blocked-domain.example"],
"retryAfter": {
"authz": 3,
"order": 5
},
"keyAlgorithm": "ecdsa",
"profiles": {
"default": {
"description": "The profile you know and love",
"validityPeriod": 7776000
},
"shortlived": {
"description": "A short-lived cert profile, without actual enforcement",
"validityPeriod": 518400
}
}
}
}
+6
View File
@@ -0,0 +1,6 @@
-----BEGIN PRIVATE KEY-----
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDnPx90G0J4ba0CMTrh
AT0kJkRGyhv5ePWyobdT75za/I9MpU/VsC8BG5uJBraxiSOhZANiAAQWEiTINq0t
j+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ
0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4SVQ9GzizHiJ//Qb71vrXbco=
-----END PRIVATE KEY-----
+13
View File
@@ -0,0 +1,13 @@
-----BEGIN CERTIFICATE-----
MIIB+zCCAYKgAwIBAgIIf+BA3XMRozcwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV
bWluaWNhIHJvb3QgY2EgN2ZlMDQwMCAXDTI1MTExMzAwMzAxMloYDzIxMjUxMTEz
MDAzMDEyWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA3ZmUwNDAwdjAQBgcq
hkjOPQIBBgUrgQQAIgNiAAQWEiTINq0tj+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1
W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4
SVQ9GzizHiJ//Qb71vrXbcqjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQW
MBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1Ud
DgQWBBSIDfQe2L6+9aYyBFbdt0S51xW3UDAfBgNVHSMEGDAWgBSIDfQe2L6+9aYy
BFbdt0S51xW3UDAKBggqhkjOPQQDAwNnADBkAjAK2OUUVHs2LVqwyLEqIrXbc3gw
5r5p9TC9asqPN8vJxlTRStrXnJQRSQ2KoWztiSICMEV5jZGVk6TaUwlqcGmXEmGr
iFeQ3rXLaRw8XKMqj7+EiwaCD1o2wLgzny/21NFtxQ==
-----END CERTIFICATE-----
+16
View File
@@ -0,0 +1,16 @@
[project]
name = "bdd"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"acme>=5.1.0",
"behave>=1.3.3",
"dotenv>=0.9.9",
"faker>=37.12.0",
"glom>=24.11.0",
"httpx>=0.28.1",
"josepy>=2.2.0",
"jq>=1.10.0",
]
+558
View File
@@ -0,0 +1,558 @@
version = 1
revision = 2
requires-python = ">=3.12"
[[package]]
name = "acme"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "josepy" },
{ name = "pyopenssl" },
{ name = "pyrfc3339" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/07/f6/897be0abeb0e64f0e6136a8a6369a54d2a603a44cb7a411f6d77dbafb4ac/acme-5.1.0.tar.gz", hash = "sha256:7b97820857d9baffed98bca50ab82bb6a636e447865d7a013a7bdd7972f03cda", size = 89982, upload-time = "2025-10-07T17:30:38.579Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/0b/4d0421412bb063f4393ae7ebf3a9a6fde621aed187a1140ccf7f9e22b823/acme-5.1.0-py3-none-any.whl", hash = "sha256:80e9c315d82302bb97279f4516ff31230d29195ab9d4a6c9411ceec20481b61e", size = 94151, upload-time = "2025-10-07T17:30:15.994Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "bdd"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "acme" },
{ name = "behave" },
{ name = "dotenv" },
{ name = "faker" },
{ name = "glom" },
{ name = "httpx" },
{ name = "josepy" },
{ name = "jq" },
]
[package.metadata]
requires-dist = [
{ name = "acme", specifier = ">=5.1.0" },
{ name = "behave", specifier = ">=1.3.3" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "faker", specifier = ">=37.12.0" },
{ name = "glom", specifier = ">=24.11.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "josepy", specifier = ">=2.2.0" },
{ name = "jq", specifier = ">=1.10.0" },
]
[[package]]
name = "behave"
version = "1.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama" },
{ name = "cucumber-expressions" },
{ name = "cucumber-tag-expressions" },
{ name = "parse" },
{ name = "parse-type" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" },
]
[[package]]
name = "boltons"
version = "25.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
]
[[package]]
name = "cucumber-expressions"
version = "18.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/f4e231167b23b3d7348aa1c90117ce8854fae186d6984ad66d705df24061/cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60", size = 22232, upload-time = "2024-10-28T11:38:48.672Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/e0/31ce90dad5234c3d52432bfce7562aa11cda4848aea90936a4be6c67d7ab/cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42", size = 20211, upload-time = "2024-10-28T11:38:47.101Z" },
]
[[package]]
name = "cucumber-tag-expressions"
version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/77/b8868653e9c7d432433d4d4d5e99d5923b309b89c8b08bc7f0cb5657ba0b/cucumber_tag_expressions-8.0.0.tar.gz", hash = "sha256:4af80282ff0349918c332428176089094019af6e2a381a2fd8f1c62a7a6bb7e8", size = 8427, upload-time = "2025-10-14T17:01:27.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" },
]
[[package]]
name = "dotenv"
version = "0.9.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dotenv" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
]
[[package]]
name = "face"
version = "24.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boltons" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/79/2484075a8549cd64beae697a8f664dee69a5ccf3a7439ee40c8f93c1978a/face-24.0.0.tar.gz", hash = "sha256:611e29a01ac5970f0077f9c577e746d48c082588b411b33a0dd55c4d872949f6", size = 62732, upload-time = "2024-11-02T05:24:26.095Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" },
]
[[package]]
name = "faker"
version = "37.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" },
]
[[package]]
name = "glom"
version = "24.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "boltons" },
{ name = "face" },
]
sdist = { url = "https://files.pythonhosted.org/packages/05/89/b57cfbc448189426f2e01b244fbe9226b059ef5423a9d49c1d335a1f1026/glom-24.11.0.tar.gz", hash = "sha256:4325f96759a912044af7b6c6bd0dba44ad8c1eb6038aab057329661d2021bb27", size = 195120, upload-time = "2024-11-02T23:17:50.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "josepy"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" },
]
[[package]]
name = "jq"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308, upload-time = "2025-07-14T18:54:53.679Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103, upload-time = "2025-07-14T18:52:39.016Z" },
{ url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325, upload-time = "2025-07-14T18:52:40.654Z" },
{ url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212, upload-time = "2025-07-14T18:52:42.637Z" },
{ url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068, upload-time = "2025-07-14T18:52:44.709Z" },
{ url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259, upload-time = "2025-07-14T18:52:47.308Z" },
{ url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075, upload-time = "2025-07-14T18:52:50.038Z" },
{ url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475, upload-time = "2025-07-14T18:52:53.047Z" },
{ url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416, upload-time = "2025-07-14T18:52:55.858Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113, upload-time = "2025-07-14T18:52:57.736Z" },
{ url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316, upload-time = "2025-07-14T18:52:59.605Z" },
{ url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325, upload-time = "2025-07-14T18:53:01.854Z" },
{ url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344, upload-time = "2025-07-14T18:53:03.528Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874, upload-time = "2025-07-14T18:53:05.406Z" },
{ url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355, upload-time = "2025-07-14T18:53:07.487Z" },
{ url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546, upload-time = "2025-07-14T18:53:11.756Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777, upload-time = "2025-07-14T18:53:14.856Z" },
{ url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307, upload-time = "2025-07-14T18:53:17.611Z" },
{ url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830, upload-time = "2025-07-14T18:53:19.937Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285, upload-time = "2025-07-14T18:53:21.631Z" },
{ url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094, upload-time = "2025-07-14T18:53:23.687Z" },
]
[[package]]
name = "parse"
version = "1.20.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" },
]
[[package]]
name = "parse-type"
version = "0.6.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "parse" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" },
]
[[package]]
name = "pycparser"
version = "2.23"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
]
[[package]]
name = "pyopenssl"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
]
[[package]]
name = "pyrfc3339"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
+121 -24
View File
@@ -58,6 +58,7 @@
"@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0",
"ajv": "^8.12.0",
@@ -83,6 +84,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",
@@ -96,6 +98,8 @@
"ms": "^2.1.3",
"mysql2": "^3.9.8",
"nanoid": "^3.3.8",
"nock": "^14.0.10",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0",
"odbc": "^2.4.9",
@@ -9702,6 +9706,23 @@
"win32"
]
},
"node_modules/@mswjs/interceptors": {
"version": "0.39.8",
"resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz",
"integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==",
"license": "MIT",
"dependencies": {
"@open-draft/deferred-promise": "^2.2.0",
"@open-draft/logger": "^0.3.0",
"@open-draft/until": "^2.0.0",
"is-node-process": "^1.2.0",
"outvariant": "^1.4.3",
"strict-event-emitter": "^0.5.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@next/env": {
"version": "15.5.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz",
@@ -10711,6 +10732,28 @@
"urijs": "^1.19.11"
}
},
"node_modules/@open-draft/deferred-promise": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz",
"integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==",
"license": "MIT"
},
"node_modules/@open-draft/logger": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz",
"integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==",
"license": "MIT",
"dependencies": {
"is-node-process": "^1.2.0",
"outvariant": "^1.4.0"
}
},
"node_modules/@open-draft/until": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz",
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -15271,6 +15314,15 @@
"form-data": "^4.0.0"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -22946,6 +22998,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-node-process": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz",
"integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==",
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -23236,9 +23294,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"
}
@@ -23494,6 +23553,12 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -23638,6 +23703,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",
@@ -25052,6 +25126,20 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/nock": {
"version": "14.0.10",
"resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz",
"integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==",
"license": "MIT",
"dependencies": {
"@mswjs/interceptors": "^0.39.5",
"json-stringify-safe": "^5.0.1",
"propagate": "^2.0.0"
},
"engines": {
"node": ">=18.20.0 <20 || >=20.12.1"
}
},
"node_modules/node-abi": {
"version": "3.65.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz",
@@ -27590,6 +27678,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",
@@ -27671,6 +27768,12 @@
"@otplib/preset-v11": "^12.0.1"
}
},
"node_modules/outvariant": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz",
"integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==",
"license": "MIT"
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -29072,6 +29175,15 @@
"node": ">= 6"
}
},
"node_modules/propagate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz",
"integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/proto3-json-serializer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz",
@@ -31570,6 +31682,12 @@
"node": ">=4.0.0"
}
},
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -33506,18 +33624,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-node/node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/vite-node/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -33549,15 +33655,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite-node/node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/vite-node/node_modules/vite": {
"version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+5
View File
@@ -44,6 +44,7 @@
"test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1",
"test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1",
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts",
"test:bdd": "cd bdd && uv run behave",
"generate:component": "tsx ./scripts/create-backend-file.ts",
"generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas",
"auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest",
@@ -185,6 +186,7 @@
"@sindresorhus/slugify": "1.1.0",
"@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0",
"@types/node-forge": "^1.3.14",
"@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0",
"ajv": "^8.12.0",
@@ -210,6 +212,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",
@@ -223,6 +226,8 @@
"ms": "^2.1.3",
"mysql2": "^3.9.8",
"nanoid": "^3.3.8",
"nock": "^14.0.10",
"node-forge": "^1.3.1",
"nodemailer": "^6.9.9",
"oci-sdk": "^2.108.0",
"odbc": "^2.4.9",
+7 -1
View File
@@ -32,6 +32,7 @@ import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-re
import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TPitServiceFactory } from "@app/ee/services/pit/pit-service";
import { TPkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-types";
import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types";
import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types";
import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service";
@@ -90,6 +91,7 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i
import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types";
import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service";
import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service";
import { TScopedIdentityV2ServiceFactory } from "@app/services/identity-v2/identity-service";
import { TIntegrationServiceFactory } from "@app/services/integration/integration-service";
import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service";
import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service";
@@ -101,6 +103,7 @@ import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-r
import { TOrgServiceFactory } from "@app/services/org/org-service";
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
import { TPkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service";
import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
@@ -256,7 +259,8 @@ declare module "fastify" {
integrationAuth: TIntegrationAuthServiceFactory;
webhook: TWebhookServiceFactory;
serviceToken: TServiceTokenServiceFactory;
identity: TIdentityServiceFactory;
identityV1: TIdentityServiceFactory;
identityV2: TScopedIdentityV2ServiceFactory;
identityAccessToken: TIdentityAccessTokenServiceFactory;
identityProject: TIdentityProjectServiceFactory;
identityTokenAuth: TIdentityTokenAuthServiceFactory;
@@ -294,6 +298,7 @@ declare module "fastify" {
certificateAuthority: TCertificateAuthorityServiceFactory;
certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory;
certificateEst: TCertificateEstServiceFactory;
pkiAcme: TPkiAcmeServiceFactory;
certificateEstV3: TCertificateEstV3ServiceFactory;
pkiCollection: TPkiCollectionServiceFactory;
pkiSubscriber: TPkiSubscriberServiceFactory;
@@ -353,6 +358,7 @@ declare module "fastify" {
role: TRoleServiceFactory;
convertor: TConvertorServiceFactory;
subOrganization: TSubOrgServiceFactory;
pkiAlertV2: TPkiAlertV2ServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer
+72
View File
@@ -266,9 +266,39 @@ import {
TOrgRoles,
TOrgRolesInsert,
TOrgRolesUpdate,
TPkiAcmeAccounts,
TPkiAcmeAccountsInsert,
TPkiAcmeAccountsUpdate,
TPkiAcmeAuths,
TPkiAcmeAuthsInsert,
TPkiAcmeAuthsUpdate,
TPkiAcmeChallenges,
TPkiAcmeChallengesInsert,
TPkiAcmeChallengesUpdate,
TPkiAcmeEnrollmentConfigs,
TPkiAcmeEnrollmentConfigsInsert,
TPkiAcmeEnrollmentConfigsUpdate,
TPkiAcmeOrderAuths,
TPkiAcmeOrderAuthsInsert,
TPkiAcmeOrderAuthsUpdate,
TPkiAcmeOrders,
TPkiAcmeOrdersInsert,
TPkiAcmeOrdersUpdate,
TPkiAlertChannels,
TPkiAlertChannelsInsert,
TPkiAlertChannelsUpdate,
TPkiAlertHistory,
TPkiAlertHistoryCertificate,
TPkiAlertHistoryCertificateInsert,
TPkiAlertHistoryCertificateUpdate,
TPkiAlertHistoryInsert,
TPkiAlertHistoryUpdate,
TPkiAlerts,
TPkiAlertsInsert,
TPkiAlertsUpdate,
TPkiAlertsV2,
TPkiAlertsV2Insert,
TPkiAlertsV2Update,
TPkiApiEnrollmentConfigs,
TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate,
@@ -709,6 +739,32 @@ declare module "knex/types/tables" {
TPkiApiEnrollmentConfigsInsert,
TPkiApiEnrollmentConfigsUpdate
>;
[TableName.PkiAcmeEnrollmentConfig]: KnexOriginal.CompositeTableType<
TPkiAcmeEnrollmentConfigs,
TPkiAcmeEnrollmentConfigsInsert,
TPkiAcmeEnrollmentConfigsUpdate
>;
[TableName.PkiAcmeAccount]: KnexOriginal.CompositeTableType<
TPkiAcmeAccounts,
TPkiAcmeAccountsInsert,
TPkiAcmeAccountsUpdate
>;
[TableName.PkiAcmeOrder]: KnexOriginal.CompositeTableType<
TPkiAcmeOrders,
TPkiAcmeOrdersInsert,
TPkiAcmeOrdersUpdate
>;
[TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType<TPkiAcmeAuths, TPkiAcmeAuthsInsert, TPkiAcmeAuthsUpdate>;
[TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType<
TPkiAcmeOrderAuths,
TPkiAcmeOrderAuthsInsert,
TPkiAcmeOrderAuthsUpdate
>;
[TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType<
TPkiAcmeChallenges,
TPkiAcmeChallengesInsert,
TPkiAcmeChallengesUpdate
>;
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
TCertificateTemplateEstConfigs,
TCertificateTemplateEstConfigsInsert,
@@ -725,6 +781,22 @@ declare module "knex/types/tables" {
TCertificateSecretsUpdate
>;
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>;
[TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType<TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update>;
[TableName.PkiAlertChannels]: KnexOriginal.CompositeTableType<
TPkiAlertChannels,
TPkiAlertChannelsInsert,
TPkiAlertChannelsUpdate
>;
[TableName.PkiAlertHistory]: KnexOriginal.CompositeTableType<
TPkiAlertHistory,
TPkiAlertHistoryInsert,
TPkiAlertHistoryUpdate
>;
[TableName.PkiAlertHistoryCertificate]: KnexOriginal.CompositeTableType<
TPkiAlertHistoryCertificate,
TPkiAlertHistoryCertificateInsert,
TPkiAlertHistoryCertificateUpdate
>;
[TableName.PkiCollection]: KnexOriginal.CompositeTableType<
TPkiCollections,
TPkiCollectionsInsert,
@@ -0,0 +1,22 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasProjectIdCol = await knex.schema.hasColumn(TableName.Identity, "projectId");
if (!hasProjectIdCol) {
await knex.schema.alterTable(TableName.Identity, (t) => {
t.string("projectId");
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasProjectIdCol = await knex.schema.hasColumn(TableName.Identity, "projectId");
if (hasProjectIdCol) {
await knex.schema.alterTable(TableName.Identity, (t) => {
t.dropColumn("projectId");
});
}
}
@@ -0,0 +1,31 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled"))) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.boolean("isSecretSyncErrorNotificationEnabled").notNullable().defaultTo(false);
});
}
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels"))) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.text("secretSyncErrorChannels").notNullable().defaultTo("");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled")) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.dropColumn("isSecretSyncErrorNotificationEnabled");
});
}
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels")) {
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
table.dropColumn("secretSyncErrorChannels");
});
}
}
@@ -0,0 +1,87 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.PkiAlertsV2))) {
await knex.schema.createTable(TableName.PkiAlertsV2, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.string("name").notNullable();
t.text("description").nullable();
t.string("eventType").notNullable();
t.string("alertBefore").nullable();
t.jsonb("filters").nullable();
t.boolean("enabled").defaultTo(true);
t.string("projectId").notNullable();
t.timestamps(true, true, true);
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
t.index("projectId");
t.unique(["name", "projectId"]);
});
}
if (!(await knex.schema.hasTable(TableName.PkiAlertChannels))) {
await knex.schema.createTable(TableName.PkiAlertChannels, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("alertId").notNullable();
t.string("channelType").notNullable();
t.jsonb("config").notNullable();
t.boolean("enabled").defaultTo(true);
t.timestamps(true, true, true);
t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE");
t.index("alertId");
t.index("channelType");
t.index("enabled");
});
}
if (!(await knex.schema.hasTable(TableName.PkiAlertHistory))) {
await knex.schema.createTable(TableName.PkiAlertHistory, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("alertId").notNullable();
t.timestamp("triggeredAt").defaultTo(knex.fn.now());
t.boolean("hasNotificationSent").defaultTo(false);
t.text("notificationError").nullable();
t.timestamps(true, true, true);
t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE");
t.index("alertId");
t.index("triggeredAt");
});
}
if (!(await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate))) {
await knex.schema.createTable(TableName.PkiAlertHistoryCertificate, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("alertHistoryId").notNullable();
t.uuid("certificateId").notNullable();
t.timestamps(true, true, true);
t.foreign("alertHistoryId").references("id").inTable(TableName.PkiAlertHistory).onDelete("CASCADE");
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
t.index("alertHistoryId");
t.index("certificateId");
t.unique(["alertHistoryId", "certificateId"]);
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate)) {
await knex.schema.dropTable(TableName.PkiAlertHistoryCertificate);
}
if (await knex.schema.hasTable(TableName.PkiAlertHistory)) {
await knex.schema.dropTable(TableName.PkiAlertHistory);
}
if (await knex.schema.hasTable(TableName.PkiAlertChannels)) {
await knex.schema.dropTable(TableName.PkiAlertChannels);
}
if (await knex.schema.hasTable(TableName.PkiAlertsV2)) {
await knex.schema.dropTable(TableName.PkiAlertsV2);
}
}
@@ -0,0 +1,247 @@
import { Knex } from "knex";
import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
// Notice: the old constraint name is "enrollmentType_check" instead of "enrollment_type_check"
// with psql, if there's no quote around an identifier, it will be lowercased.
// this may cause issues in migrations as Knex sometimes generates identifiers without quotes.
// to avoid this, we use a new constraint name that contains only lowercase letters and underscores.
const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check";
const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check";
const PUBLIC_KEY_THUMBPRINT_ALG_INDEX = "pki_acme_accounts_publicKey_thumbprint_alg_index";
export async function up(knex: Knex): Promise<void> {
// Create PkiAcmeEnrollmentConfig table
if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) {
await knex.schema.createTable(TableName.PkiAcmeEnrollmentConfig, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.binary("encryptedEabSecret").notNullable();
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig);
}
if (!(await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId"))) {
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
t.uuid("acmeConfigId");
t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL");
t.index("acmeConfigId");
});
}
await dropConstraintIfExists(TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex);
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) {
// Notice: it's okay to use `.checkIn(...).alter();` here because the constraint name is all lowercase.
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
t.string("enrollmentType")
.notNullable()
.checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT)
.alter();
});
}
// Create PkiAcmeAccount table
if (!(await knex.schema.hasTable(TableName.PkiAcmeAccount))) {
await knex.schema.createTable(TableName.PkiAcmeAccount, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
// Foreign key to PkiCertificateProfile
t.uuid("profileId").notNullable();
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("CASCADE");
// Multi-value emails array
t.specificType("emails", "text[]").notNullable();
// Public key (JWK format)
t.jsonb("publicKey").notNullable();
// Public key thumbprint
t.string("publicKeyThumbprint").notNullable();
// The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc.
t.string("alg").notNullable();
// We may need to look up existing accounts by public key thumbprint and algorithm, so we index on both of them.
t.index(["publicKeyThumbprint", "alg"], PUBLIC_KEY_THUMBPRINT_ALG_INDEX);
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeAccount);
}
// Create PkiAcmeOrder table
if (!(await knex.schema.hasTable(TableName.PkiAcmeOrder))) {
await knex.schema.createTable(TableName.PkiAcmeOrder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
// Foreign key to PkiAcmeAccount
t.uuid("accountId").notNullable();
t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE");
// Foreign key to certificate
t.uuid("certificateId").nullable();
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
t.timestamp("notBefore").nullable();
t.timestamp("notAfter").nullable();
t.timestamp("expiresAt").notNullable();
t.text("csr").nullable();
t.text("error").nullable();
// Order status
t.string("status").notNullable(); // pending, ready, processing, valid, invalid
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeOrder);
}
// Create PkiAcmeAuth table
if (!(await knex.schema.hasTable(TableName.PkiAcmeAuth))) {
await knex.schema.createTable(TableName.PkiAcmeAuth, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
// Foreign key to PkiAcmeAccount
t.uuid("accountId").notNullable();
t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE");
// Authorization status
t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked
// Token used to validate the authorization through ACME challenge
t.string("token").nullable();
// Identifier type and value
t.string("identifierType").notNullable(); // dns
t.string("identifierValue").notNullable(); // domain name
// Expiration timestamp
t.timestamp("expiresAt").notNullable();
// Optional link to issued certificate
t.uuid("certificateId").nullable();
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeAuth);
}
// Create PkiAcmeOrderAuth table
if (!(await knex.schema.hasTable(TableName.PkiAcmeOrderAuth))) {
await knex.schema.createTable(TableName.PkiAcmeOrderAuth, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
// Foreign key to PkiAcmeOrder
t.uuid("orderId").notNullable();
t.foreign("orderId").references("id").inTable(TableName.PkiAcmeOrder).onDelete("CASCADE");
// Foreign key to PkiAcmeAuth
t.uuid("authId").notNullable();
t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE");
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth);
}
// Create PkiAcmeChallenge table
if (!(await knex.schema.hasTable(TableName.PkiAcmeChallenge))) {
await knex.schema.createTable(TableName.PkiAcmeChallenge, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
// Foreign key to PkiAcmeAuth
t.uuid("authId").notNullable();
t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE");
// Challenge type
t.string("type").notNullable(); // http-01, dns-01, tls-alpn-01
// Challenge status
t.string("status").notNullable(); // pending, processing, valid, invalid
// Error message when the challenge fails
t.string("error").nullable();
// Validation timestamp
t.timestamp("validatedAt").nullable();
t.timestamps(true, true, true);
});
await createOnUpdateTrigger(knex, TableName.PkiAcmeChallenge);
}
}
export async function down(knex: Knex): Promise<void> {
// Drop tables in reverse dependency order
// Drop PkiAcmeChallenge first (depends on PkiAcmeAuth)
if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) {
await knex.schema.dropTable(TableName.PkiAcmeChallenge);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeChallenge);
}
// Drop PkiAcmeOrderAuth (depends on PkiAcmeOrder and PkiAcmeAuth)
if (await knex.schema.hasTable(TableName.PkiAcmeOrderAuth)) {
await knex.schema.dropTable(TableName.PkiAcmeOrderAuth);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth);
}
// Drop PkiAcmeAuth (depends on PkiAcmeAccount and Certificate)
if (await knex.schema.hasTable(TableName.PkiAcmeAuth)) {
await knex.schema.dropTable(TableName.PkiAcmeAuth);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeAuth);
}
// Drop PkiAcmeOrder (depends on PkiAcmeAccount)
if (await knex.schema.hasTable(TableName.PkiAcmeOrder)) {
await knex.schema.dropTable(TableName.PkiAcmeOrder);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrder);
}
// Drop PkiAcmeAccount (depends on PkiCertificateProfile)
if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) {
await knex.schema.dropTable(TableName.PkiAcmeAccount);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount);
}
// Change enrollmentType check constraint to allow acme
await dropConstraintIfExists(TableName.PkiCertificateProfile, NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex);
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) {
// Notice: DO NOT USE
//
// `t.string("enrollmentType").checkIn(["api", "est"], OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter();`
//
// here because knex will generate a constraint name without quotes, and it will be treated as lowercased and causing problems.
await knex.raw(
`ALTER TABLE ??
ADD CONSTRAINT ?? CHECK (?? IN ('api', 'est'));
`,
[TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, "enrollmentType"]
);
}
// Drop acmeConfigId column
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) {
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
t.dropForeign(["acmeConfigId"]);
t.dropIndex("acmeConfigId");
t.dropColumn("acmeConfigId");
});
}
// Drop PkiAcmeEnrollmentConfig
if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) {
await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig);
await dropOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig);
}
}
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasTable(TableName.PkiCertificateTemplateV2)) {
await knex.schema.alterTable(TableName.PkiCertificateTemplateV2, (t) => {
t.dropForeign(["projectId"]);
t.foreign("projectId").references("id").inTable(TableName.Project);
});
}
}
@@ -3,4 +3,4 @@ import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
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]);
+2 -1
View File
@@ -14,7 +14,8 @@ export const IdentitiesSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
hasDeleteProtection: z.boolean().default(false),
orgId: z.string().uuid()
orgId: z.string().uuid(),
projectId: z.string().nullable().optional()
});
export type TIdentities = z.infer<typeof IdentitiesSchema>;
+10
View File
@@ -92,7 +92,17 @@ export * from "./pam-accounts";
export * from "./pam-folders";
export * from "./pam-resources";
export * from "./pam-sessions";
export * from "./pki-acme-accounts";
export * from "./pki-acme-auths";
export * from "./pki-acme-challenges";
export * from "./pki-acme-enrollment-configs";
export * from "./pki-acme-order-auths";
export * from "./pki-acme-orders";
export * from "./pki-alert-channels";
export * from "./pki-alert-history";
export * from "./pki-alert-history-certificate";
export * from "./pki-alerts";
export * from "./pki-alerts-v2";
export * from "./pki-api-enrollment-configs";
export * from "./pki-certificate-profiles";
export * from "./pki-certificate-templates-v2";
+13 -1
View File
@@ -27,8 +27,13 @@ export enum TableName {
PkiCertificateProfile = "pki_certificate_profiles",
PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
PkiSubscriber = "pki_subscribers",
PkiAlert = "pki_alerts",
PkiAlertsV2 = "pki_alerts_v2",
PkiAlertChannels = "pki_alert_channels",
PkiAlertHistory = "pki_alert_history",
PkiAlertHistoryCertificate = "pki_alert_history_certificate",
PkiCollection = "pki_collections",
PkiCollectionItem = "pki_collection_items",
Groups = "groups",
@@ -210,7 +215,14 @@ export enum TableName {
PamAccount = "pam_accounts",
PamSession = "pam_sessions",
VaultExternalMigrationConfig = "vault_external_migration_configs"
VaultExternalMigrationConfig = "vault_external_migration_configs",
// PKI ACME
PkiAcmeAccount = "pki_acme_accounts",
PkiAcmeOrder = "pki_acme_orders",
PkiAcmeOrderAuth = "pki_acme_order_auths",
PkiAcmeAuth = "pki_acme_auths",
PkiAcmeChallenge = "pki_acme_challenges"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeAccountsSchema = z.object({
id: z.string().uuid(),
profileId: z.string().uuid(),
emails: z.string().array(),
publicKey: z.unknown(),
publicKeyThumbprint: z.string(),
alg: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeAccounts = z.infer<typeof PkiAcmeAccountsSchema>;
export type TPkiAcmeAccountsInsert = Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>;
export type TPkiAcmeAccountsUpdate = Partial<Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>>;
+25
View File
@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeAuthsSchema = z.object({
id: z.string().uuid(),
accountId: z.string().uuid(),
status: z.string(),
token: z.string().nullable().optional(),
identifierType: z.string(),
identifierValue: z.string(),
expiresAt: z.date(),
certificateId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
export type TPkiAcmeAuthsInsert = Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>;
export type TPkiAcmeAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>>;
@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeChallengesSchema = z.object({
id: z.string().uuid(),
authId: z.string().uuid(),
type: z.string(),
status: z.string(),
error: z.string().nullable().optional(),
validatedAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeChallenges = z.infer<typeof PkiAcmeChallengesSchema>;
export type TPkiAcmeChallengesInsert = Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>;
export type TPkiAcmeChallengesUpdate = Partial<Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>>;
@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { zodBuffer } from "@app/lib/zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeEnrollmentConfigsSchema = z.object({
id: z.string().uuid(),
encryptedEabSecret: zodBuffer,
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeEnrollmentConfigs = z.infer<typeof PkiAcmeEnrollmentConfigsSchema>;
export type TPkiAcmeEnrollmentConfigsInsert = Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>;
export type TPkiAcmeEnrollmentConfigsUpdate = Partial<
Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>
>;
@@ -0,0 +1,20 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeOrderAuthsSchema = z.object({
id: z.string().uuid(),
orderId: z.string().uuid(),
authId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeOrderAuths = z.infer<typeof PkiAcmeOrderAuthsSchema>;
export type TPkiAcmeOrderAuthsInsert = Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>;
export type TPkiAcmeOrderAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>>;
+26
View File
@@ -0,0 +1,26 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAcmeOrdersSchema = z.object({
id: z.string().uuid(),
accountId: z.string().uuid(),
certificateId: z.string().uuid().nullable().optional(),
notBefore: z.date().nullable().optional(),
notAfter: z.date().nullable().optional(),
expiresAt: z.date(),
csr: z.string().nullable().optional(),
error: z.string().nullable().optional(),
status: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAcmeOrders = z.infer<typeof PkiAcmeOrdersSchema>;
export type TPkiAcmeOrdersInsert = Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>;
export type TPkiAcmeOrdersUpdate = Partial<Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>>;
@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAlertChannelsSchema = z.object({
id: z.string().uuid(),
alertId: z.string().uuid(),
channelType: z.string(),
config: z.unknown(),
enabled: z.boolean().default(true).nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAlertChannels = z.infer<typeof PkiAlertChannelsSchema>;
export type TPkiAlertChannelsInsert = Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>;
export type TPkiAlertChannelsUpdate = Partial<Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>>;
@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAlertHistoryCertificateSchema = z.object({
id: z.string().uuid(),
alertHistoryId: z.string().uuid(),
certificateId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAlertHistoryCertificate = z.infer<typeof PkiAlertHistoryCertificateSchema>;
export type TPkiAlertHistoryCertificateInsert = Omit<
z.input<typeof PkiAlertHistoryCertificateSchema>,
TImmutableDBKeys
>;
export type TPkiAlertHistoryCertificateUpdate = Partial<
Omit<z.input<typeof PkiAlertHistoryCertificateSchema>, TImmutableDBKeys>
>;
@@ -0,0 +1,22 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAlertHistorySchema = z.object({
id: z.string().uuid(),
alertId: z.string().uuid(),
triggeredAt: z.date().nullable().optional(),
hasNotificationSent: z.boolean().default(false).nullable().optional(),
notificationError: z.string().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAlertHistory = z.infer<typeof PkiAlertHistorySchema>;
export type TPkiAlertHistoryInsert = Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>;
export type TPkiAlertHistoryUpdate = Partial<Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>>;
+25
View File
@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const PkiAlertsV2Schema = z.object({
id: z.string().uuid(),
name: z.string(),
description: z.string().nullable().optional(),
eventType: z.string(),
alertBefore: z.string().nullable().optional(),
filters: z.unknown().nullable().optional(),
enabled: z.boolean().default(true).nullable().optional(),
projectId: z.string(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TPkiAlertsV2 = z.infer<typeof PkiAlertsV2Schema>;
export type TPkiAlertsV2Insert = Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>;
export type TPkiAlertsV2Update = Partial<Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>>;
@@ -18,7 +18,8 @@ export const PkiCertificateProfilesSchema = z.object({
estConfigId: z.string().uuid().nullable().optional(),
apiConfigId: z.string().uuid().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
acmeConfigId: z.string().uuid().nullable().optional()
});
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
@@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({
isSecretRequestNotificationEnabled: z.boolean().default(false),
secretRequestChannels: z.string().default(""),
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
isSecretSyncErrorNotificationEnabled: z.boolean().default(false),
secretSyncErrorChannels: z.string().default("")
});
export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>;
+2
View File
@@ -30,6 +30,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 +108,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => {
await server.register(
async (pkiRouter) => {
await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" });
await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" });
},
{ prefix: "/pki" }
);
+451
View File
@@ -0,0 +1,451 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import {
AcmeOrderResourceSchema,
CreateAcmeAccountResponseSchema,
CreateAcmeOrderBodySchema,
DeactivateAcmeAccountBodySchema,
DeactivateAcmeAccountResponseSchema,
FinalizeAcmeOrderBodySchema,
GetAcmeAuthorizationResponseSchema,
GetAcmeDirectoryResponseSchema,
ListAcmeOrdersPayloadSchema,
ListAcmeOrdersResponseSchema,
RawJwsPayloadSchema,
RespondToAcmeChallengeBodySchema,
RespondToAcmeChallengeResponseSchema
} from "@app/ee/services/pki-acme/pki-acme-schemas";
import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
const SharedParamsSchema = z.object({
profileId: z.string().uuid()
});
export interface MyRequestInterface {
Params: { profileId: string; accountId?: string };
Body: TRawJwsPayload;
}
export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => {
const validateExistingAccount = async <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
R extends FastifyRequest<any>,
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer U> ? U : string
>({
req,
schema
}: {
req: R;
schema?: TSchema;
}): Promise<TAuthenciatedJwsPayload<T>> => {
return server.services.pkiAcme.validateExistingAccountJwsPayload({
url: new URL(req.url, `${req.protocol}://${req.hostname}`),
profileId: (req.params as { profileId: string }).profileId,
rawJwsPayload: req.body as TRawJwsPayload,
schema,
expectedAccountId: (req.params as { accountId?: string }).accountId
});
};
const sendAcmeResponse = async <T>(res: FastifyReply, profileId: string, response: TAcmeResponse<T>): Promise<T> => {
res.code(response.status);
for (const [key, value] of Object.entries(response.headers)) {
res.header(key, value);
}
const nonce = await server.services.pkiAcme.getAcmeNewNonce(profileId);
res.header("Replay-Nonce", nonce);
res.header("Cache-Control", "no-store");
return response.body;
};
server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => {
try {
const strBody = body instanceof Buffer ? body.toString() : body;
if (!strBody) {
done(null, undefined);
}
const json: unknown = JSON.parse(strBody);
done(null, json);
} catch (err) {
const error = err as Error;
done(error, undefined);
}
});
// GET /api/v1/pki/acme/profiles/<profile_id>/directory
// Directory (RFC 8555 Section 7.1.1)
server.route({
method: "GET",
url: "/profiles/:profileId/directory",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Directory - provides URLs for the client to make API calls to",
params: z.object({
profileId: z.string().uuid()
}),
response: {
200: GetAcmeDirectoryResponseSchema
}
},
handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId)
});
// HEAD /api/v1/pki/acme/profiles/<profile_id>/new-nonce
// New Nonce (RFC 8555 Section 7.2)
server.route({
method: "HEAD",
url: "/profiles/:profileId/new-nonce",
config: {
// TODO: probably a different rate limit for nonce creation?
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header",
params: z.object({
profileId: z.string().uuid()
}),
response: {
200: z.string().length(0)
}
},
handler: async (req, res) => {
const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId);
res.header("Replay-Nonce", nonce);
return "";
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/new-account
// New Account (RFC 8555 Section 7.3)
server.route({
method: "POST",
url: "/profiles/:profileId/new-account",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Account - register a new account or find existing one",
params: SharedParamsSchema,
body: RawJwsPayloadSchema,
response: {
201: CreateAcmeAccountResponseSchema
}
},
handler: async (req, res) => {
const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({
url: new URL(req.url, `${req.protocol}://${req.hostname}`),
rawJwsPayload: req.body
});
const { alg, jwk } = protectedHeader;
return sendAcmeResponse(
res,
req.params.profileId,
await server.services.pkiAcme.createAcmeAccount({
profileId: req.params.profileId,
alg,
jwk: jwk!,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/accounts/<account_id>
// Account Deactivation (RFC 8555 Section 7.3.6)
server.route({
method: "POST",
url: "/profiles/:profileId/accounts/:accountId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Account Deactivation",
params: SharedParamsSchema.extend({
accountId: z.string()
}),
body: RawJwsPayloadSchema,
response: {
200: DeactivateAcmeAccountResponseSchema
}
},
handler: async (req, res) => {
const { payload, profileId, accountId } = await validateExistingAccount({
req,
schema: DeactivateAcmeAccountBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.deactivateAcmeAccount({
profileId,
accountId,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/new-order
// New Certificate Order (RFC 8555 Section 7.4)
server.route({
method: "POST",
url: "/profiles/:profileId/new-order",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME New Order - apply for a new certificate",
params: SharedParamsSchema,
body: RawJwsPayloadSchema,
response: {
201: AcmeOrderResourceSchema
}
},
handler: async (req, res) => {
const { profileId, accountId, payload } = await validateExistingAccount({
req,
schema: CreateAcmeOrderBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.createAcmeOrder({
profileId,
accountId,
payload
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/orders/<order_id>
// Get Order (RFC 8555 Section 7.1.3)
server.route({
method: "POST",
url: "/profiles/:profileId/orders/:orderId",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Get Order - return status and details of the order",
params: SharedParamsSchema.extend({
orderId: z.string().uuid()
}),
body: RawJwsPayloadSchema,
response: {
200: AcmeOrderResourceSchema
}
},
handler: async (req, res) => {
const { profileId, accountId } = await validateExistingAccount({
req
});
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 } = await validateExistingAccount({
req
});
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 } = await validateExistingAccount({ req });
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.getAcmeAuthorization({
profileId,
accountId,
authzId: req.params.authzId
})
);
}
});
// POST /api/v1/pki/acme/profiles/<profile_id>/authorizations/<authz_id>/challenges/<challenge_id>
// Respond to Challenge (RFC 8555 Section 7.5.1)
server.route({
method: "POST",
url: "/profiles/:profileId/authorizations/:authzId/challenges/:challengeId",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiAcme],
description: "ACME Respond to Challenge - let ACME server know challenge is ready",
params: SharedParamsSchema.extend({
authzId: z.string().uuid(),
challengeId: z.string().uuid()
}),
response: {
200: RespondToAcmeChallengeResponseSchema
}
},
handler: async (req, res) => {
const { profileId, accountId } = await validateExistingAccount({
req,
schema: RespondToAcmeChallengeBodySchema
});
return sendAcmeResponse(
res,
profileId,
await server.services.pkiAcme.respondToAcmeChallenge({
profileId,
accountId,
authzId: req.params.authzId,
challengeId: req.params.challengeId
})
);
}
});
};
@@ -315,6 +315,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
.extend({
status: z.string(),
comment: z.string().optional(),
createdAt: z.date(),
isOrgMembershipActive: z.boolean().nullable().optional()
})
.array(),
+3 -3
View File
@@ -42,7 +42,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => {
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { organization } = await server.services.subOrganization.createSubOrg({
name: req.body.name,
@@ -95,7 +95,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => {
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { organizations } = await server.services.subOrganization.listSubOrgs({
permissionActor: req.permission,
@@ -137,7 +137,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => {
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { organization } = await server.services.subOrganization.updateSubOrg({
subOrgId: req.params.subOrgId,
@@ -1,14 +1,14 @@
import { ForbiddenError } from "@casl/ability";
import { AccessScope, ActionProjectType } from "@app/db/schemas";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal";
import { TMembershipUserDALFactory } from "@app/services/membership-user/membership-user-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalRequestDALFactory } from "../access-approval-request/access-approval-request-dal";
@@ -38,13 +38,13 @@ type TAccessApprovalPolicyServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">;
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
groupDAL: TGroupDALFactory;
userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find" | "resetReviewByPolicyId">;
additionalPrivilegeDAL: Pick<TAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory;
membershipUserDAL: TMembershipUserDALFactory;
};
export const accessApprovalPolicyServiceFactory = ({
@@ -60,7 +60,7 @@ export const accessApprovalPolicyServiceFactory = ({
accessApprovalRequestDAL,
additionalPrivilegeDAL,
accessApprovalRequestReviewerDAL,
membershipUserDAL
projectMembershipDAL
}: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => {
const $policyExists = async ({
envId,
@@ -83,6 +83,21 @@ export const accessApprovalPolicyServiceFactory = ({
return policyId ? policy && policy.id !== policyId : Boolean(policy);
};
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const createAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["createAccessApprovalPolicy"] = async ({
name,
actor,
@@ -160,7 +175,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) {
throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}`
message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
});
}
@@ -171,6 +186,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
project.orgId,
project.id
);
}
let groupBypassers: string[] = [];
let bypasserUserIds: string[] = [];
@@ -257,6 +280,8 @@ export const accessApprovalPolicyServiceFactory = ({
}
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, project.orgId, project.id);
await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -420,23 +445,6 @@ export const accessApprovalPolicyServiceFactory = ({
bypasserUserIds = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))];
}
// Validate user bypassers
if (bypasserUserIds.length > 0) {
const orgMemberships = await membershipUserDAL.find({
$in: { actorUserId: bypasserUserIds },
scopeOrgId: actorOrgId,
scope: AccessScope.Organization
});
if (orgMemberships.length !== bypasserUserIds.length) {
const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.actorUserId as string));
const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id));
throw new BadRequestError({
message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}`
});
}
}
// Validate group bypassers
if (groupBypassers.length > 0) {
const orgGroups = await groupDAL.find({
@@ -487,7 +495,7 @@ export const accessApprovalPolicyServiceFactory = ({
if (invalidUsernames.length) {
throw new BadRequestError({
message: `Invalid approver user: ${invalidUsernames.join(", ")}`
message: `Invalid approver user: ${invalidUsernames.map((i) => i.username).join(", ")}`
});
}
@@ -498,6 +506,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
if (approverUserIds.length > 0) {
await verifyProjectUserMembership(
approverUserIds.map((au) => au.id),
actorOrgId,
accessApprovalPolicy.projectId
);
}
await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({
approverUserId: el.id,
@@ -536,6 +552,8 @@ export const accessApprovalPolicyServiceFactory = ({
await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, accessApprovalPolicy.projectId);
await accessApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({
);
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({
type: TriggerFeature.ACCESS_REQUEST,
payload: {
projectName: project.name,
projectPath,
requesterFullName,
isTemporary,
requesterEmail: requestedByUser.email as string,
@@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({
const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`;
const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`;
const approvalPath = `/projects/secret-management/${project.id}/approval`;
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
@@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({
approvalUrl,
editNote,
editorEmail: editedByUser.email as string,
editorFullName
editorFullName,
projectPath
}
},
projectId: project.id
@@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types";
import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types";
import { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
import {
@@ -158,9 +159,22 @@ export enum EventType {
DELETE_TRUSTED_IP = "delete-trusted-ip",
CREATE_SERVICE_TOKEN = "create-service-token", // v2
DELETE_SERVICE_TOKEN = "delete-service-token", // v2
CREATE_SUB_ORGANIZATION = "create-sub-organization",
UPDATE_SUB_ORGANIZATION = "update-sub-organization",
CREATE_IDENTITY = "create-identity",
UPDATE_IDENTITY = "update-identity",
DELETE_IDENTITY = "delete-identity",
CREATE_IDENTITY_ORG_MEMBERSHIP = "create-identity-org-membership",
UPDATE_IDENTITY_ORG_MEMBERSHIP = "update-identity-org-membership",
DELETE_IDENTITY_ORG_MEMBERSHIP = "delete-identity-org-membership",
CREATE_IDENTITY_PROJECT_MEMBERSHIP = "create-identity-project-membership",
UPDATE_IDENTITY_PROJECT_MEMBERSHIP = "update-identity-project-membership",
DELETE_IDENTITY_PROJECT_MEMBERSHIP = "delete-identity-project-membership",
MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE = "machine-identity-auth-template-create",
MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE = "machine-identity-auth-template-update",
MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE = "machine-identity-auth-template-delete",
@@ -173,9 +187,6 @@ export enum EventType {
UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth",
GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth",
CREATE_SUB_ORGANIZATION = "create-sub-organization",
UPDATE_SUB_ORGANIZATION = "update-sub-organization",
ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth",
UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth",
GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth",
@@ -322,6 +333,7 @@ export enum EventType {
GET_CERT_BODY = "get-cert-body",
GET_CERT_PRIVATE_KEY = "get-cert-private-key",
GET_CERT_BUNDLE = "get-cert-bundle",
EXPORT_CERT_PKCS12 = "export-cert-pkcs12",
CREATE_PKI_ALERT = "create-pki-alert",
GET_PKI_ALERT = "get-pki-alert",
UPDATE_PKI_ALERT = "update-pki-alert",
@@ -370,6 +382,7 @@ export enum EventType {
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
RENEW_CERTIFICATE = "renew-certificate",
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
@@ -888,6 +901,7 @@ interface CreateIdentityEvent {
identityId: string;
name: string;
hasDeleteProtection: boolean;
metadata?: { key: string; value: string }[];
};
}
@@ -897,6 +911,7 @@ interface UpdateIdentityEvent {
identityId: string;
name?: string;
hasDeleteProtection?: boolean;
metadata?: { key: string; value: string }[];
};
}
@@ -1498,6 +1513,52 @@ interface ClearIdentityLdapAuthLockoutsEvent {
};
}
interface CreateIdentityOrgMembershipEvent {
type: EventType.CREATE_IDENTITY_ORG_MEMBERSHIP;
metadata: {
identityId: string;
roles: unknown;
};
}
interface UpdateIdentityOrgMembershipEvent {
type: EventType.UPDATE_IDENTITY_ORG_MEMBERSHIP;
metadata: {
identityId: string;
roles?: unknown;
};
}
interface DeleteIdentityOrgMembershipEvent {
type: EventType.DELETE_IDENTITY_ORG_MEMBERSHIP;
metadata: {
identityId: string;
};
}
interface CreateIdentityProjectMembershipEvent {
type: EventType.CREATE_IDENTITY_PROJECT_MEMBERSHIP;
metadata: {
identityId: string;
roles: unknown;
};
}
interface UpdateIdentityProjectMembershipEvent {
type: EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP;
metadata: {
identityId: string;
roles?: unknown;
};
}
interface DeleteIdentityProjectMembershipEvent {
type: EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP;
metadata: {
identityId: string;
};
}
interface LoginIdentityOidcAuthEvent {
type: EventType.LOGIN_IDENTITY_OIDC_AUTH;
metadata: {
@@ -2313,15 +2374,24 @@ interface GetCertBundle {
serialNumber: string;
};
}
interface GetCertPkcs12 {
type: EventType.EXPORT_CERT_PKCS12;
metadata: {
certId: string;
cn: string;
serialNumber: string;
};
}
interface CreatePkiAlert {
type: EventType.CREATE_PKI_ALERT;
metadata: {
pkiAlertId: string;
pkiCollectionId: string;
pkiCollectionId?: string;
name: string;
alertBeforeDays: number;
recipientEmails: string;
alertBefore: string;
eventType: PkiAlertEventType;
recipientEmails?: string;
};
}
interface GetPkiAlert {
@@ -2337,7 +2407,8 @@ interface UpdatePkiAlert {
pkiAlertId: string;
pkiCollectionId?: string;
name?: string;
alertBeforeDays?: number;
alertBefore?: string;
eventType?: PkiAlertEventType;
recipientEmails?: string;
};
}
@@ -2749,6 +2820,17 @@ interface OrderCertificateFromProfile {
};
}
interface GetCertificateProfileLatestActiveBundle {
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE;
metadata: {
certificateProfileId: string;
certificateId: string;
commonName: string;
profileName: string;
serialNumber: string;
};
}
interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE;
metadata: {
@@ -4173,6 +4255,12 @@ export type Event =
| GetIdentityLdapAuthEvent
| RevokeIdentityLdapAuthEvent
| ClearIdentityLdapAuthLockoutsEvent
| CreateIdentityOrgMembershipEvent
| UpdateIdentityOrgMembershipEvent
| DeleteIdentityOrgMembershipEvent
| CreateIdentityProjectMembershipEvent
| UpdateIdentityProjectMembershipEvent
| DeleteIdentityProjectMembershipEvent
| CreateEnvironmentEvent
| GetEnvironmentEvent
| UpdateEnvironmentEvent
@@ -4237,6 +4325,7 @@ export type Event =
| GetCertBody
| GetCertPrivateKey
| GetCertBundle
| GetCertPkcs12
| CreatePkiAlert
| GetPkiAlert
| UpdatePkiAlert
@@ -4279,6 +4368,7 @@ export type Event =
| DeleteCertificateProfile
| GetCertificateProfile
| ListCertificateProfiles
| GetCertificateProfileLatestActiveBundle
| IssueCertificateFromProfile
| SignCertificateFromProfile
| OrderCertificateFromProfile
@@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
secretsLimit: 40
},
pkiEst: false,
pkiAcme: false,
enforceMfa: false,
projectTemplates: false,
kmip: false,
@@ -78,6 +78,7 @@ export type TFeatureSet = {
secretsLimit: number;
};
pkiEst: boolean;
pkiAcme: false;
enforceMfa: boolean;
projectTemplates: false;
kmip: false;
@@ -104,8 +104,7 @@ const makeSqlConnection = (
// (like being able to do an auth handshake regardless pass or not)
if (
connectOnly &&
(error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` ||
error.message.includes("no pg_hba.conf entry for host"))
error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`
) {
return;
}
@@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => {
ProjectPermissionCertificateProfileActions.Edit,
ProjectPermissionCertificateProfileActions.Create,
ProjectPermissionCertificateProfileActions.Delete,
ProjectPermissionCertificateProfileActions.IssueCert
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret,
ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret
],
ProjectPermissionSub.CertificateProfiles
);
@@ -169,7 +171,11 @@ const buildAdminPermissionRules = () => {
ProjectPermissionIdentityActions.Delete,
ProjectPermissionIdentityActions.Read,
ProjectPermissionIdentityActions.GrantPrivileges,
ProjectPermissionIdentityActions.AssumePrivileges
ProjectPermissionIdentityActions.AssumePrivileges,
ProjectPermissionIdentityActions.GetToken,
ProjectPermissionIdentityActions.CreateToken,
ProjectPermissionIdentityActions.DeleteToken,
ProjectPermissionIdentityActions.RevokeAuth
],
ProjectPermissionSub.Identity
);
@@ -204,9 +204,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
.on(`${TableName.IdentityMetadata}.userId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
} else if (actorType === ActorType.IDENTITY) {
void queryBuilder
.on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
void queryBuilder.on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId]));
}
})
.where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId)
@@ -667,9 +665,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
})
.join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`)
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`)
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`);
})
.where(`${TableName.Membership}.scopeOrgId`, orgId)
.whereNotNull(`${TableName.Membership}.actorIdentityId`)
@@ -196,7 +196,7 @@ export const permissionServiceFactory = ({
}
if (orgId !== actorOrgId) {
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
throw new ForbiddenRequestError({ name: "You are not allowed to access organization resource" });
}
const permissionData = await permissionDAL.getPermission({
@@ -344,7 +344,7 @@ export const permissionServiceFactory = ({
});
if (projectDetails.orgId !== actorOrgId) {
throw new ForbiddenRequestError({ name: "You are not logged into this organization" });
throw new ForbiddenRequestError({ name: "This project does not belong to your selected organization." });
}
if (actionProjectType !== ActionProjectType.Any && actionProjectType !== projectDetails.type) {
@@ -362,7 +362,7 @@ export const permissionServiceFactory = ({
actorId,
actorType: actor
});
if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" });
if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this project" });
const permissionFromRoles = permissionData.flatMap((membership) => {
const activeRoles = membership?.roles
@@ -65,7 +65,11 @@ export enum ProjectPermissionIdentityActions {
Edit = "edit",
Delete = "delete",
GrantPrivileges = "grant-privileges",
AssumePrivileges = "assume-privileges"
AssumePrivileges = "assume-privileges",
RevokeAuth = "revoke-auth",
CreateToken = "create-token",
GetToken = "get-token",
DeleteToken = "delete-token"
}
export enum ProjectPermissionMemberActions {
@@ -116,7 +120,9 @@ export enum ProjectPermissionCertificateProfileActions {
Create = "create",
Edit = "edit",
Delete = "delete",
IssueCert = "issue-cert"
IssueCert = "issue-cert",
RevealAcmeEabSecret = "reveal-acme-eab-secret",
RotateAcmeEabSecret = "rotate-acme-eab-secret"
}
export enum ProjectPermissionSecretSyncActions {
@@ -0,0 +1,42 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
export type TPkiAcmeAccountDALFactory = ReturnType<typeof pkiAcmeAccountDALFactory>;
export const pkiAcmeAccountDALFactory = (db: TDbClient) => {
const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount);
const findByProjectIdAndAccountId = async (profileId: string, id: string, tx?: Knex) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by id" });
}
};
const findByProfileIdAndPublicKeyThumbprintAndAlg = async (
profileId: string,
alg: string,
publicKeyThumbprint: string,
tx?: Knex
) => {
try {
const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKeyThumbprint }).first();
return account || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME account by profile id, public key thumbprint and alg" });
}
};
return {
...pkiAcmeAccountOrm,
findByProjectIdAndAccountId,
findByProfileIdAndPublicKeyThumbprintAndAlg
};
};
@@ -0,0 +1,54 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPkiAcmeAuthDALFactory = ReturnType<typeof pkiAcmeAuthDALFactory>;
export const pkiAcmeAuthDALFactory = (db: TDbClient) => {
const pkiAcmeAuthOrm = ormify(db, TableName.PkiAcmeAuth);
const findByAccountIdAndAuthIdWithChallenges = async (accountId: string, authId: string, tx?: Knex) => {
try {
const rows = await (tx || db)(TableName.PkiAcmeAuth)
.leftJoin(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeAuth),
db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"),
db.ref("type").withSchema(TableName.PkiAcmeChallenge).as("challengeType"),
db.ref("status").withSchema(TableName.PkiAcmeChallenge).as("challengeStatus")
)
.where(`${TableName.PkiAcmeAuth}.accountId`, accountId)
.where(`${TableName.PkiAcmeAuth}.id`, authId);
if (rows.length === 0) {
return null;
}
return sqlNestRelationships({
data: rows,
key: "id",
parentMapper: (row) => row,
childrenMapper: [
{
key: "challengeId",
label: "challenges" as const,
mapper: ({ challengeId, challengeType, challengeStatus }) => ({
id: challengeId,
type: challengeType,
status: challengeStatus
})
}
]
})?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME auth by account id and auth id with challenges" });
}
};
return {
...pkiAcmeAuthOrm,
findByAccountIdAndAuthIdWithChallenges
};
};
@@ -0,0 +1,177 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TPkiAcmeChallenges } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { AcmeAuthStatus, AcmeChallengeStatus, AcmeOrderStatus } from "./pki-acme-schemas";
export type TPkiAcmeChallengeDALFactory = ReturnType<typeof pkiAcmeChallengeDALFactory>;
export const pkiAcmeChallengeDALFactory = (db: TDbClient) => {
const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge);
const markAsValidCascadeById = async (id: string, tx?: Knex): Promise<TPkiAcmeChallenges> => {
try {
const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge)
.where({ id })
.update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() })
.returning("*")) as [TPkiAcmeChallenges];
// Update pending auth to valid as well
const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth)
.where({ id: challenge.authId, status: AcmeAuthStatus.Pending })
.update({ status: AcmeAuthStatus.Valid })
.returning("id");
if (updatedAuths.length > 0) {
// Find all the orders that are involved in the challenge validation
const involvedOrderIds = (tx || db)({ o: TableName.PkiAcmeOrder })
.distinct("o.id")
.join({ oa: TableName.PkiAcmeOrderAuth }, "o.id", "oa.orderId")
.join({ a: TableName.PkiAcmeAuth }, "oa.authId", `a.id`)
.whereIn(
"a.id",
updatedAuths.map((auth) => auth.id)
);
// Update status for pending orders that have all auths valid
await (tx || db)(TableName.PkiAcmeOrder)
.whereIn("id", (qb) => {
void qb
.select("o2.id")
.from({ o2: TableName.PkiAcmeOrder })
.join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId")
.join({ a2: TableName.PkiAcmeAuth }, "oa2.authId", "a2.id")
.groupBy("o2.id")
// All auths should be valid for the order to be ready
.havingRaw("SUM(CASE WHEN a2.status = ? THEN 1 ELSE 0 END) = COUNT(DISTINCT a2.id)", [
AcmeAuthStatus.Valid
])
// Only update orders that are pending
.where("o2.status", AcmeOrderStatus.Pending)
.whereIn("o2.id", involvedOrderIds);
})
.update({ status: AcmeOrderStatus.Ready });
}
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
};
const markAsInvalidCascadeById = async (id: string, tx?: Knex): Promise<TPkiAcmeChallenges> => {
try {
const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge)
.where({ id })
.update({ status: AcmeChallengeStatus.Invalid })
.returning("*")) as [TPkiAcmeChallenges];
// Update pending auth to valid as well
const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth)
.where({ id: challenge.authId, status: AcmeAuthStatus.Pending })
.update({ status: AcmeAuthStatus.Invalid })
.returning("id");
if (updatedAuths.length > 0) {
// Update status for pending orders that have all auths valid
await (tx || db)(TableName.PkiAcmeOrder)
.whereIn("id", (qb) => {
void qb
.select("o.id")
.from({ o: TableName.PkiAcmeOrder })
.join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`)
// We only update orders that are pending
.where("o.status", AcmeOrderStatus.Pending)
.whereIn(
`${TableName.PkiAcmeAuth}.id`,
updatedAuths.map((auth) => auth.id)
);
})
.update({ status: AcmeOrderStatus.Invalid });
}
// TODO: update order status to invalid as well
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Update certificate profile" });
}
};
const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => {
try {
const challenge = await (tx || db)(TableName.PkiAcmeChallenge)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(selectAllTableCols(TableName.PkiAcmeChallenge))
.where(`${TableName.PkiAcmeChallenge}.id`, challengeId)
.where(`${TableName.PkiAcmeChallenge}.authId`, authId)
.where(`${TableName.PkiAcmeAuth}.accountId`, accountId)
.first();
if (!challenge) {
return null;
}
return challenge;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" });
}
};
const findByIdForChallengeValidation = async (id: string, tx?: Knex) => {
const result = await (tx || db)(TableName.PkiAcmeChallenge)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`)
.join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeChallenge),
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
db.ref("token").withSchema(TableName.PkiAcmeAuth).as("authToken"),
db.ref("status").withSchema(TableName.PkiAcmeAuth).as("authStatus"),
db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("authIdentifierType"),
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"),
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"),
db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"),
db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint")
)
// For all challenges, acquire update lock on the auth to avoid race conditions
.forUpdate(TableName.PkiAcmeAuth)
.where(`${TableName.PkiAcmeChallenge}.id`, id)
.first();
if (!result) {
return null;
}
const {
authId,
authToken,
authStatus,
authIdentifierType,
authIdentifierValue,
authExpiresAt,
accountId,
accountPublicKeyThumbprint,
...challenge
} = result;
return {
...challenge,
auth: {
token: authToken,
status: authStatus,
identifierType: authIdentifierType,
identifierValue: authIdentifierValue,
expiresAt: authExpiresAt,
account: {
id: accountId,
publicKeyThumbprint: accountPublicKeyThumbprint
}
}
};
};
return {
...pkiAcmeChallengeOrm,
markAsValidCascadeById,
markAsInvalidCascadeById,
findByAccountAuthAndChallengeId,
findByIdForChallengeValidation
};
};
@@ -0,0 +1,137 @@
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 AcmeIncorrectResponseError({
message: `ACME challenge response is not 200: ${challengeResponse.status}`
});
}
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)" });
}
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
} else if (exp instanceof DOMException) {
if (exp.name === "TimeoutError") {
logger.error(exp, "Connection timed out while validating ACME challenge response");
return new AcmeConnectionError({ message: "Connection timed out" });
}
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
} else if (exp instanceof Error) {
logger.error(exp, "Error validating ACME challenge response");
} else {
logger.error(exp, "Unknown error validating ACME challenge response");
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
}
return exp;
}
});
if (error) {
throw error;
}
};
return { validateChallengeResponse };
};
@@ -0,0 +1,512 @@
/**
* 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;
message: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
}
export class AcmeError extends Error implements IAcmeError {
type: AcmeErrorType;
message: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
constructor({
type,
message,
status,
subproblems,
error
}: {
type: AcmeErrorType;
message: string;
status: number;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
}) {
super(message);
this.type = type;
this.message = message;
this.status = status;
this.subproblems = subproblems;
this.error = error;
this.name = "AcmeError";
}
toAcmeResponse(): IAcmeError {
return {
type: this.type,
message: this.message,
status: this.status,
subproblems: this.subproblems
};
}
}
/**
* malformed - The request message was malformed (RFC 8555 Section 6.7.1)
*/
export class AcmeMalformedError extends AcmeError {
constructor({
message = "The request message was malformed",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Malformed,
message,
status: 400,
error
});
this.name = "AcmeMalformedError";
}
}
/**
* unauthorized - The client lacks sufficient authorization (RFC 8555 Section 6.7.2)
*/
export class AcmeUnauthorizedError extends AcmeError {
constructor({
message = "The client lacks sufficient authorization",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Unauthorized,
message,
status: 403,
error
});
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({
message = "The request specified an account that does not exist",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.AccountDoesNotExist,
message,
status: 404,
error
});
this.name = "AcmeAccountDoesNotExistError";
}
}
/**
* badNonce - The client sent an unacceptable anti-replay nonce (RFC 8555 Section 6.7.4)
*/
export class AcmeBadNonceError extends AcmeError {
constructor({
message = "The client sent an unacceptable anti-replay nonce",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadNonce,
message,
status: 400,
error
});
this.name = "AcmeBadNonceError";
}
}
/**
* badSignatureAlgorithm - The signature algorithm is invalid (RFC 8555 Section 6.7.5)
*/
export class AcmeBadSignatureAlgorithmError extends AcmeError {
constructor({
message = "The signature algorithm is invalid",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadSignatureAlgorithm,
message,
status: 401,
error
});
this.name = "AcmeBadSignatureAlgorithmError";
}
}
/**
* badPublicKey - The public key is not acceptable (RFC 8555 Section 6.7.6)
*/
export class AcmeBadPublicKeyError extends AcmeError {
constructor({
message = "The public key is not acceptable",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadPublicKey,
message,
status: 400,
error
});
this.name = "AcmeBadPublicKeyError";
}
}
/**
* badCSR - The CSR is unacceptable (RFC 8555 Section 6.7.7)
*/
export class AcmeBadCsrError extends AcmeError {
constructor({
message = "The CSR is unacceptable",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
message,
status: 400,
error
});
this.name = "AcmeBadCsrError";
}
}
/**
* badRevocationReason - The revocation reason provided is not allowed
* (RFC 8555 Section 6.7.8)
*/
export class AcmeBadRevocationReasonError extends AcmeError {
constructor({
message = "The revocation reason provided is not allowed",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadRevocationReason,
message,
status: 400,
error
});
this.name = "AcmeBadRevocationReasonError";
}
}
/**
* rateLimited - The client has exceeded a rate limit (RFC 8555 Section 6.7.9)
*/
export class AcmeRateLimitedError extends AcmeError {
constructor({
message = "The client has exceeded a rate limit",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.RateLimited,
message,
status: 429,
error
});
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({
message = "The server will not issue certificates for the identifier",
subproblems,
error
}: {
message?: string;
subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.RejectedIdentifier,
message,
status: 400,
subproblems,
error
});
this.name = "AcmeRejectedIdentifierError";
}
}
/**
* serverInternal - An internal error occurred (RFC 8555 Section 6.7.11)
*/
export class AcmeServerInternalError extends AcmeError {
constructor({
message = "An internal error occurred",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.ServerInternal,
message,
status: 500,
error
});
this.name = "AcmeServerInternalError";
}
}
/**
* unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13)
*/
export class AcmeUnsupportedContactError extends AcmeError {
constructor({
message = "A contact URL is of an unsupported type",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.UnsupportedContact,
message,
status: 400,
error
});
this.name = "AcmeUnsupportedContactError";
}
}
/**
* unsupportedIdentifier - An identifier is of an unsupported type
* (RFC 8555 Section 6.7.14)
*/
export class AcmeUnsupportedIdentifierError extends AcmeError {
constructor({
message = "An identifier is of an unsupported type",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.UnsupportedIdentifier,
message,
status: 400,
error
});
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({
message = "Visit the instance URL and take actions specified there",
instance,
error
}: {
message?: string;
instance?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.UserActionRequired,
message,
status: 403,
error
});
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({
message = "The response is incorrect",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.IncorrectResponse,
message,
status: 400,
error
});
this.name = "AcmeIncorrectResponseError";
}
}
/**
* connectionError - A connection error occurred (RFC 8555 Section 6.7.17)
*/
export class AcmeConnectionError extends AcmeError {
constructor({
message = "A connection error occurred",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.Connection,
message,
status: 400,
error
});
this.name = "AcmeConnectionError";
}
}
export class AcmeDnsFailureError extends AcmeError {
constructor({
message = "Hostname could not be resolved (DNS failure)",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.DNS,
message,
status: 400,
error
});
this.name = "AcmeDnsFailureError";
}
}
export class AcmeOrderNotReadyError extends AcmeError {
constructor({
message = "The order is not ready",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.OrderNotReady,
message,
status: 400,
error
});
this.name = "AcmeOrderNotReadyError";
}
}
export class AcmeBadCSRError extends AcmeError {
constructor({
message = "The CSR is unacceptable",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.BadCsr,
message,
status: 400,
error
});
this.name = "AcmeBadCSRError";
}
}
export class AcmeExternalAccountRequiredError extends AcmeError {
constructor({
message = "External account binding is required",
error
}: {
message?: string;
error?: unknown;
} = {}) {
super({
type: AcmeErrorType.ExternalAccountRequired,
message,
status: 400,
error
});
this.name = "AcmeExternalAccountRequiredError";
}
}
@@ -0,0 +1,25 @@
import { getConfig } from "@app/lib/config/env";
import RE2 from "re2";
import { z } from "zod";
import { AcmeAccountDoesNotExistError } 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 AcmeAccountDoesNotExistError({ message: "KID must start with the profile account URL" });
}
return z.string().uuid().parse(kid.slice(kidPrefix.length));
};
export const validateDnsIdentifier = (identifier: string): boolean => {
// 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 = identifier.split(".");
return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label));
};
@@ -0,0 +1,13 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TPkiAcmeOrderAuthDALFactory = ReturnType<typeof pkiAcmeOrderAuthDALFactory>;
export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => {
const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth);
return {
...pkiAcmeOrderAuthOrm
};
};
@@ -0,0 +1,78 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TPkiAcmeOrderDALFactory = ReturnType<typeof pkiAcmeOrderDALFactory>;
export const pkiAcmeOrderDALFactory = (db: TDbClient) => {
const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder);
const findByIdForFinalization = async (id: string, tx?: Knex) => {
try {
const order = await (tx || db)(TableName.PkiAcmeOrder).forUpdate().where({ id }).first();
return order || null;
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME order by id for finalization" });
}
};
const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => {
try {
const rows = await (tx || db)(TableName.PkiAcmeOrder)
.join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`)
.join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`)
.select(
selectAllTableCols(TableName.PkiAcmeOrder),
db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"),
db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("identifierType"),
db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"),
db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt")
)
.where(`${TableName.PkiAcmeOrder}.id`, orderId)
.where(`${TableName.PkiAcmeOrder}.accountId`, accountId)
.orderBy(`${TableName.PkiAcmeAuth}.identifierValue`, "asc");
if (rows.length === 0) {
return null;
}
return sqlNestRelationships({
data: rows,
key: "id",
parentMapper: (row) => row,
childrenMapper: [
{
key: "authId",
label: "authorizations" as const,
mapper: ({ authId, identifierType, identifierValue, authExpiresAt }) => ({
id: authId,
identifierType,
identifierValue,
expiresAt: authExpiresAt
})
}
]
})?.[0];
} catch (error) {
throw new DatabaseError({ error, name: "Find PKI ACME order by id" });
}
};
const listByAccountId = async (accountId: string, tx?: Knex) => {
try {
const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }).orderBy("createdAt", "desc");
return orders;
} catch (error) {
throw new DatabaseError({ error, name: "List PKI ACME orders by account id" });
}
};
return {
...pkiAcmeOrderOrm,
findByIdForFinalization,
findByAccountAndOrderIdWithAuthorizations,
listByAccountId
};
};
@@ -0,0 +1,168 @@
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.string(),
value: z.string()
})
),
notBefore: z.string().optional(),
notAfter: z.string().optional()
});
export const AcmeOrderResourceSchema = z.object({
status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]),
expires: z.string().optional(),
notBefore: z.string().optional(),
notAfter: z.string().optional(),
identifiers: z.array(
z.object({
type: z.string(),
value: z.string()
})
),
authorizations: z.array(z.string()),
finalize: z.string(),
certificate: z.string().optional()
});
// Account Deactivation payload schema
export const DeactivateAcmeAccountBodySchema = z.object({
status: z.literal("deactivated")
});
export const DeactivateAcmeAccountResponseSchema = z.object({
status: z.string()
});
// List Orders endpoint
export const ListAcmeOrdersPayloadSchema = z.object({}).strict();
export const ListAcmeOrdersResponseSchema = z.object({
orders: z.array(z.string())
});
// Finalize Order payload schema
export const FinalizeAcmeOrderBodySchema = z.object({
csr: z.string()
});
export const GetAcmeAuthorizationResponseSchema = z.object({
status: z.enum(Object.values(AcmeAuthStatus) as [string, ...string[]]),
expires: z.string().optional(),
identifier: z.object({
type: z.string(),
value: z.string()
}),
challenges: z.array(
z.object({
type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]),
url: z.string(),
status: z.string(),
token: z.string(),
validated: z.string().optional()
})
)
});
export const RespondToAcmeChallengeBodySchema = z.object({}).strict();
export const RespondToAcmeChallengeResponseSchema = z.object({
type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]),
url: z.string(),
status: z.string(),
token: z.string(),
validated: z.string().optional(),
error: z
.object({
type: z.string(),
detail: z.string(),
status: z.number()
})
.optional()
});
@@ -0,0 +1,974 @@
import * as x509 from "@peculiar/x509";
import {
calculateJwkThumbprint,
errors,
flattenedVerify,
FlattenedVerifyResult,
importJWK,
JWSHeaderParameters
} from "jose";
import { z, ZodError } from "zod";
import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts";
import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { isPrivateIp } from "@app/lib/ip/ipRange";
import { logger } from "@app/lib/logger";
import { ActorType } from "@app/services/auth/auth-type";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import {
EnrollmentType,
TCertificateProfileWithConfigs
} from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { 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 { getConfig } from "@app/lib/config/env";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { TExternalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import {
CertExtendedKeyUsage,
CertKeyUsage,
CertSubjectAlternativeNameType
} from "@app/services/certificate/certificate-types";
import { TLicenseServiceFactory } from "../license/license-service";
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, validateDnsIdentifier } 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" | "findById">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
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" | "encryptWithKmsKey" | "createCipherPairWithDataKey"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
};
export const pkiAcmeServiceFactory = ({
projectDAL,
appConnectionDAL,
certificateDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateProfileDAL,
certificateBodyDAL,
certificateSecretDAL,
acmeAccountDAL,
acmeOrderDAL,
acmeAuthDAL,
acmeOrderAuthDAL,
acmeChallengeDAL,
keyStore,
kmsService,
licenseService,
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" });
}
const orgLicensePlan = await licenseService.getPlan(profile.project!.orgId);
if (!orgLicensePlan.pkiAcme) {
throw new AcmeUnauthorizedError({
message: "Failed to validate ACME profile: Plan restriction. Upgrade plan to continue"
});
}
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({ message: "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({ message: `Invalid JWS payload: ${error.message}` });
}
if (error instanceof errors.JWSSignatureVerificationFailed) {
throw new AcmeBadPublicKeyError({ message: "Invalid JWS payload" });
}
logger.error(error, "Unexpected error while verifying JWS payload");
throw new AcmeMalformedError({ message: "Failed to verify JWS payload" });
}
const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result;
try {
const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader);
const parsedUrl = (() => {
try {
return new URL(protectedHeader.url);
} catch (error) {
throw new AcmeMalformedError({ message: "Invalid URL in the protected header" });
}
})();
// Validate the URL
if (parsedUrl.href !== url.href) {
throw new AcmeMalformedError({ message: "URL mismatch in the protected header" });
}
// Consume the nonce
if (!protectedHeader.nonce) {
throw new AcmeMalformedError({ message: "Nonce is required in the protected header" });
}
const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce));
if (deleted !== 1) {
throw new AcmeBadNonceError({ message: "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({ message: `Invalid JWS payload: ${error.message}` });
}
logger.error(error, "Unexpected error while parsing JWS payload");
throw new AcmeMalformedError({ message: "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({ message: "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({ message: "KID is required in the protected header" });
}
const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId);
if (expectedAccountId && accountId !== expectedAccountId) {
throw new AcmeAccountDoesNotExistError({ 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({ message: "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({ message: "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 AcmeExternalAccountRequiredError({ message: "Invalid external account binding JWS signature" });
}
logger.error(error, "Unexpected error while verifying EAB JWS signature");
throw new AcmeServerInternalError({ message: "Failed to verify EAB JWS signature" });
}
})();
const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!;
if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) {
throw new AcmeExternalAccountRequiredError({
message: "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 AcmeExternalAccountRequiredError({ 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 AcmeExternalAccountRequiredError({ 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.
// TODO: ideally, we should return an error with subproblems if we have multiple unsupported identifiers
if (payload.identifiers.some((identifier) => identifier.type !== AcmeIdentifierType.DNS)) {
throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" });
}
if (
payload.identifiers.some(
(identifier) =>
!validateDnsIdentifier(identifier.value) ||
isPrivateIp(identifier.value) ||
(!getConfig().isDevelopmentMode && identifier.value.toLowerCase() === "localhost")
)
) {
throw new AcmeUnsupportedIdentifierError({ message: "Invalid DNS identifier" });
}
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({ message: "Only DNS identifiers are supported" });
}
if (isPrivateIp(identifier.value)) {
throw new AcmeUnsupportedIdentifierError({ message: "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>> => {
const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!;
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;
// Check and validate the CSR
const certificateRequest = extractCertificateRequestFromCSR(csr);
if (!certificateRequest.commonName) {
throw new AcmeBadCSRError({ message: "Invalid CSR: Common name is required" });
}
if (
certificateRequest.subjectAlternativeNames?.some(
(san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME
)
) {
throw new AcmeBadCSRError({ message: "Invalid CSR: Only DNS subject alternative names are supported" });
}
const orderWithAuthorizations = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(
accountId,
orderId,
tx
))!;
const csrIdentifierValues = new Set(
(certificateRequest.subjectAlternativeNames ?? [])
.map((san) => san.value.toLowerCase())
.concat([certificateRequest.commonName.toLowerCase()])
);
if (
csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length ||
!orderWithAuthorizations.authorizations.every((auth) =>
csrIdentifierValues.has(auth.identifierValue.toLowerCase())
)
) {
throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" });
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
let errorToReturn: Error | undefined;
try {
const { certificateId } = await (async () => {
if (caType === CaType.INTERNAL) {
const result = 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
? {
// 47 days, the default TTL comes with Let's Encrypt
// TODO: read config from the profile to get the expiration time instead
ttl: `${47}d`
}
: // ttl is not used if notAfter is provided
({ ttl: "0d" } as const),
enrollmentType: EnrollmentType.ACME
});
return { certificateId: result.certificateId };
}
const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!;
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const csrPem = csrObj.toString("pem");
// TODO: for internal CA, we rely on the internal certificate authority service to check CSR against the template
// we should check the CSR against the template here
// TODO: this is pretty slow, and we are holding the transaction open for a long time,
// we should queue the certificate issuance to a background job instead
const cert = await orderCertificate(
{
caId: certificateAuthority!.id,
commonName: certificateRequest.commonName!,
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value),
csr: Buffer.from(csrPem),
// TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now
keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT],
extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH]
},
{
appConnectionDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL
}
);
return { certificateId: cert.id };
})();
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({ message: `Invalid CSR: ${exp.message}` });
} else {
errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" });
}
}
return {
order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!,
error: errorToReturn
};
});
if (error) {
throw error;
}
order = updatedOrder;
} else if (order.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" });
}
return {
status: 200,
body: buildAcmeOrderResource({ profileId, order }),
headers: {
Location: buildUrl(profileId, `/orders/${orderId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const downloadAcmeCertificate = async ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}): Promise<TAcmeResponse<string>> => {
const profile = await validateAcmeProfile(profileId);
const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId);
if (!order) {
throw new NotFoundError({ message: "ACME order not found" });
}
if (order.status !== AcmeOrderStatus.Valid) {
throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" });
}
if (!order.certificateId) {
throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" });
}
const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: profile.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKeyId
});
const decryptedCert = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificate
});
const certObj = new x509.X509Certificate(decryptedCert);
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain!
});
const certificateChain = decryptedCertChain.toString();
const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n");
const certChain = certificateChain.trim().replace("\n", "\r\n");
return {
status: 200,
body:
// The final line is needed, otherwise some clients will not parse the certificate chain correctly
// ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514
`${certLeaf}\r\n${certChain}\r\n`,
headers: {
Location: buildUrl(profileId, `/orders/${orderId}/certificate`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const listAcmeOrders = async ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
}): Promise<TAcmeResponse<TListAcmeOrdersResponse>> => {
const orders = await acmeOrderDAL.listByAccountId(accountId);
return {
status: 200,
body: { orders: orders.map((order) => buildUrl(profileId, `/orders/${order.id}`)) },
headers: {
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
/** --------------------------------------------------------------
* ACME Authorization
* -------------------------------------------------------------- */
const getAcmeAuthorization = async ({
profileId,
accountId,
authzId
}: {
profileId: string;
accountId: string;
authzId: string;
}): Promise<TAcmeResponse<TGetAcmeAuthorizationResponse>> => {
const auth = await acmeAuthDAL.findByAccountIdAndAuthIdWithChallenges(accountId, authzId);
if (!auth) {
throw new NotFoundError({ message: "ACME authorization not found" });
}
return {
status: 200,
body: {
status: auth.status,
expires: auth.expiresAt.toISOString(),
identifier: {
type: auth.identifierType,
value: auth.identifierValue
},
challenges: auth.challenges.map((challenge) => {
return {
type: challenge.type,
url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`),
status: challenge.status,
token: auth.token!
};
})
},
headers: {
Location: buildUrl(profileId, `/authorizations/${authzId}`),
Link: `<${buildUrl(profileId, "/directory")}>;rel="index"`
}
};
};
const respondToAcmeChallenge = async ({
profileId,
accountId,
authzId,
challengeId
}: {
profileId: string;
accountId: string;
authzId: string;
challengeId: string;
}): Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>> => {
const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId);
if (!result) {
throw new NotFoundError({ message: "ACME challenge not found" });
}
await acmeChallengeService.validateChallengeResponse(challengeId);
const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!;
return {
status: 200,
body: {
type: challenge.type,
url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`),
status: challenge.status,
token: challenge.auth.token!
},
headers: {
Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`),
Link: [
`<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`,
`<${buildUrl(profileId, "/directory")}>;rel="index"`
]
}
};
};
return {
validateJwsPayload,
validateNewAccountJwsPayload,
validateExistingAccountJwsPayload,
getAcmeDirectory,
getAcmeNewNonce,
createAcmeAccount,
createAcmeOrder,
deactivateAcmeAccount,
listAcmeOrders,
getAcmeOrder,
finalizeAcmeOrder,
downloadAcmeCertificate,
getAcmeAuthorization,
respondToAcmeChallenge
};
};
@@ -0,0 +1,180 @@
import { JWSHeaderParameters } from "jose";
import { z } from "zod";
import {
AcmeOrderResourceSchema,
CreateAcmeAccountBodySchema,
CreateAcmeAccountResponseSchema,
CreateAcmeOrderBodySchema,
DeactivateAcmeAccountBodySchema,
DeactivateAcmeAccountResponseSchema,
FinalizeAcmeOrderBodySchema,
GetAcmeAuthorizationResponseSchema,
GetAcmeDirectoryResponseSchema,
ListAcmeOrdersResponseSchema,
ProtectedHeaderSchema,
RawJwsPayloadSchema,
RespondToAcmeChallengeResponseSchema
} from "./pki-acme-schemas";
export type TGetAcmeDirectoryResponse = z.infer<typeof GetAcmeDirectoryResponseSchema>;
export type TCreateAcmeAccountResponse = z.infer<typeof CreateAcmeAccountResponseSchema>;
export type TAcmeOrderResource = z.infer<typeof AcmeOrderResourceSchema>;
export type TDeactivateAcmeAccountResponse = z.infer<typeof DeactivateAcmeAccountResponseSchema>;
export type TListAcmeOrdersResponse = z.infer<typeof ListAcmeOrdersResponseSchema>;
export type TDownloadAcmeCertificateDTO = string;
export type TGetAcmeAuthorizationResponse = z.infer<typeof GetAcmeAuthorizationResponseSchema>;
export type TRespondToAcmeChallengeResponse = z.infer<typeof RespondToAcmeChallengeResponseSchema>;
// Payload types
export type TRawJwsPayload = z.infer<typeof RawJwsPayloadSchema>;
export type TProtectedHeader = z.infer<typeof ProtectedHeaderSchema>;
export type TCreateAcmeAccountPayload = z.infer<typeof CreateAcmeAccountBodySchema>;
export type TCreateAcmeOrderPayload = z.infer<typeof CreateAcmeOrderBodySchema>;
export type TDeactivateAcmeAccountPayload = z.infer<typeof DeactivateAcmeAccountBodySchema>;
export type TFinalizeAcmeOrderPayload = z.infer<typeof FinalizeAcmeOrderBodySchema>;
export type TJwsPayload<T> = {
protectedHeader: TProtectedHeader;
payload: T;
};
export type TAuthenciatedJwsPayload<T> = TJwsPayload<T> & {
profileId: string;
accountId: string;
};
export type TAcmeResponse<TPayload> = {
status: number;
headers: Record<string, string | string[]>;
body: TPayload;
};
export type TPkiAcmeServiceFactory = {
validateJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
rawJwsPayload,
getJWK,
schema
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
getJWK: (protectedHeader: JWSHeaderParameters) => Promise<JsonWebKey>;
schema?: TSchema;
}) => Promise<TJwsPayload<T>>;
validateNewAccountJwsPayload: ({
url,
rawJwsPayload
}: {
url: URL;
rawJwsPayload: TRawJwsPayload;
}) => Promise<TJwsPayload<TCreateAcmeAccountPayload>>;
validateExistingAccountJwsPayload: <
TSchema extends z.ZodSchema<unknown> | undefined = undefined,
T = TSchema extends z.ZodSchema<infer R> ? R : string
>({
url,
profileId,
rawJwsPayload,
schema,
expectedAccountId
}: {
url: URL;
profileId: string;
rawJwsPayload: TRawJwsPayload;
schema?: TSchema;
expectedAccountId?: string;
}) => Promise<TAuthenciatedJwsPayload<T>>;
getAcmeDirectory: (profileId: string) => Promise<TGetAcmeDirectoryResponse>;
getAcmeNewNonce: (profileId: string) => Promise<string>;
createAcmeAccount: ({
profileId,
alg,
jwk,
payload
}: {
profileId: string;
alg: string;
jwk: JsonWebKey;
payload: TCreateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TCreateAcmeAccountResponse>>;
deactivateAcmeAccount: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload?: TDeactivateAcmeAccountPayload;
}) => Promise<TAcmeResponse<TDeactivateAcmeAccountResponse>>;
createAcmeOrder: ({
profileId,
accountId,
payload
}: {
profileId: string;
accountId: string;
payload: TCreateAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
getAcmeOrder: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
finalizeAcmeOrder: ({
profileId,
accountId,
orderId,
payload
}: {
profileId: string;
accountId: string;
orderId: string;
payload: TFinalizeAcmeOrderPayload;
}) => Promise<TAcmeResponse<TAcmeOrderResource>>;
downloadAcmeCertificate: ({
profileId,
accountId,
orderId
}: {
profileId: string;
accountId: string;
orderId: string;
}) => Promise<TAcmeResponse<string>>;
listAcmeOrders: ({
profileId,
accountId
}: {
profileId: string;
accountId: string;
}) => Promise<TAcmeResponse<TListAcmeOrdersResponse>>;
getAcmeAuthorization: ({
profileId,
accountId,
authzId
}: {
profileId: string;
accountId: string;
authzId: string;
}) => Promise<TAcmeResponse<TGetAcmeAuthorizationResponse>>;
respondToAcmeChallenge: ({
profileId,
accountId,
authzId,
challengeId
}: {
profileId: string;
accountId: string;
authzId: string;
challengeId: string;
}) => Promise<TAcmeResponse<TRespondToAcmeChallengeResponse>>;
};
export type TPkiAcmeChallengeServiceFactory = {
validateChallengeResponse: (challengeId: string) => Promise<void>;
};
@@ -8,6 +8,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { containsGlobPatterns } from "@app/lib/picomatch";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal";
import { TUserDALFactory } from "@app/services/user/user-dal";
import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types";
@@ -39,6 +40,7 @@ type TSecretApprovalPolicyServiceFactoryDep = {
secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne" | "find">;
userDAL: Pick<TUserDALFactory, "find">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory;
secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
@@ -56,9 +58,25 @@ export const secretApprovalPolicyServiceFactory = ({
secretApprovalPolicyEnvironmentDAL,
projectEnvDAL,
userDAL,
projectMembershipDAL,
licenseService,
secretApprovalRequestDAL
}: TSecretApprovalPolicyServiceFactoryDep) => {
const verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
if (userIds.length === 0) return;
const projectMemberships = (await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, userIds)).filter(
(v) => v.projectId === projectId
);
if (projectMemberships.length !== userIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((member) => member.userId));
const userIdsNotInProject = userIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const $policyExists = async ({
envIds,
envId,
@@ -202,6 +220,7 @@ export const secretApprovalPolicyServiceFactory = ({
},
tx
);
await secretApprovalPolicyEnvironmentDAL.insertMany(
envs.map((env) => ({
envId: env.id,
@@ -233,6 +252,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
}
await verifyProjectUserMembership(userApproverIds, actorOrgId, projectId);
await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({
approverUserId,
@@ -250,6 +271,8 @@ export const secretApprovalPolicyServiceFactory = ({
);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, projectId);
await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -425,6 +448,8 @@ export const secretApprovalPolicyServiceFactory = ({
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
}
await verifyProjectUserMembership(userApproverIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyApproverDAL.insertMany(
userApproverIds.map((approverUserId) => ({
approverUserId,
@@ -458,6 +483,8 @@ export const secretApprovalPolicyServiceFactory = ({
await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx);
if (bypasserUserIds.length) {
await verifyProjectUserMembership(bypasserUserIds, actorOrgId, secretApprovalPolicy.projectId);
await secretApprovalPolicyBypasserDAL.insertMany(
bypasserUserIds.map((userId) => ({
bypasserUserId: userId,
@@ -166,6 +166,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"),
tx.ref("createdAt").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerCreatedAt"),
tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"),
tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"),
tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"),
@@ -240,6 +241,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
reviewerUsername: username,
reviewerFirstName: firstName,
reviewerComment: comment,
reviewerCreatedAt: createdAt,
reviewerIsOrgMembershipActive: isOrgMembershipActive
}) =>
userId
@@ -251,6 +253,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
lastName,
username,
comment: comment ?? "",
createdAt,
isOrgMembershipActive
}
: undefined
@@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({
type: NotificationType.SECRET_CHANGE_REQUEST,
title: "Secret Change Request",
body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`,
link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
link: `/projects/secret-management/${project.id}/approval`
}))
);
@@ -51,7 +51,7 @@ export const sendApprovalEmailsFn = async ({
firstName: reviewerUser.firstName,
projectName: project.name,
organizationName: project.organization.name,
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}`
approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval}`
},
template: SmtpTemplates.SecretApprovalRequestNeedsReview
});
@@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({
const env = await projectEnvDAL.findOne({ id: policy.envId });
const user = await userDAL.findById(actorId);
const projectPath = `/projects/secret-management/${projectId}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))],
approvalUrl
}
}
},
@@ -1786,6 +1792,11 @@ export const secretApprovalRequestServiceFactory = ({
const user = await userDAL.findById(actorId);
const env = await projectEnvDAL.findOne({ id: policy.envId });
const projectPath = `/projects/secret-management/${project.id}`;
const approvalPath = `${projectPath}/approval`;
const cfg = getConfig();
const approvalUrl = `${cfg.SITE_URL}${approvalPath}`;
await triggerWorkflowIntegrationNotification({
input: {
projectId,
@@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({
secretPath,
projectId,
requestId: secretApprovalRequest.id,
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))]
secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))],
approvalUrl
}
}
},
+3 -1
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 = {
+109 -4
View File
@@ -33,11 +33,13 @@ export enum ApiDocsTags {
LdapAuth = "LDAP Auth",
Groups = "Groups",
Organizations = "Organizations",
OrgIdentityMembership = "Organization Identity Membership",
SubOrganizations = "Sub Organizations",
Projects = "Projects",
ProjectUsers = "Project Users",
ProjectGroups = "Project Groups",
ProjectIdentities = "Project Identities",
IdentityProjectMembership = "Project Identity Membership",
ProjectRoles = "Project Roles",
ProjectTemplates = "Project Templates",
Environments = "Environments",
@@ -62,6 +64,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",
@@ -121,13 +124,15 @@ export const IDENTITIES = {
name: "The name of the identity to create.",
organizationId: "The organization ID to which the identity belongs.",
role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'.",
hasDeleteProtection: "Prevents deletion of the identity when enabled."
hasDeleteProtection: "Prevents deletion of the identity when enabled.",
metadata: "An optional array of key-value pairs to attach to the identity."
},
UPDATE: {
identityId: "The ID of the machine identity to update.",
name: "The new name of the identity.",
role: "The new role of the identity.",
hasDeleteProtection: "Prevents deletion of the identity when enabled."
hasDeleteProtection: "Prevents deletion of the identity when enabled.",
metadata: "An optional array of key-value pairs to attach to the identity."
},
DELETE: {
identityId: "The ID of the machine identity to delete."
@@ -137,7 +142,10 @@ export const IDENTITIES = {
orgId: "The ID of the org of the identity"
},
LIST: {
orgId: "The ID of the organization to list identities."
orgId: "The ID of the organization to list identities.",
search: "The text string that identity names will be filtered by.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
limit: "The number of identities to return."
},
SEARCH: {
search: {
@@ -718,6 +726,50 @@ export const ORGANIZATIONS = {
}
} as const;
export const ORG_IDENTITY_MEMBERSHIP = {
CREATE_IDENTITY_MEMBERSHIP: {
identityId: "The ID of the machine identity to create the membership for.",
roles: {
description: "A list of role slugs to assign to the identity organization membership.",
role: "The role slug to assign to the newly created identity organization membership.",
isTemporary:
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
temporaryMode: "Type of temporary expiry.",
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.",
temporaryAccessStartTime: "Time to which the temporary access starts."
}
},
UPDATE_IDENTITY_MEMBERSHIP: {
identityId: "The ID of the machine identity to update the membership for.",
roles: {
description: "A list of role slugs to assign to the identity organization membership.",
role: "The role slug to assign to the identity organization membership.",
isTemporary:
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
temporaryMode: "Type of temporary expiry.",
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.",
temporaryAccessStartTime: "Time to which the temporary access starts."
}
},
DELETE_IDENTITY_MEMBERSHIP: {
identityId: "The ID of the machine identity to delete the membership from."
},
LIST_IDENTITY_MEMBERSHIPS: {
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
identityName: "",
roles: "The role slugs to filter identity memberships by."
},
GET_IDENTITY_MEMBERSHIP_BY_ID: {
identityId: "The ID of the machine identity to get the membership for."
},
LIST_AVAILABLE_IDENTITIES: {
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
limit: "The number of identities to return.",
identityName: "The text string that identity membership names will be filtered by."
}
} as const;
export const SUB_ORGANIZATIONS = {
CREATE: {
name: "The name of the sub organization to create."
@@ -906,6 +958,56 @@ export const PROJECT_IDENTITIES = {
}
};
export const PROJECT_IDENTITY_MEMBERSHIP = {
CREATE_IDENTITY_MEMBERSHIP: {
projectId: "The ID of the project to create the identity membership for.",
identityId: "The ID of the machine identity to create the membership for.",
roles: {
description: "A list of role slugs to assign to the identity project membership.",
role: "The role slug to assign to the newly created identity project membership.",
isTemporary:
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
temporaryMode: "Type of temporary expiry.",
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.",
temporaryAccessStartTime: "Time to which the temporary access starts."
}
},
UPDATE_IDENTITY_MEMBERSHIP: {
projectId: "The ID of the project to update the identity membership for.",
identityId: "The ID of the machine identity to update the membership for.",
roles: {
description: "A list of role slugs to assign to the identity project membership.",
role: "The role slug to assign to the identity project membership.",
isTemporary:
"Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.",
temporaryMode: "Type of temporary expiry.",
temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.",
temporaryAccessStartTime: "Time to which the temporary access starts."
}
},
DELETE_IDENTITY_MEMBERSHIP: {
projectId: "The ID of the project to delete the identity membership from.",
identityId: "The ID of the machine identity to delete the membership from."
},
LIST_IDENTITY_MEMBERSHIPS: {
projectId: "The ID of the project to list identity memberships from.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.",
limit: "The number of identity memberships to return.",
identityName: "The text string that identity membership names will be filtered by.",
roles: "The role slugs to filter identity memberships by."
},
GET_IDENTITY_MEMBERSHIP_BY_ID: {
projectId: "The ID of the project to get the identity membership for.",
identityId: "The ID of the machine identity to get the membership for."
},
LIST_AVAILABLE_IDENTITIES: {
projectId: "The ID of the project to list available identities for.",
offset: "The offset to start from. If you enter 10, it will start from the 10th identity.",
limit: "The number of identities to return.",
identityName: "The text string that identity membership names will be filtered by."
}
} as const;
export const ENVIRONMENTS = {
CREATE: {
projectId: "The ID of the project to create the environment in.",
@@ -2308,7 +2410,10 @@ export const AppConnections = {
code: "The OAuth code to use to connect with Azure Client Secrets.",
tenantId: "The Tenant ID to use to connect with Azure Client Secrets.",
clientId: "The Client ID to use to connect with Azure Client Secrets.",
clientSecret: "The Client Secret to use to connect with Azure Client Secrets."
clientSecret: "The Client Secret to use to connect with Azure Client Secrets.",
certificateBody: "The certificate body in PEM format to use to connect with Azure Client Secrets.",
privateKey:
"The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with."
},
AZURE_DEVOPS: {
code: "The OAuth code to use to connect with Azure DevOps.",
+15
View File
@@ -106,6 +106,19 @@ const envSchema = z
HTTPS_ENABLED: zodStrBool,
ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
BDD_NOCK_API_ENABLED: zodStrBool.default("false").optional(),
ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(),
ACME_SKIP_UPSTREAM_VALIDATION: 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,8 +397,10 @@ 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,
isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE,
isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED,
isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS),
isBddNockApiEnabled: data.NODE_ENV === "development" && data.BDD_NOCK_API_ENABLED,
REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim()
?.split(",")
.map((el) => {
@@ -0,0 +1,92 @@
import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns";
import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service";
import {
TProjectMicrosoftTeamsConfigDALFactory,
TProjectMicrosoftTeamsConfigWithIntegrations
} from "@app/services/microsoft-teams/project-microsoft-teams-config-dal";
import { logger } from "../../logger";
import { TNotification, TriggerFeature } from "../types";
const handleMicrosoftTeamsNotification = async ({
microsoftTeamsConfig,
notification,
orgId,
microsoftTeamsService
}: {
microsoftTeamsConfig: TProjectMicrosoftTeamsConfigWithIntegrations;
notification: TNotification;
orgId: string;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
let targetChannels: unknown;
let isEnabled = false;
switch (notification.type) {
case TriggerFeature.ACCESS_REQUEST:
case TriggerFeature.ACCESS_REQUEST_UPDATED:
targetChannels = microsoftTeamsConfig.accessRequestChannels;
isEnabled = microsoftTeamsConfig.isAccessRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_APPROVAL:
targetChannels = microsoftTeamsConfig.secretRequestChannels;
isEnabled = microsoftTeamsConfig.isSecretRequestNotificationEnabled;
break;
default:
return;
}
if (isEnabled && targetChannels) {
const { success, data, error: validationError } = validateMicrosoftTeamsChannelsSchema.safeParse(targetChannels);
if (!success) {
logger.error(validationError, "Invalid Microsoft Teams channel configuration");
return;
}
if (data) {
await microsoftTeamsService
.sendNotification({
notification,
target: data,
tenantId: microsoftTeamsConfig.tenantId,
microsoftTeamsIntegrationId: microsoftTeamsConfig.id,
orgId
})
.catch((error) => {
logger.error(
error,
`Error sending Microsoft Teams notification. Notification type: ${notification.type}, Tenant ID: ${microsoftTeamsConfig.tenantId}, Project ID: ${microsoftTeamsConfig.projectId}`
);
});
}
}
};
export const triggerMicrosoftTeamsNotification = async ({
projectId,
notification,
orgId,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
}: {
projectId: string;
notification: TNotification;
orgId: string;
projectMicrosoftTeamsConfigDAL: Pick<TProjectMicrosoftTeamsConfigDALFactory, "getIntegrationDetailsByProject">;
microsoftTeamsService: Pick<TMicrosoftTeamsServiceFactory, "sendNotification">;
}): Promise<void> => {
try {
const config = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId);
if (config) {
await handleMicrosoftTeamsNotification({
microsoftTeamsConfig: config,
notification,
orgId,
microsoftTeamsService
});
}
} catch (error) {
logger.error(error, `Error handling Microsoft Teams notification. Project ID: ${projectId}`);
}
};
@@ -0,0 +1,80 @@
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import {
TProjectSlackConfigDALFactory,
TProjectSlackConfigWithIntegrations
} from "@app/services/slack/project-slack-config-dal";
import { sendSlackNotification } from "@app/services/slack/slack-fns";
import { logger } from "../../logger";
import { TNotification, TriggerFeature } from "../types";
const handleSlackNotification = async ({
slackConfig,
notification,
orgId,
kmsService
}: {
slackConfig: TProjectSlackConfigWithIntegrations;
notification: TNotification;
orgId: string;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
let targetChannelIds: string[] = [];
let isEnabled = false;
switch (notification.type) {
case TriggerFeature.ACCESS_REQUEST:
case TriggerFeature.ACCESS_REQUEST_UPDATED:
targetChannelIds = slackConfig.accessRequestChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isAccessRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_APPROVAL:
targetChannelIds = slackConfig.secretRequestChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isSecretRequestNotificationEnabled;
break;
case TriggerFeature.SECRET_SYNC_ERROR:
targetChannelIds = slackConfig.secretSyncErrorChannels?.split(", ").filter(Boolean) || [];
isEnabled = slackConfig.isSecretSyncErrorNotificationEnabled;
break;
default:
return;
}
if (targetChannelIds.length && isEnabled) {
await sendSlackNotification({
orgId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(
error,
`Error sending Slack notification. Notification type: ${notification.type}, Target channel IDs: ${targetChannelIds.join(", ")}, Project ID: ${slackConfig.projectId}`
);
});
}
};
export const triggerSlackNotification = async ({
projectId,
notification,
orgId,
projectSlackConfigDAL,
kmsService
}: {
projectId: string;
notification: TNotification;
orgId: string;
projectSlackConfigDAL: Pick<TProjectSlackConfigDALFactory, "getIntegrationDetailsByProject">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
}): Promise<void> => {
try {
const config = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
if (config) {
await handleSlackNotification({ slackConfig: config, notification, orgId, kmsService });
}
} catch (error) {
logger.error(error, `Error handling Slack notification. Project ID: ${projectId}`);
}
};
@@ -1,8 +1,7 @@
import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns";
import { sendSlackNotification } from "@app/services/slack/slack-fns";
import { logger } from "../logger";
import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types";
import { triggerMicrosoftTeamsNotification } from "./notification-handlers/microsoft-teams";
import { triggerSlackNotification } from "./notification-handlers/slack";
import { TTriggerWorkflowNotificationDTO } from "./types";
export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => {
try {
@@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl
return;
}
const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId);
const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId);
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({
const handlerPromises = [
triggerSlackNotification({
projectId,
notification,
orgId: project.orgId,
projectSlackConfigDAL,
kmsService
}),
triggerMicrosoftTeamsNotification({
projectId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(error, "Error sending Slack notification");
});
}
} else if (notification.type === TriggerFeature.SECRET_APPROVAL) {
const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || [];
if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) {
await sendSlackNotification({
orgId: project.orgId,
notification,
kmsService,
targetChannelIds,
slackIntegration: slackConfig
}).catch((error) => {
logger.error(error, "Error sending Slack notification");
});
}
}
}
if (microsoftTeamsConfig) {
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
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
})
.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");
}
+19 -1
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;
};
};
+6
View File
@@ -51,6 +51,7 @@ export enum QueueName {
AuditLogPrune = "audit-log-prune",
DailyResourceCleanUp = "daily-resource-cleanup",
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
PkiSyncCleanup = "pki-sync-cleanup",
PkiSubscriber = "pki-subscriber",
TelemetryInstanceStats = "telemtry-self-hosted-stats",
@@ -90,6 +91,7 @@ export enum QueueJobs {
AuditLogPrune = "audit-log-prune-job",
DailyResourceCleanUp = "daily-resource-cleanup-job",
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
PkiSyncCleanup = "pki-sync-cleanup-job",
SecWebhook = "secret-webhook-trigger",
TelemetryInstanceStats = "telemetry-self-hosted-stats",
@@ -159,6 +161,10 @@ export type TQueueJobTypes = {
name: QueueJobs.DailyExpiringPkiItemAlert;
payload: undefined;
};
[QueueName.DailyPkiAlertV2Processing]: {
name: QueueJobs.DailyPkiAlertV2Processing;
payload: undefined;
};
[QueueName.PkiSyncCleanup]: {
name: QueueJobs.PkiSyncCleanup;
payload: undefined;
@@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas";
const isScimRoutes = (pathname: string) =>
pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups");
const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/pki/acme/");
export const addErrorsToResponseSchemas = fp(async (server) => {
server.addHook("onRoute", (routeOptions) => {
if (routeOptions.schema && routeOptions.schema.response && !isScimRoutes(routeOptions.path)) {
if (
routeOptions.schema &&
routeOptions.schema.response &&
!isScimRoutes(routeOptions.path) &&
!isAcmeRoutes(routeOptions.path)
) {
routeOptions.schema.response = {
...DefaultResponseErrorsSchema,
...routeOptions.schema.response
@@ -182,8 +182,8 @@ export const injectIdentity = fp(
case AuthMode.IDENTITY_ACCESS_TOKEN: {
const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(
token,
subOrganizationSelector,
req.realIp
req.realIp,
subOrganizationSelector
);
const serverCfg = await getServerCfg();
requestContext.set("orgId", identity.orgId);
@@ -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,18 @@ 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.message
// TODO: add subproblems if they exist
});
} else {
void res.status(HttpStatusCodes.InternalServerError).send({
reqId: req.id,
+94 -5
View File
@@ -74,6 +74,13 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi
import { permissionDALFactory } from "@app/ee/services/permission/permission-dal";
import { permissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { pitServiceFactory } from "@app/ee/services/pit/pit-service";
import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal";
import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal";
import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-dal";
import { pkiAcmeChallengeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-service";
import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal";
import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal";
import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service";
import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal";
import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal";
@@ -182,6 +189,7 @@ import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/c
import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal";
import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal";
import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal";
@@ -233,6 +241,8 @@ 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 { identityV2DALFactory } from "@app/services/identity-v2/identity-dal";
import { identityV2ServiceFactory } from "@app/services/identity-v2/identity-service";
import { integrationDALFactory } from "@app/services/integration/integration-dal";
import { integrationServiceFactory } from "@app/services/integration/integration-service";
import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal";
@@ -267,6 +277,11 @@ import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rota
import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
import { pkiAlertChannelDALFactory } from "@app/services/pki-alert-v2/pki-alert-channel-dal";
import { pkiAlertHistoryDALFactory } from "@app/services/pki-alert-v2/pki-alert-history-dal";
import { pkiAlertV2DALFactory } from "@app/services/pki-alert-v2/pki-alert-v2-dal";
import { pkiAlertV2QueueServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-queue";
import { pkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service";
import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
@@ -432,6 +447,7 @@ export const registerRoutes = async (
const serviceTokenDAL = serviceTokenDALFactory(db);
const identityDAL = identityDALFactory(db);
const identityV2DAL = identityV2DALFactory(db);
const identityMetadataDAL = identityMetadataDALFactory(db);
const identityAccessTokenDAL = identityAccessTokenDALFactory(db);
const identityOrgMembershipDAL = identityOrgDALFactory(db);
@@ -547,6 +563,9 @@ export const registerRoutes = async (
const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db);
const membershipRoleDAL = membershipRoleDALFactory(db);
const roleDAL = roleDALFactory(db);
const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db);
const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db);
const pkiAlertV2DAL = pkiAlertV2DALFactory(db);
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
@@ -691,6 +710,7 @@ export const registerRoutes = async (
secretApprovalPolicyDAL,
licenseService,
userDAL,
projectMembershipDAL,
secretApprovalRequestDAL
});
@@ -1061,7 +1081,12 @@ export const registerRoutes = async (
const certificateProfileDAL = certificateProfileDALFactory(db);
const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db);
const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db);
const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db);
const acmeAccountDAL = pkiAcmeAccountDALFactory(db);
const acmeOrderDAL = pkiAcmeOrderDALFactory(db);
const acmeAuthDAL = pkiAcmeAuthDALFactory(db);
const acmeOrderAuthDAL = pkiAcmeOrderAuthDALFactory(db);
const acmeChallengeDAL = pkiAcmeChallengeDALFactory(db);
const certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db);
const certificateSecretDAL = certificateSecretDALFactory(db);
@@ -1156,7 +1181,13 @@ export const registerRoutes = async (
certificateTemplateV2DAL,
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
permissionService,
licenseService,
kmsService,
projectDAL
});
@@ -1251,7 +1282,10 @@ export const registerRoutes = async (
licenseService,
gatewayService,
gatewayV2Service,
notificationService
notificationService,
projectSlackConfigDAL,
projectMicrosoftTeamsConfigDAL,
microsoftTeamsService
});
const secretQueueService = secretQueueFactory({
@@ -1510,7 +1544,7 @@ export const registerRoutes = async (
accessApprovalRequestDAL,
accessApprovalRequestReviewerDAL,
additionalPrivilegeDAL,
membershipUserDAL
projectMembershipDAL
});
const accessApprovalRequestService = accessApprovalRequestServiceFactory({
@@ -1625,6 +1659,17 @@ export const registerRoutes = async (
membershipIdentityDAL,
membershipRoleDAL
});
const identityV2Service = identityV2ServiceFactory({
membershipIdentityDAL,
membershipRoleDAL,
identityMetadataDAL,
licenseService,
permissionService,
identityDAL: identityV2DAL,
keyStore
});
const identityProjectService = identityProjectServiceFactory({
identityProjectDAL,
membershipIdentityDAL,
@@ -1801,6 +1846,21 @@ export const registerRoutes = async (
groupDAL
});
const pkiAlertV2Service = pkiAlertV2ServiceFactory({
pkiAlertV2DAL,
pkiAlertChannelDAL,
pkiAlertHistoryDAL,
permissionService,
smtpService
});
const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({
queueService,
pkiAlertV2Service,
pkiAlertV2DAL,
pkiAlertHistoryDAL
});
const dynamicSecretProviders = buildDynamicSecretProviders({
gatewayService,
gatewayV2Service
@@ -2152,6 +2212,7 @@ export const registerRoutes = async (
certificateAuthorityDAL,
certificateProfileDAL,
certificateTemplateV2Service,
acmeAccountDAL,
internalCaService: internalCertificateAuthorityService,
permissionService,
certificateSyncDAL,
@@ -2178,6 +2239,30 @@ export const registerRoutes = async (
estEnrollmentConfigDAL
});
const acmeChallengeService = pkiAcmeChallengeServiceFactory({
acmeChallengeDAL
});
const pkiAcmeService = pkiAcmeServiceFactory({
projectDAL,
appConnectionDAL,
certificateDAL,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateProfileDAL,
certificateBodyDAL,
certificateSecretDAL,
acmeAccountDAL,
acmeOrderDAL,
acmeAuthDAL,
acmeOrderAuthDAL,
acmeChallengeDAL,
keyStore,
kmsService,
licenseService,
certificateV3Service,
acmeChallengeService
});
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
certificateAuthorityDAL,
@@ -2355,6 +2440,7 @@ export const registerRoutes = async (
await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts();
await pkiSubscriberQueue.startDailyAutoRenewalJob();
await pkiAlertV2Queue.init();
await certificateV3Queue.init();
await kmsService.startService(hsmStatus);
await microsoftTeamsService.start();
@@ -2392,7 +2478,8 @@ export const registerRoutes = async (
integrationAuth: integrationAuthService,
webhook: webhookService,
serviceToken: serviceTokenService,
identity: identityService,
identityV1: identityService,
identityV2: identityV2Service,
identityAuthTemplate: identityAuthTemplateService,
identityAccessToken: identityAccessTokenService,
identityTokenAuth: identityTokenAuthService,
@@ -2433,6 +2520,7 @@ export const registerRoutes = async (
certificateProfile: certificateProfileService,
certificateAuthorityCrl: certificateAuthorityCrlService,
certificateEst: certificateEstService,
pkiAcme: pkiAcmeService,
pit: pitService,
pkiAlert: pkiAlertService,
pkiCollection: pkiCollectionService,
@@ -2486,7 +2574,8 @@ export const registerRoutes = async (
role: roleService,
additionalPrivilege: additionalPrivilegeService,
identityProject: identityProjectService,
convertor: convertorService
convertor: convertorService,
pkiAlertV2: pkiAlertV2Service
});
const cronJobs: CronJob[] = [];
@@ -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(),
@@ -0,0 +1,88 @@
import { z } from "zod";
import { getConfig } from "@app/lib/config/env";
import { ForbiddenRequestError } from "@app/lib/errors";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { logger } from "@app/lib/logger";
import nock, { Definition } from "nock";
export const registerBddNockRouter = async (server: FastifyZodProvider) => {
const checkIfBddNockApiEnabled = () => {
const appCfg = getConfig();
// Note: Please note that this API is only available in development mode and only for BDD tests.
// This endpoint should NEVER BE ENABLED IN PRODUCTION!
if (appCfg.NODE_ENV !== "development" || !appCfg.isBddNockApiEnabled) {
throw new ForbiddenRequestError({ message: "BDD Nock API is not enabled" });
}
};
server.route({
method: "POST",
url: "/define",
schema: {
body: z.object({ definitions: z.unknown().array() }),
response: {
200: z.object({ status: z.string() })
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
checkIfBddNockApiEnabled();
const { body } = req;
const { definitions } = body;
logger.info(definitions, "Defining nock");
const processedDefinitions = definitions.map((definition: unknown) => {
const { path, ...rest } = definition as Definition;
return {
...rest,
path:
path !== undefined && typeof path === "string"
? path
: new RegExp((path as unknown as { regex: string }).regex ?? "")
} as Definition;
});
nock.define(processedDefinitions);
// Ensure we are activating the nocks, because we could have called `nock.restore()` before this call.
if (!nock.isActive()) {
nock.activate();
}
return { status: "ok" };
}
});
server.route({
method: "POST",
url: "/clean-all",
schema: {
response: {
200: z.object({ status: z.string() })
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => {
checkIfBddNockApiEnabled();
logger.info("Cleaning all nocks");
nock.cleanAll();
return { status: "ok" };
}
});
server.route({
method: "POST",
url: "/restore",
schema: {
response: {
200: z.object({ status: z.string() })
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async () => {
checkIfBddNockApiEnabled();
logger.info("Restore network requests from nock");
nock.restore();
return { status: "ok" };
}
});
};
@@ -44,7 +44,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean().default(false),
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional()
.optional(),
acmeConfig: z.object({}).optional()
})
.refine(
(data) => {
@@ -55,6 +56,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
if (data.apiConfig) {
return false;
}
if (data.acmeConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.API) {
if (!data.apiConfig) {
@@ -63,12 +67,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
if (data.estConfig) {
return false;
}
if (data.acmeConfig) {
return false;
}
}
if (data.enrollmentType === EnrollmentType.ACME) {
if (!data.acmeConfig) {
return false;
}
if (data.estConfig) {
return false;
}
if (data.apiConfig) {
return false;
}
}
return true;
},
{
message:
"EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration."
"EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration."
}
),
response: {
@@ -150,6 +168,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(),
renewBeforeDays: z.number().optional()
})
.optional(),
acmeConfig: z
.object({
id: z.string(),
directoryUrl: z.string()
})
.optional()
}).array(),
totalCount: z.number()
@@ -473,4 +497,101 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
return { certificates };
}
});
server.route({
method: "GET",
url: "/:id/certificates/latest-active-bundle",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
description: "Get latest active certificate bundle for a profile",
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
certificate: z.string().nullable(),
certificateChain: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const response = await server.services.certificateProfile.getLatestActiveCertificateBundle({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id
});
if (!response) {
return {
certificate: null,
certificateChain: null,
privateKey: null,
serialNumber: null
};
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: response.certObj.projectId,
event: {
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE,
metadata: {
certificateProfileId: response.profile.id,
certificateId: response.certObj.id,
commonName: response.certObj.commonName,
profileName: response.profile.slug,
serialNumber: response.certObj.serialNumber
}
}
});
return {
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey,
serialNumber: response.certObj.serialNumber
};
}
});
server.route({
method: "GET",
url: "/:id/acme/eab-secret/reveal",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
eabKid: z.string(),
eabSecret: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { eabKid, eabSecret } = await server.services.certificateProfile.revealAcmeEabSecret({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id
});
return { eabKid, eabSecret };
}
});
};
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import RE2 from "re2";
import { z } from "zod";
import { CertificatesSchema } from "@app/db/schemas";
@@ -616,4 +617,64 @@ export const registerCertRouter = async (server: FastifyZodProvider) => {
};
}
});
server.route({
method: "POST",
url: "/:serialNumber/pkcs12",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT]),
schema: {
hide: true,
tags: [ApiDocsTags.PkiCertificates],
description: "Download certificate in PKCS12 format",
params: z.object({
serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber)
}),
body: z.object({
password: z
.string()
.min(6, "Password must be at least 6 characters long")
.describe("Password for the keystore (minimum 6 characters)"),
alias: z.string().min(1, "Alias is required").describe("Alias for the certificate in the keystore")
}),
response: {
200: z.any().describe("PKCS12 keystore as binary data")
}
},
handler: async (req, reply) => {
const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({
serialNumber: req.params.serialNumber,
password: req.body.password,
alias: req.body.alias,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: cert.projectId,
event: {
type: EventType.EXPORT_CERT_PKCS12,
metadata: {
certId: cert.id,
cn: cert.commonName,
serialNumber: cert.serialNumber
}
}
});
addNoCacheHeaders(reply);
reply.header("Content-Type", "application/octet-stream");
reply.header(
"Content-Disposition",
`attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"`
);
return pkcs12Data;
}
});
};
@@ -19,7 +19,7 @@ import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity-
import { SanitizedProjectSchema } from "../sanitizedSchemas";
export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => {
export const registerDeprecatedIdentityProjectMembershipRouter = async (server: FastifyZodProvider) => {
server.route({
method: "POST",
url: "/:projectId/identity-memberships/:identityId",
@@ -293,7 +293,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
temporaryAccessEndTime: z.date().nullable().optional()
})
),
identity: IdentitiesSchema.pick({ name: true, id: true }).extend({
identity: IdentitiesSchema.pick({ name: true, id: true, projectId: true, orgId: true }).extend({
authMethods: z.array(z.string())
}),
project: SanitizedProjectSchema.pick({ name: true, id: true })
@@ -362,7 +362,9 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider)
temporaryAccessEndTime: z.date().nullable().optional()
})
),
identity: IdentitiesSchema.pick({ name: true, id: true }).extend({
lastLoginAuthMethod: z.string().nullable().optional(),
lastLoginTime: z.date().nullable().optional(),
identity: IdentitiesSchema.pick({ name: true, id: true, projectId: true, orgId: true }).extend({
authMethods: z.array(z.string())
}),
project: SanitizedProjectSchema.pick({ name: true, id: true })

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