mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 22:29:03 +00:00
Merge branch 'main' into PAM-10
This commit is contained in:
+1
-1
@@ -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,2 +1,2 @@
|
||||
DB_CONNECTION_URI=
|
||||
DB_CONNECTION_URI=postgres://infisical:infisical@localhost:5432/infisical
|
||||
AUDIT_LOGS_DB_CONNECTION_URI=
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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
|
||||
@@ -71,5 +71,6 @@ frontend-build
|
||||
cli/infisical-merge
|
||||
cli/test/infisical-merge
|
||||
/backend/binary
|
||||
backend/bdd/.bdd-infisical-bootstrap-result.json
|
||||
|
||||
/npm/bin
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# API URL to the Infisical server
|
||||
INFISICAL_API_URL="http://localhost:8080"
|
||||
# JWT token with admin permission for the cert projects
|
||||
INFISICAL_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoTWV0aG9kIjoiZW1haWwiLCJhdXRoVG9rZW5UeXBlIjoiYWNjZXNzVG9rZW4iLCJ1c2VySWQiOiJkOWZlMzMwZi00OTQwLTQ3ZmYtYmE4Yy0zZGUxYTVlYjYzNGEiLCJ0b2tlblZlcnNpb25JZCI6ImM4MWZhODY1LTFjYTAtNGNmZS1iNjM5LThlMDI3M2E2N2JjYyIsImFjY2Vzc1ZlcnNpb24iOjEsIm9yZ2FuaXphdGlvbklkIjoiM2I5OTRkNTktMjE5Ny00MjcwLWE3MGMtOTczMzdmZjlmYTRkIiwiaWF0IjoxNzYyMjAzMjQwLCJleHAiOjE3NjMwNjcyNDB9.KFYeMYAv3Ceis0hp-pTa8fsLLWbT-JcqhuWyIY0DWU0"
|
||||
# PKI project id
|
||||
PROJECT_ID="c051e74c-48a7-4724-832c-d5b496698546"
|
||||
# Certificate CA id
|
||||
CERT_CA_ID="2f0d9820-e5a8-48bb-aac8-deed9d868a1e"
|
||||
# Certificate template id
|
||||
CERT_TEMPLATE_ID="4dbf6bb0-6e86-4ee6-8550-9171428c8e82"
|
||||
# ACME profile ID
|
||||
PROFILE_ID="108c6303-ab8c-4986-ab88-eefe11bb5553"
|
||||
# ACME profile EAB secret
|
||||
EAB_SECRET="JHYxJDEwJFJldE9tb3dkUU9XVnJLZWFia3IxVC94L1pIbHRoQnJsNVRKZWFoV1hpNTczVHpwMFNGZzU4OGtuU3NVK1crVGM"
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
"""
|
||||
@@ -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} |
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBx7d0VqxwTYcJajFgz
|
||||
ja0PExBmxdZjEQRfGCMQY8GfHa0WpBUEwVtBD6XOGE5xZB2hZANiAATZXmgzkmKa
|
||||
oOPMR8fXRwq7ovx5D2vHORQtsglEUW65e0pBDCRwBbwvhS4ySYaW7mEUIFHyxocO
|
||||
khK4DJgtiqx13HCd3GYZPfPLDqhT7yhdZfS1OEvQqBqOZlyQ59HKxik=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDnPx90G0J4ba0CMTrh
|
||||
AT0kJkRGyhv5ePWyobdT75za/I9MpU/VsC8BG5uJBraxiSOhZANiAAQWEiTINq0t
|
||||
j+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ
|
||||
0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4SVQ9GzizHiJ//Qb71vrXbco=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -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-----
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
name = "bdd"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"acme>=5.1.0",
|
||||
"behave>=1.3.3",
|
||||
"dotenv>=0.9.9",
|
||||
"faker>=37.12.0",
|
||||
"glom>=24.11.0",
|
||||
"httpx>=0.28.1",
|
||||
"josepy>=2.2.0",
|
||||
"jq>=1.10.0",
|
||||
]
|
||||
Generated
+558
@@ -0,0 +1,558 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "acme"
|
||||
version = "5.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "josepy" },
|
||||
{ name = "pyopenssl" },
|
||||
{ name = "pyrfc3339" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/f6/897be0abeb0e64f0e6136a8a6369a54d2a603a44cb7a411f6d77dbafb4ac/acme-5.1.0.tar.gz", hash = "sha256:7b97820857d9baffed98bca50ab82bb6a636e447865d7a013a7bdd7972f03cda", size = 89982, upload-time = "2025-10-07T17:30:38.579Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/0b/4d0421412bb063f4393ae7ebf3a9a6fde621aed187a1140ccf7f9e22b823/acme-5.1.0-py3-none-any.whl", hash = "sha256:80e9c315d82302bb97279f4516ff31230d29195ab9d4a6c9411ceec20481b61e", size = 94151, upload-time = "2025-10-07T17:30:15.994Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bdd"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "acme" },
|
||||
{ name = "behave" },
|
||||
{ name = "dotenv" },
|
||||
{ name = "faker" },
|
||||
{ name = "glom" },
|
||||
{ name = "httpx" },
|
||||
{ name = "josepy" },
|
||||
{ name = "jq" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "acme", specifier = ">=5.1.0" },
|
||||
{ name = "behave", specifier = ">=1.3.3" },
|
||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||
{ name = "faker", specifier = ">=37.12.0" },
|
||||
{ name = "glom", specifier = ">=24.11.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "josepy", specifier = ">=2.2.0" },
|
||||
{ name = "jq", specifier = ">=1.10.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "behave"
|
||||
version = "1.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
{ name = "cucumber-expressions" },
|
||||
{ name = "cucumber-tag-expressions" },
|
||||
{ name = "parse" },
|
||||
{ name = "parse-type" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boltons"
|
||||
version = "25.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.10.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "charset-normalizer"
|
||||
version = "3.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cucumber-expressions"
|
||||
version = "18.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/f4e231167b23b3d7348aa1c90117ce8854fae186d6984ad66d705df24061/cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60", size = 22232, upload-time = "2024-10-28T11:38:48.672Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/e0/31ce90dad5234c3d52432bfce7562aa11cda4848aea90936a4be6c67d7ab/cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42", size = 20211, upload-time = "2024-10-28T11:38:47.101Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cucumber-tag-expressions"
|
||||
version = "8.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/77/b8868653e9c7d432433d4d4d5e99d5923b309b89c8b08bc7f0cb5657ba0b/cucumber_tag_expressions-8.0.0.tar.gz", hash = "sha256:4af80282ff0349918c332428176089094019af6e2a381a2fd8f1c62a7a6bb7e8", size = 8427, upload-time = "2025-10-14T17:01:27.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.9.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "face"
|
||||
version = "24.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "boltons" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/79/2484075a8549cd64beae697a8f664dee69a5ccf3a7439ee40c8f93c1978a/face-24.0.0.tar.gz", hash = "sha256:611e29a01ac5970f0077f9c577e746d48c082588b411b33a0dd55c4d872949f6", size = 62732, upload-time = "2024-11-02T05:24:26.095Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "37.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glom"
|
||||
version = "24.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "boltons" },
|
||||
{ name = "face" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/89/b57cfbc448189426f2e01b244fbe9226b059ef5423a9d49c1d335a1f1026/glom-24.11.0.tar.gz", hash = "sha256:4325f96759a912044af7b6c6bd0dba44ad8c1eb6038aab057329661d2021bb27", size = 195120, upload-time = "2024-11-02T23:17:50.405Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "josepy"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jq"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308, upload-time = "2025-07-14T18:54:53.679Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103, upload-time = "2025-07-14T18:52:39.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325, upload-time = "2025-07-14T18:52:40.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212, upload-time = "2025-07-14T18:52:42.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068, upload-time = "2025-07-14T18:52:44.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259, upload-time = "2025-07-14T18:52:47.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075, upload-time = "2025-07-14T18:52:50.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475, upload-time = "2025-07-14T18:52:53.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416, upload-time = "2025-07-14T18:52:55.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113, upload-time = "2025-07-14T18:52:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316, upload-time = "2025-07-14T18:52:59.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325, upload-time = "2025-07-14T18:53:01.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344, upload-time = "2025-07-14T18:53:03.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874, upload-time = "2025-07-14T18:53:05.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355, upload-time = "2025-07-14T18:53:07.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546, upload-time = "2025-07-14T18:53:11.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777, upload-time = "2025-07-14T18:53:14.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307, upload-time = "2025-07-14T18:53:17.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830, upload-time = "2025-07-14T18:53:19.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285, upload-time = "2025-07-14T18:53:21.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094, upload-time = "2025-07-14T18:53:23.687Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse"
|
||||
version = "1.20.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse-type"
|
||||
version = "0.6.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "parse" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.23"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyopenssl"
|
||||
version = "25.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyrfc3339"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "idna" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2025.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
Generated
+121
-24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+7
-1
@@ -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
|
||||
|
||||
Vendored
+72
@@ -266,9 +266,39 @@ import {
|
||||
TOrgRoles,
|
||||
TOrgRolesInsert,
|
||||
TOrgRolesUpdate,
|
||||
TPkiAcmeAccounts,
|
||||
TPkiAcmeAccountsInsert,
|
||||
TPkiAcmeAccountsUpdate,
|
||||
TPkiAcmeAuths,
|
||||
TPkiAcmeAuthsInsert,
|
||||
TPkiAcmeAuthsUpdate,
|
||||
TPkiAcmeChallenges,
|
||||
TPkiAcmeChallengesInsert,
|
||||
TPkiAcmeChallengesUpdate,
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate,
|
||||
TPkiAcmeOrderAuths,
|
||||
TPkiAcmeOrderAuthsInsert,
|
||||
TPkiAcmeOrderAuthsUpdate,
|
||||
TPkiAcmeOrders,
|
||||
TPkiAcmeOrdersInsert,
|
||||
TPkiAcmeOrdersUpdate,
|
||||
TPkiAlertChannels,
|
||||
TPkiAlertChannelsInsert,
|
||||
TPkiAlertChannelsUpdate,
|
||||
TPkiAlertHistory,
|
||||
TPkiAlertHistoryCertificate,
|
||||
TPkiAlertHistoryCertificateInsert,
|
||||
TPkiAlertHistoryCertificateUpdate,
|
||||
TPkiAlertHistoryInsert,
|
||||
TPkiAlertHistoryUpdate,
|
||||
TPkiAlerts,
|
||||
TPkiAlertsInsert,
|
||||
TPkiAlertsUpdate,
|
||||
TPkiAlertsV2,
|
||||
TPkiAlertsV2Insert,
|
||||
TPkiAlertsV2Update,
|
||||
TPkiApiEnrollmentConfigs,
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
TPkiApiEnrollmentConfigsUpdate,
|
||||
@@ -709,6 +739,32 @@ declare module "knex/types/tables" {
|
||||
TPkiApiEnrollmentConfigsInsert,
|
||||
TPkiApiEnrollmentConfigsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeEnrollmentConfig]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeEnrollmentConfigs,
|
||||
TPkiAcmeEnrollmentConfigsInsert,
|
||||
TPkiAcmeEnrollmentConfigsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeAccount]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeAccounts,
|
||||
TPkiAcmeAccountsInsert,
|
||||
TPkiAcmeAccountsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeOrder]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeOrders,
|
||||
TPkiAcmeOrdersInsert,
|
||||
TPkiAcmeOrdersUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType<TPkiAcmeAuths, TPkiAcmeAuthsInsert, TPkiAcmeAuthsUpdate>;
|
||||
[TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeOrderAuths,
|
||||
TPkiAcmeOrderAuthsInsert,
|
||||
TPkiAcmeOrderAuthsUpdate
|
||||
>;
|
||||
[TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType<
|
||||
TPkiAcmeChallenges,
|
||||
TPkiAcmeChallengesInsert,
|
||||
TPkiAcmeChallengesUpdate
|
||||
>;
|
||||
[TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType<
|
||||
TCertificateTemplateEstConfigs,
|
||||
TCertificateTemplateEstConfigsInsert,
|
||||
@@ -725,6 +781,22 @@ declare module "knex/types/tables" {
|
||||
TCertificateSecretsUpdate
|
||||
>;
|
||||
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>;
|
||||
[TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType<TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update>;
|
||||
[TableName.PkiAlertChannels]: KnexOriginal.CompositeTableType<
|
||||
TPkiAlertChannels,
|
||||
TPkiAlertChannelsInsert,
|
||||
TPkiAlertChannelsUpdate
|
||||
>;
|
||||
[TableName.PkiAlertHistory]: KnexOriginal.CompositeTableType<
|
||||
TPkiAlertHistory,
|
||||
TPkiAlertHistoryInsert,
|
||||
TPkiAlertHistoryUpdate
|
||||
>;
|
||||
[TableName.PkiAlertHistoryCertificate]: KnexOriginal.CompositeTableType<
|
||||
TPkiAlertHistoryCertificate,
|
||||
TPkiAlertHistoryCertificateInsert,
|
||||
TPkiAlertHistoryCertificateUpdate
|
||||
>;
|
||||
[TableName.PkiCollection]: KnexOriginal.CompositeTableType<
|
||||
TPkiCollections,
|
||||
TPkiCollectionsInsert,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled"))) {
|
||||
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
|
||||
table.boolean("isSecretSyncErrorNotificationEnabled").notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels"))) {
|
||||
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
|
||||
table.text("secretSyncErrorChannels").notNullable().defaultTo("");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled")) {
|
||||
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
|
||||
table.dropColumn("isSecretSyncErrorNotificationEnabled");
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels")) {
|
||||
await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => {
|
||||
table.dropColumn("secretSyncErrorChannels");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAlertsV2))) {
|
||||
await knex.schema.createTable(TableName.PkiAlertsV2, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name").notNullable();
|
||||
t.text("description").nullable();
|
||||
t.string("eventType").notNullable();
|
||||
t.string("alertBefore").nullable();
|
||||
t.jsonb("filters").nullable();
|
||||
t.boolean("enabled").defaultTo(true);
|
||||
t.string("projectId").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.index("projectId");
|
||||
t.unique(["name", "projectId"]);
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAlertChannels))) {
|
||||
await knex.schema.createTable(TableName.PkiAlertChannels, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("alertId").notNullable();
|
||||
t.string("channelType").notNullable();
|
||||
t.jsonb("config").notNullable();
|
||||
t.boolean("enabled").defaultTo(true);
|
||||
t.timestamps(true, true, true);
|
||||
|
||||
t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE");
|
||||
t.index("alertId");
|
||||
t.index("channelType");
|
||||
t.index("enabled");
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAlertHistory))) {
|
||||
await knex.schema.createTable(TableName.PkiAlertHistory, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("alertId").notNullable();
|
||||
t.timestamp("triggeredAt").defaultTo(knex.fn.now());
|
||||
t.boolean("hasNotificationSent").defaultTo(false);
|
||||
t.text("notificationError").nullable();
|
||||
t.timestamps(true, true, true);
|
||||
|
||||
t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE");
|
||||
t.index("alertId");
|
||||
t.index("triggeredAt");
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate))) {
|
||||
await knex.schema.createTable(TableName.PkiAlertHistoryCertificate, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.uuid("alertHistoryId").notNullable();
|
||||
t.uuid("certificateId").notNullable();
|
||||
t.timestamps(true, true, true);
|
||||
|
||||
t.foreign("alertHistoryId").references("id").inTable(TableName.PkiAlertHistory).onDelete("CASCADE");
|
||||
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
|
||||
t.index("alertHistoryId");
|
||||
t.index("certificateId");
|
||||
t.unique(["alertHistoryId", "certificateId"]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate)) {
|
||||
await knex.schema.dropTable(TableName.PkiAlertHistoryCertificate);
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable(TableName.PkiAlertHistory)) {
|
||||
await knex.schema.dropTable(TableName.PkiAlertHistory);
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable(TableName.PkiAlertChannels)) {
|
||||
await knex.schema.dropTable(TableName.PkiAlertChannels);
|
||||
}
|
||||
|
||||
if (await knex.schema.hasTable(TableName.PkiAlertsV2)) {
|
||||
await knex.schema.dropTable(TableName.PkiAlertsV2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
// Notice: the old constraint name is "enrollmentType_check" instead of "enrollment_type_check"
|
||||
// with psql, if there's no quote around an identifier, it will be lowercased.
|
||||
// this may cause issues in migrations as Knex sometimes generates identifiers without quotes.
|
||||
// to avoid this, we use a new constraint name that contains only lowercase letters and underscores.
|
||||
const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check";
|
||||
const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check";
|
||||
|
||||
const PUBLIC_KEY_THUMBPRINT_ALG_INDEX = "pki_acme_accounts_publicKey_thumbprint_alg_index";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
// Create PkiAcmeEnrollmentConfig table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeEnrollmentConfig, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.binary("encryptedEabSecret").notNullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig);
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId"))) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.uuid("acmeConfigId");
|
||||
t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL");
|
||||
t.index("acmeConfigId");
|
||||
});
|
||||
}
|
||||
|
||||
await dropConstraintIfExists(TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex);
|
||||
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) {
|
||||
// Notice: it's okay to use `.checkIn(...).alter();` here because the constraint name is all lowercase.
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.string("enrollmentType")
|
||||
.notNullable()
|
||||
.checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT)
|
||||
.alter();
|
||||
});
|
||||
}
|
||||
|
||||
// Create PkiAcmeAccount table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeAccount))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeAccount, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
// Foreign key to PkiCertificateProfile
|
||||
t.uuid("profileId").notNullable();
|
||||
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("CASCADE");
|
||||
|
||||
// Multi-value emails array
|
||||
t.specificType("emails", "text[]").notNullable();
|
||||
|
||||
// Public key (JWK format)
|
||||
t.jsonb("publicKey").notNullable();
|
||||
// Public key thumbprint
|
||||
t.string("publicKeyThumbprint").notNullable();
|
||||
// The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc.
|
||||
t.string("alg").notNullable();
|
||||
// We may need to look up existing accounts by public key thumbprint and algorithm, so we index on both of them.
|
||||
t.index(["publicKeyThumbprint", "alg"], PUBLIC_KEY_THUMBPRINT_ALG_INDEX);
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeAccount);
|
||||
}
|
||||
|
||||
// Create PkiAcmeOrder table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeOrder))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeOrder, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
// Foreign key to PkiAcmeAccount
|
||||
t.uuid("accountId").notNullable();
|
||||
t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE");
|
||||
|
||||
// Foreign key to certificate
|
||||
t.uuid("certificateId").nullable();
|
||||
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
|
||||
|
||||
t.timestamp("notBefore").nullable();
|
||||
t.timestamp("notAfter").nullable();
|
||||
|
||||
t.timestamp("expiresAt").notNullable();
|
||||
|
||||
t.text("csr").nullable();
|
||||
t.text("error").nullable();
|
||||
// Order status
|
||||
t.string("status").notNullable(); // pending, ready, processing, valid, invalid
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeOrder);
|
||||
}
|
||||
|
||||
// Create PkiAcmeAuth table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeAuth))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeAuth, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
// Foreign key to PkiAcmeAccount
|
||||
t.uuid("accountId").notNullable();
|
||||
t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE");
|
||||
|
||||
// Authorization status
|
||||
t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked
|
||||
|
||||
// Token used to validate the authorization through ACME challenge
|
||||
t.string("token").nullable();
|
||||
|
||||
// Identifier type and value
|
||||
t.string("identifierType").notNullable(); // dns
|
||||
t.string("identifierValue").notNullable(); // domain name
|
||||
|
||||
// Expiration timestamp
|
||||
t.timestamp("expiresAt").notNullable();
|
||||
|
||||
// Optional link to issued certificate
|
||||
t.uuid("certificateId").nullable();
|
||||
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeAuth);
|
||||
}
|
||||
|
||||
// Create PkiAcmeOrderAuth table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeOrderAuth))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeOrderAuth, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
// Foreign key to PkiAcmeOrder
|
||||
t.uuid("orderId").notNullable();
|
||||
t.foreign("orderId").references("id").inTable(TableName.PkiAcmeOrder).onDelete("CASCADE");
|
||||
|
||||
// Foreign key to PkiAcmeAuth
|
||||
t.uuid("authId").notNullable();
|
||||
t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE");
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth);
|
||||
}
|
||||
|
||||
// Create PkiAcmeChallenge table
|
||||
if (!(await knex.schema.hasTable(TableName.PkiAcmeChallenge))) {
|
||||
await knex.schema.createTable(TableName.PkiAcmeChallenge, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
|
||||
// Foreign key to PkiAcmeAuth
|
||||
t.uuid("authId").notNullable();
|
||||
t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE");
|
||||
|
||||
// Challenge type
|
||||
t.string("type").notNullable(); // http-01, dns-01, tls-alpn-01
|
||||
|
||||
// Challenge status
|
||||
t.string("status").notNullable(); // pending, processing, valid, invalid
|
||||
|
||||
// Error message when the challenge fails
|
||||
t.string("error").nullable();
|
||||
|
||||
// Validation timestamp
|
||||
t.timestamp("validatedAt").nullable();
|
||||
|
||||
t.timestamps(true, true, true);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiAcmeChallenge);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
// Drop tables in reverse dependency order
|
||||
|
||||
// Drop PkiAcmeChallenge first (depends on PkiAcmeAuth)
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeChallenge);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeChallenge);
|
||||
}
|
||||
|
||||
// Drop PkiAcmeOrderAuth (depends on PkiAcmeOrder and PkiAcmeAuth)
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeOrderAuth)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeOrderAuth);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth);
|
||||
}
|
||||
|
||||
// Drop PkiAcmeAuth (depends on PkiAcmeAccount and Certificate)
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeAuth)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeAuth);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeAuth);
|
||||
}
|
||||
|
||||
// Drop PkiAcmeOrder (depends on PkiAcmeAccount)
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeOrder)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeOrder);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrder);
|
||||
}
|
||||
|
||||
// Drop PkiAcmeAccount (depends on PkiCertificateProfile)
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeAccount);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount);
|
||||
}
|
||||
|
||||
// Change enrollmentType check constraint to allow acme
|
||||
await dropConstraintIfExists(TableName.PkiCertificateProfile, NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex);
|
||||
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) {
|
||||
// Notice: DO NOT USE
|
||||
//
|
||||
// `t.string("enrollmentType").checkIn(["api", "est"], OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter();`
|
||||
//
|
||||
// here because knex will generate a constraint name without quotes, and it will be treated as lowercased and causing problems.
|
||||
await knex.raw(
|
||||
`ALTER TABLE ??
|
||||
ADD CONSTRAINT ?? CHECK (?? IN ('api', 'est'));
|
||||
`,
|
||||
[TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, "enrollmentType"]
|
||||
);
|
||||
}
|
||||
|
||||
// Drop acmeConfigId column
|
||||
if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.dropForeign(["acmeConfigId"]);
|
||||
t.dropIndex("acmeConfigId");
|
||||
t.dropColumn("acmeConfigId");
|
||||
});
|
||||
}
|
||||
|
||||
// Drop PkiAcmeEnrollmentConfig
|
||||
if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) {
|
||||
await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -92,7 +92,17 @@ export * from "./pam-accounts";
|
||||
export * from "./pam-folders";
|
||||
export * from "./pam-resources";
|
||||
export * from "./pam-sessions";
|
||||
export * from "./pki-acme-accounts";
|
||||
export * from "./pki-acme-auths";
|
||||
export * from "./pki-acme-challenges";
|
||||
export * from "./pki-acme-enrollment-configs";
|
||||
export * from "./pki-acme-order-auths";
|
||||
export * from "./pki-acme-orders";
|
||||
export * from "./pki-alert-channels";
|
||||
export * from "./pki-alert-history";
|
||||
export * from "./pki-alert-history-certificate";
|
||||
export * from "./pki-alerts";
|
||||
export * from "./pki-alerts-v2";
|
||||
export * from "./pki-api-enrollment-configs";
|
||||
export * from "./pki-certificate-profiles";
|
||||
export * from "./pki-certificate-templates-v2";
|
||||
|
||||
@@ -27,8 +27,13 @@ export enum TableName {
|
||||
PkiCertificateProfile = "pki_certificate_profiles",
|
||||
PkiEstEnrollmentConfig = "pki_est_enrollment_configs",
|
||||
PkiApiEnrollmentConfig = "pki_api_enrollment_configs",
|
||||
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
|
||||
PkiSubscriber = "pki_subscribers",
|
||||
PkiAlert = "pki_alerts",
|
||||
PkiAlertsV2 = "pki_alerts_v2",
|
||||
PkiAlertChannels = "pki_alert_channels",
|
||||
PkiAlertHistory = "pki_alert_history",
|
||||
PkiAlertHistoryCertificate = "pki_alert_history_certificate",
|
||||
PkiCollection = "pki_collections",
|
||||
PkiCollectionItem = "pki_collection_items",
|
||||
Groups = "groups",
|
||||
@@ -210,7 +215,14 @@ export enum TableName {
|
||||
PamAccount = "pam_accounts",
|
||||
PamSession = "pam_sessions",
|
||||
|
||||
VaultExternalMigrationConfig = "vault_external_migration_configs"
|
||||
VaultExternalMigrationConfig = "vault_external_migration_configs",
|
||||
|
||||
// PKI ACME
|
||||
PkiAcmeAccount = "pki_acme_accounts",
|
||||
PkiAcmeOrder = "pki_acme_orders",
|
||||
PkiAcmeOrderAuth = "pki_acme_order_auths",
|
||||
PkiAcmeAuth = "pki_acme_auths",
|
||||
PkiAcmeChallenge = "pki_acme_challenges"
|
||||
}
|
||||
|
||||
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeAccountsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
profileId: z.string().uuid(),
|
||||
emails: z.string().array(),
|
||||
publicKey: z.unknown(),
|
||||
publicKeyThumbprint: z.string(),
|
||||
alg: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeAccounts = z.infer<typeof PkiAcmeAccountsSchema>;
|
||||
export type TPkiAcmeAccountsInsert = Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeAccountsUpdate = Partial<Omit<z.input<typeof PkiAcmeAccountsSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeAuthsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
status: z.string(),
|
||||
token: z.string().nullable().optional(),
|
||||
identifierType: z.string(),
|
||||
identifierValue: z.string(),
|
||||
expiresAt: z.date(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeAuths = z.infer<typeof PkiAcmeAuthsSchema>;
|
||||
export type TPkiAcmeAuthsInsert = Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeAuthsSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeChallengesSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
authId: z.string().uuid(),
|
||||
type: z.string(),
|
||||
status: z.string(),
|
||||
error: z.string().nullable().optional(),
|
||||
validatedAt: z.date().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeChallenges = z.infer<typeof PkiAcmeChallengesSchema>;
|
||||
export type TPkiAcmeChallengesInsert = Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeChallengesUpdate = Partial<Omit<z.input<typeof PkiAcmeChallengesSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,23 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { zodBuffer } from "@app/lib/zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeEnrollmentConfigsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
encryptedEabSecret: zodBuffer,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeEnrollmentConfigs = z.infer<typeof PkiAcmeEnrollmentConfigsSchema>;
|
||||
export type TPkiAcmeEnrollmentConfigsInsert = Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeEnrollmentConfigsUpdate = Partial<
|
||||
Omit<z.input<typeof PkiAcmeEnrollmentConfigsSchema>, TImmutableDBKeys>
|
||||
>;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeOrderAuthsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
orderId: z.string().uuid(),
|
||||
authId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeOrderAuths = z.infer<typeof PkiAcmeOrderAuthsSchema>;
|
||||
export type TPkiAcmeOrderAuthsInsert = Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeOrderAuthsUpdate = Partial<Omit<z.input<typeof PkiAcmeOrderAuthsSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,26 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAcmeOrdersSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
accountId: z.string().uuid(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
notBefore: z.date().nullable().optional(),
|
||||
notAfter: z.date().nullable().optional(),
|
||||
expiresAt: z.date(),
|
||||
csr: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAcmeOrders = z.infer<typeof PkiAcmeOrdersSchema>;
|
||||
export type TPkiAcmeOrdersInsert = Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAcmeOrdersUpdate = Partial<Omit<z.input<typeof PkiAcmeOrdersSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAlertChannelsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
alertId: z.string().uuid(),
|
||||
channelType: z.string(),
|
||||
config: z.unknown(),
|
||||
enabled: z.boolean().default(true).nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAlertChannels = z.infer<typeof PkiAlertChannelsSchema>;
|
||||
export type TPkiAlertChannelsInsert = Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiAlertChannelsUpdate = Partial<Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAlertHistoryCertificateSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
alertHistoryId: z.string().uuid(),
|
||||
certificateId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAlertHistoryCertificate = z.infer<typeof PkiAlertHistoryCertificateSchema>;
|
||||
export type TPkiAlertHistoryCertificateInsert = Omit<
|
||||
z.input<typeof PkiAlertHistoryCertificateSchema>,
|
||||
TImmutableDBKeys
|
||||
>;
|
||||
export type TPkiAlertHistoryCertificateUpdate = Partial<
|
||||
Omit<z.input<typeof PkiAlertHistoryCertificateSchema>, TImmutableDBKeys>
|
||||
>;
|
||||
@@ -0,0 +1,22 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAlertHistorySchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
alertId: z.string().uuid(),
|
||||
triggeredAt: z.date().nullable().optional(),
|
||||
hasNotificationSent: z.boolean().default(false).nullable().optional(),
|
||||
notificationError: z.string().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAlertHistory = z.infer<typeof PkiAlertHistorySchema>;
|
||||
export type TPkiAlertHistoryInsert = Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>;
|
||||
export type TPkiAlertHistoryUpdate = Partial<Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>>;
|
||||
@@ -0,0 +1,25 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const PkiAlertsV2Schema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
eventType: z.string(),
|
||||
alertBefore: z.string().nullable().optional(),
|
||||
filters: z.unknown().nullable().optional(),
|
||||
enabled: z.boolean().default(true).nullable().optional(),
|
||||
projectId: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
export type TPkiAlertsV2 = z.infer<typeof PkiAlertsV2Schema>;
|
||||
export type TPkiAlertsV2Insert = Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>;
|
||||
export type TPkiAlertsV2Update = Partial<Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>>;
|
||||
@@ -18,7 +18,8 @@ export const PkiCertificateProfilesSchema = z.object({
|
||||
estConfigId: z.string().uuid().nullable().optional(),
|
||||
apiConfigId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
acmeConfigId: z.string().uuid().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
|
||||
|
||||
@@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({
|
||||
isSecretRequestNotificationEnabled: z.boolean().default(false),
|
||||
secretRequestChannels: z.string().default(""),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
updatedAt: z.date(),
|
||||
isSecretSyncErrorNotificationEnabled: z.boolean().default(false),
|
||||
secretSyncErrorChannels: z.string().default("")
|
||||
});
|
||||
|
||||
export type TProjectSlackConfigs = z.infer<typeof ProjectSlackConfigsSchema>;
|
||||
|
||||
@@ -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" }
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack
|
||||
export enum TriggerFeature {
|
||||
SECRET_APPROVAL = "secret-approval",
|
||||
ACCESS_REQUEST = "access-request",
|
||||
ACCESS_REQUEST_UPDATED = "access-request-updated"
|
||||
ACCESS_REQUEST_UPDATED = "access-request-updated",
|
||||
SECRET_SYNC_ERROR = "secret-sync-error"
|
||||
}
|
||||
|
||||
export type TNotification =
|
||||
@@ -20,6 +21,7 @@ export type TNotification =
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
secretKeys: string[];
|
||||
approvalUrl: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -31,6 +33,7 @@ export type TNotification =
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
permissions: string[];
|
||||
approvalUrl: string;
|
||||
note?: string;
|
||||
@@ -50,6 +53,21 @@ export type TNotification =
|
||||
editNote?: string;
|
||||
editorFullName?: string;
|
||||
editorEmail?: string;
|
||||
projectPath: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: TriggerFeature.SECRET_SYNC_ERROR;
|
||||
payload: {
|
||||
syncName: string;
|
||||
syncActionLabel: string;
|
||||
syncDestination: string;
|
||||
failureMessage: string;
|
||||
syncUrl: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
projectName: string;
|
||||
projectPath: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export enum QueueName {
|
||||
AuditLogPrune = "audit-log-prune",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||
PkiSyncCleanup = "pki-sync-cleanup",
|
||||
PkiSubscriber = "pki-subscriber",
|
||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||
@@ -90,6 +91,7 @@ export enum QueueJobs {
|
||||
AuditLogPrune = "audit-log-prune-job",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||
PkiSyncCleanup = "pki-sync-cleanup-job",
|
||||
SecWebhook = "secret-webhook-trigger",
|
||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||
@@ -159,6 +161,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.DailyExpiringPkiItemAlert;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.DailyPkiAlertV2Processing]: {
|
||||
name: QueueJobs.DailyPkiAlertV2Processing;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PkiSyncCleanup]: {
|
||||
name: QueueJobs.PkiSyncCleanup;
|
||||
payload: undefined;
|
||||
|
||||
@@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas";
|
||||
const isScimRoutes = (pathname: string) =>
|
||||
pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups");
|
||||
|
||||
const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/pki/acme/");
|
||||
|
||||
export const addErrorsToResponseSchemas = fp(async (server) => {
|
||||
server.addHook("onRoute", (routeOptions) => {
|
||||
if (routeOptions.schema && routeOptions.schema.response && !isScimRoutes(routeOptions.path)) {
|
||||
if (
|
||||
routeOptions.schema &&
|
||||
routeOptions.schema.response &&
|
||||
!isScimRoutes(routeOptions.path) &&
|
||||
!isAcmeRoutes(routeOptions.path)
|
||||
) {
|
||||
routeOptions.schema.response = {
|
||||
...DefaultResponseErrorsSchema,
|
||||
...routeOptions.schema.response
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
+5
-3
@@ -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
Reference in New Issue
Block a user