diff --git a/.env.example b/.env.example index 5220d5a03..f67488c23 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.migration.example b/.env.migration.example index 2c5f5b957..dfc54d171 100644 --- a/.env.migration.example +++ b/.env.migration.example @@ -1,2 +1,2 @@ -DB_CONNECTION_URI= +DB_CONNECTION_URI=postgres://infisical:infisical@localhost:5432/infisical AUDIT_LOGS_DB_CONNECTION_URI= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 16a828578..a8a64e7b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,4 @@ --- -- [ ] 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). πŸ“ - - \ No newline at end of file +- [ ] 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). πŸ“ \ No newline at end of file diff --git a/.github/workflows/run-backend-bdd-tests.yml b/.github/workflows/run-backend-bdd-tests.yml new file mode 100644 index 000000000..7b54aa44e --- /dev/null +++ b/.github/workflows/run-backend-bdd-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index f2a23324b..b4e9a07c2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,6 @@ frontend-build cli/infisical-merge cli/test/infisical-merge /backend/binary +backend/bdd/.bdd-infisical-bootstrap-result.json /npm/bin diff --git a/.infisicalignore b/.infisicalignore index b935763c8..ec1cbfe16 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index c5017f8fa..1a44117a4 100644 --- a/README.md +++ b/README.md @@ -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 | | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | diff --git a/backend/bdd/.env.example b/backend/bdd/.env.example new file mode 100644 index 000000000..8e59c033d --- /dev/null +++ b/backend/bdd/.env.example @@ -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" diff --git a/backend/bdd/.python-version b/backend/bdd/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/backend/bdd/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/bdd/README.md b/backend/bdd/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py new file mode 100644 index 000000000..9a2e9f90b --- /dev/null +++ b/backend/bdd/features/environment.py @@ -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) diff --git a/backend/bdd/features/pki/acme/access-control.feature b/backend/bdd/features/pki/acme/access-control.feature new file mode 100644 index 000000000..6615d00f8 --- /dev/null +++ b/backend/bdd/features/pki/acme/access-control.feature @@ -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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "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 "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "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 "" + + 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 | diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature new file mode 100644 index 000000000..589c5ab24 --- /dev/null +++ b/backend/bdd/features/pki/acme/account.feature @@ -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 "" with secret "" as acme_account + And the value error with jq ".type" should be equal to "" + And the value error with jq ".detail" should be equal to "" + + 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 "" 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 | diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature new file mode 100644 index 000000000..46cc9d4e2 --- /dev/null +++ b/backend/bdd/features/pki/acme/auth.feature @@ -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" + } + """ diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature new file mode 100644 index 000000000..3c292e8ba --- /dev/null +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -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 diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature new file mode 100644 index 000000000..67f73aab2 --- /dev/null +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -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" diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature new file mode 100644 index 000000000..664ff7457 --- /dev/null +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -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" + } + """ diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature new file mode 100644 index 000000000..26bfd84ad --- /dev/null +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -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" + ] + """ \ No newline at end of file diff --git a/backend/bdd/features/pki/acme/nonce.feature b/backend/bdd/features/pki/acme/nonce.feature new file mode 100644 index 000000000..9a55ae284 --- /dev/null +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "oFvnlFP1wIhRlYS2jTaXbA", + "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" 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 with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce_value}", + "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} | diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature new file mode 100644 index 000000000..19f467f00 --- /dev/null +++ b/backend/bdd/features/pki/acme/order.feature @@ -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": "", "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": "" } + ] + } + } + """ + + 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 | + diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py new file mode 100644 index 000000000..46b10c13e --- /dev/null +++ b/backend/bdd/features/steps/pki_acme.py @@ -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 diff --git a/backend/bdd/features/steps/utils.py b/backend/bdd/features/steps/utils.py new file mode 100644 index 000000000..4ee7c8921 --- /dev/null +++ b/backend/bdd/features/steps/utils.py @@ -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"(? 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) diff --git a/backend/bdd/pebble/localhost/cert.pem b/backend/bdd/pebble/localhost/cert.pem new file mode 100644 index 000000000..9117526df --- /dev/null +++ b/backend/bdd/pebble/localhost/cert.pem @@ -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----- diff --git a/backend/bdd/pebble/localhost/key.pem b/backend/bdd/pebble/localhost/key.pem new file mode 100644 index 000000000..93b93eada --- /dev/null +++ b/backend/bdd/pebble/localhost/key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBx7d0VqxwTYcJajFgz +ja0PExBmxdZjEQRfGCMQY8GfHa0WpBUEwVtBD6XOGE5xZB2hZANiAATZXmgzkmKa +oOPMR8fXRwq7ovx5D2vHORQtsglEUW65e0pBDCRwBbwvhS4ySYaW7mEUIFHyxocO +khK4DJgtiqx13HCd3GYZPfPLDqhT7yhdZfS1OEvQqBqOZlyQ59HKxik= +-----END PRIVATE KEY----- diff --git a/backend/bdd/pebble/pebble-config.json b/backend/bdd/pebble/pebble-config.json new file mode 100644 index 000000000..013f6ff64 --- /dev/null +++ b/backend/bdd/pebble/pebble-config.json @@ -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 + } + } + } +} \ No newline at end of file diff --git a/backend/bdd/pebble/pebble.minica.key.pem b/backend/bdd/pebble/pebble.minica.key.pem new file mode 100644 index 000000000..322b4e88e --- /dev/null +++ b/backend/bdd/pebble/pebble.minica.key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDnPx90G0J4ba0CMTrh +AT0kJkRGyhv5ePWyobdT75za/I9MpU/VsC8BG5uJBraxiSOhZANiAAQWEiTINq0t +j+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ +0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4SVQ9GzizHiJ//Qb71vrXbco= +-----END PRIVATE KEY----- diff --git a/backend/bdd/pebble/pebble.minica.pem b/backend/bdd/pebble/pebble.minica.pem new file mode 100644 index 000000000..030ca32bb --- /dev/null +++ b/backend/bdd/pebble/pebble.minica.pem @@ -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----- diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml new file mode 100644 index 000000000..4f19cb0de --- /dev/null +++ b/backend/bdd/pyproject.toml @@ -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", +] diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock new file mode 100644 index 000000000..8ae5b6c01 --- /dev/null +++ b/backend/bdd/uv.lock @@ -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" }, +] diff --git a/backend/package-lock.json b/backend/package-lock.json index a6cef3888..8f1112cb7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 9bcd63cf1..db94de681 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a2bc332f8..6ef775f90 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -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 diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7ff31ed99..603df5f6c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -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; + [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; + [TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType; + [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, diff --git a/backend/src/db/migrations/20251023100246_project-identity.ts b/backend/src/db/migrations/20251023100246_project-identity.ts new file mode 100644 index 000000000..4a54c6c43 --- /dev/null +++ b/backend/src/db/migrations/20251023100246_project-identity.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + const hasProjectIdCol = await knex.schema.hasColumn(TableName.Identity, "projectId"); + if (hasProjectIdCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("projectId"); + }); + } +} diff --git a/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts b/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts new file mode 100644 index 000000000..203333fed --- /dev/null +++ b/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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"); + }); + } +} diff --git a/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts new file mode 100644 index 000000000..d542c2278 --- /dev/null +++ b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts @@ -0,0 +1,87 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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); + } +} diff --git a/backend/src/db/migrations/20251104234547_add-pki-acme.ts b/backend/src/db/migrations/20251104234547_add-pki-acme.ts new file mode 100644 index 000000000..9dff45ccb --- /dev/null +++ b/backend/src/db/migrations/20251104234547_add-pki-acme.ts @@ -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 { + // 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 { + // 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); + } +} diff --git a/backend/src/db/migrations/20251106172316_delete-pki-templates-on-project-removal.ts b/backend/src/db/migrations/20251106172316_delete-pki-templates-on-project-removal.ts new file mode 100644 index 000000000..4d98f8af4 --- /dev/null +++ b/backend/src/db/migrations/20251106172316_delete-pki-templates-on-project-removal.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + 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 { + 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); + }); + } +} diff --git a/backend/src/db/migrations/utils/dropConstraintIfExists.ts b/backend/src/db/migrations/utils/dropConstraintIfExists.ts index bfe487d49..93985ca76 100644 --- a/backend/src/db/migrations/utils/dropConstraintIfExists.ts +++ b/backend/src/db/migrations/utils/dropConstraintIfExists.ts @@ -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]); diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index 06c37ff22..0814d665e 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -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; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fba195746..78dcb1980 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e10c6dcbe..444a6bd97 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -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"; diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts new file mode 100644 index 000000000..69b1ffe49 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -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; +export type TPkiAcmeAccountsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAccountsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts new file mode 100644 index 000000000..7f20e0f24 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -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; +export type TPkiAcmeAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-challenges.ts b/backend/src/db/schemas/pki-acme-challenges.ts new file mode 100644 index 000000000..18245bb76 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-challenges.ts @@ -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; +export type TPkiAcmeChallengesInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeChallengesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts new file mode 100644 index 000000000..f0592319b --- /dev/null +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -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; +export type TPkiAcmeEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-acme-order-auths.ts b/backend/src/db/schemas/pki-acme-order-auths.ts new file mode 100644 index 000000000..66f8704f2 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-order-auths.ts @@ -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; +export type TPkiAcmeOrderAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrderAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts new file mode 100644 index 000000000..928753d8c --- /dev/null +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -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; +export type TPkiAcmeOrdersInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrdersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alert-channels.ts b/backend/src/db/schemas/pki-alert-channels.ts new file mode 100644 index 000000000..1101a9607 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-channels.ts @@ -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; +export type TPkiAlertChannelsInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertChannelsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alert-history-certificate.ts b/backend/src/db/schemas/pki-alert-history-certificate.ts new file mode 100644 index 000000000..f953b9a48 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history-certificate.ts @@ -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; +export type TPkiAlertHistoryCertificateInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TPkiAlertHistoryCertificateUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-alert-history.ts b/backend/src/db/schemas/pki-alert-history.ts new file mode 100644 index 000000000..20af7b757 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history.ts @@ -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; +export type TPkiAlertHistoryInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertHistoryUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alerts-v2.ts b/backend/src/db/schemas/pki-alerts-v2.ts new file mode 100644 index 000000000..cbb28220c --- /dev/null +++ b/backend/src/db/schemas/pki-alerts-v2.ts @@ -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; +export type TPkiAlertsV2Insert = Omit, TImmutableDBKeys>; +export type TPkiAlertsV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 368770c3e..04560bec6 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -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; diff --git a/backend/src/db/schemas/project-slack-configs.ts b/backend/src/db/schemas/project-slack-configs.ts index 0a46e5aae..48674ec8f 100644 --- a/backend/src/db/schemas/project-slack-configs.ts +++ b/backend/src/db/schemas/project-slack-configs.ts @@ -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; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 31847b503..7ff9ec09a 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -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" } ); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts new file mode 100644 index 000000000..c4ccf6be5 --- /dev/null +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -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, + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? U : string + >({ + req, + schema + }: { + req: R; + schema?: TSchema; + }): Promise> => { + 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 (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { + 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//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//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//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//accounts/ + // 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//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//orders/ + // 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//orders//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//accounts//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//orders//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//authorizations/ + // 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//authorizations//challenges/ + // 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 + }) + ); + } + }); +}; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 78d9aeddc..bd5bacc5f 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -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(), diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts index 200130488..8ebcd32a7 100644 --- a/backend/src/ee/routes/v1/sub-org-router.ts +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -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, diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 16c7e962c..abb94ec1c 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -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; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; + projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; 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, diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 1027995a7..4b2608c24 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -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 diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index c6ac6ff3b..f91692a14 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -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 diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 97061e3ca..14b7bcfbd 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretsLimit: 40 }, pkiEst: false, + pkiAcme: false, enforceMfa: false, projectTemplates: false, kmip: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c4ff6a8fa..5157b0730 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { secretsLimit: number; }; pkiEst: boolean; + pkiAcme: false; enforceMfa: boolean; projectTemplates: false; kmip: false; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 413990c5d..7dd7948ef 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -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; } diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 34876f739..81814a67c 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -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 ); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 88b52be22..efe17edad 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -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`) diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index b71e63c10..9b72f2ac4 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -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 diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index bb62440c1..19340644a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -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 { diff --git a/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts new file mode 100644 index 000000000..685d07f2b --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts @@ -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; + +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 + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts new file mode 100644 index 000000000..cef4d619f --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -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; + +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 + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts new file mode 100644 index 000000000..74cbd1466 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -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; + +export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { + const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); + + const markAsValidCascadeById = async (id: string, tx?: Knex): Promise => { + 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 => { + 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 + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts new file mode 100644 index 000000000..9148b0336 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -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 => { + 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 }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts new file mode 100644 index 000000000..9053be391 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -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"; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts new file mode 100644 index 000000000..cc7ddb9b1 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -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)); +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts new file mode 100644 index 000000000..5a91e6fea --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts @@ -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; + +export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => { + const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth); + + return { + ...pkiAcmeOrderAuthOrm + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts new file mode 100644 index 000000000..5aab0be63 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -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; + +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 + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts new file mode 100644 index 000000000..58ca7e833 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -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() +}); diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts new file mode 100644 index 000000000..dadb809d0 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -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; + appConnectionDAL: Pick; + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + externalCertificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + acmeAccountDAL: Pick< + TPkiAcmeAccountDALFactory, + "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" + >; + acmeOrderDAL: Pick< + TPkiAcmeOrderDALFactory, + | "create" + | "transaction" + | "updateById" + | "findByAccountAndOrderIdWithAuthorizations" + | "findByIdForFinalization" + | "listByAccountId" + >; + acmeAuthDAL: Pick; + acmeOrderAuthDAL: Pick; + acmeChallengeDAL: Pick< + TPkiAcmeChallengeDALFactory, + "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" + >; + keyStore: Pick; + kmsService: Pick< + TKmsServiceFactory, + "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" | "createCipherPairWithDataKey" + >; + licenseService: Pick; + certificateV3Service: Pick; + 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 => { + 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 | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: TSchema; + }): Promise> => { + 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> => { + 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 | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + url: URL; + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema?: TSchema; + expectedAccountId?: string; + }): Promise> => { + 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 => { + 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 => { + 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> => { + 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> => { + 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> => { + // 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> => { + 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> => { + 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> => { + 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> => { + 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> => { + 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> => { + 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 + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts new file mode 100644 index 000000000..3ddb424f1 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -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; +export type TCreateAcmeAccountResponse = z.infer; +export type TAcmeOrderResource = z.infer; +export type TDeactivateAcmeAccountResponse = z.infer; +export type TListAcmeOrdersResponse = z.infer; +export type TDownloadAcmeCertificateDTO = string; +export type TGetAcmeAuthorizationResponse = z.infer; +export type TRespondToAcmeChallengeResponse = z.infer; + +// Payload types +export type TRawJwsPayload = z.infer; +export type TProtectedHeader = z.infer; +export type TCreateAcmeAccountPayload = z.infer; +export type TCreateAcmeOrderPayload = z.infer; +export type TDeactivateAcmeAccountPayload = z.infer; +export type TFinalizeAcmeOrderPayload = z.infer; + +export type TJwsPayload = { + protectedHeader: TProtectedHeader; + payload: T; +}; +export type TAuthenciatedJwsPayload = TJwsPayload & { + profileId: string; + accountId: string; +}; +export type TAcmeResponse = { + status: number; + headers: Record; + body: TPayload; +}; + +export type TPkiAcmeServiceFactory = { + validateJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: TSchema; + }) => Promise>; + validateNewAccountJwsPayload: ({ + url, + rawJwsPayload + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + }) => Promise>; + validateExistingAccountJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + url: URL; + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema?: TSchema; + expectedAccountId?: string; + }) => Promise>; + getAcmeDirectory: (profileId: string) => Promise; + getAcmeNewNonce: (profileId: string) => Promise; + createAcmeAccount: ({ + profileId, + alg, + jwk, + payload + }: { + profileId: string; + alg: string; + jwk: JsonWebKey; + payload: TCreateAcmeAccountPayload; + }) => Promise>; + deactivateAcmeAccount: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }) => Promise>; + createAcmeOrder: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload: TCreateAcmeOrderPayload; + }) => Promise>; + getAcmeOrder: ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }) => Promise>; + finalizeAcmeOrder: ({ + profileId, + accountId, + orderId, + payload + }: { + profileId: string; + accountId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }) => Promise>; + downloadAcmeCertificate: ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }) => Promise>; + listAcmeOrders: ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }) => Promise>; + getAcmeAuthorization: ({ + profileId, + accountId, + authzId + }: { + profileId: string; + accountId: string; + authzId: string; + }) => Promise>; + respondToAcmeChallenge: ({ + profileId, + accountId, + authzId, + challengeId + }: { + profileId: string; + accountId: string; + authzId: string; + challengeId: string; + }) => Promise>; +}; + +export type TPkiAcmeChallengeServiceFactory = { + validateChallengeResponse: (challengeId: string) => Promise; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 96757dc22..f594f41ff 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -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; userDAL: Pick; + projectMembershipDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; licenseService: Pick; @@ -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, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index db300720f..2610d9324 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -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 diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts index d96fb2e53..8f1c3d060 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts @@ -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 }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a10a3f568..e6455c113 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -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 } } }, diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 3155fe05c..204952a92 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -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 = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cb606cbf..15ec747ef 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -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.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9fc4cff92..96107306f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -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; + }) + .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) => { diff --git a/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts b/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts new file mode 100644 index 000000000..0cfe28491 --- /dev/null +++ b/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts @@ -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; +}): Promise => { + 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; + microsoftTeamsService: Pick; +}): Promise => { + 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}`); + } +}; diff --git a/backend/src/lib/workflow-integrations/notification-handlers/slack.ts b/backend/src/lib/workflow-integrations/notification-handlers/slack.ts new file mode 100644 index 000000000..b55f172cb --- /dev/null +++ b/backend/src/lib/workflow-integrations/notification-handlers/slack.ts @@ -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; +}): Promise => { + 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; + kmsService: Pick; +}): Promise => { + 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}`); + } +}; diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts index 355761f73..2dc6f61fe 100644 --- a/backend/src/lib/workflow-integrations/trigger-notification.ts +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -1,8 +1,7 @@ -import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; -import { sendSlackNotification } from "@app/services/slack/slack-fns"; - import { logger } from "../logger"; -import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types"; +import { triggerMicrosoftTeamsNotification } from "./notification-handlers/microsoft-teams"; +import { triggerSlackNotification } from "./notification-handlers/slack"; +import { TTriggerWorkflowNotificationDTO } from "./types"; export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => { try { @@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl return; } - const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); - const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); + const handlerPromises = [ + triggerSlackNotification({ + projectId, + notification, + orgId: project.orgId, + projectSlackConfigDAL, + kmsService + }), - if (slackConfig) { - if ( - notification.type === TriggerFeature.ACCESS_REQUEST || - notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED - ) { - const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; - if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { - await sendSlackNotification({ - orgId: project.orgId, - notification, - kmsService, - targetChannelIds, - slackIntegration: slackConfig - }).catch((error) => { - logger.error(error, "Error sending Slack notification"); - }); - } - } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { - const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || []; - if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) { - await sendSlackNotification({ - orgId: project.orgId, - notification, - kmsService, - targetChannelIds, - slackIntegration: slackConfig - }).catch((error) => { - logger.error(error, "Error sending Slack notification"); - }); - } - } - } + triggerMicrosoftTeamsNotification({ + projectId, + notification, + orgId: project.orgId, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService + }) + ]; - if (microsoftTeamsConfig) { - if ( - notification.type === TriggerFeature.ACCESS_REQUEST || - notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED - ) { - if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { - const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( - microsoftTeamsConfig.accessRequestChannels - ); - - if (success && data) { - await microsoftTeamsService - .sendNotification({ - notification, - target: data, - tenantId: microsoftTeamsConfig.tenantId, - microsoftTeamsIntegrationId: microsoftTeamsConfig.id, - orgId: project.orgId - }) - .catch((error) => { - logger.error(error, "Error sending Microsoft Teams notification"); - }); - } - } - } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { - if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) { - const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( - microsoftTeamsConfig.secretRequestChannels - ); - - if (success && data) { - await microsoftTeamsService - .sendNotification({ - notification, - target: data, - tenantId: microsoftTeamsConfig.tenantId, - microsoftTeamsIntegrationId: microsoftTeamsConfig.id, - orgId: project.orgId - }) - .catch((error) => { - logger.error(error, "Error sending Microsoft Teams notification"); - }); - } - } - } - } + await Promise.allSettled(handlerPromises); } catch (error) { logger.error(error, "Error triggering workflow integration notification"); } diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index f8f55eadd..6d81c9174 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -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; }; }; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 3e6b1dd19..8cf8555f9 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -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; diff --git a/backend/src/server/plugins/add-errors-to-response-schemas.ts b/backend/src/server/plugins/add-errors-to-response-schemas.ts index 8eb358a1b..6337bae0f 100644 --- a/backend/src/server/plugins/add-errors-to-response-schemas.ts +++ b/backend/src/server/plugins/add-errors-to-response-schemas.ts @@ -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 diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 31f3139ec..e6ba2eec7 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -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); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8d10a8630..e703df5ef 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -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, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6bd37e991..3195e670d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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[] = []; diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 47fbb0e07..17cfcb5ce 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -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(), diff --git a/backend/src/server/routes/v1/bdd-nock-router.ts b/backend/src/server/routes/v1/bdd-nock-router.ts new file mode 100644 index 000000000..ad4777772 --- /dev/null +++ b/backend/src/server/routes/v1/bdd-nock-router.ts @@ -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" }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 08f532bc4..5792c5e83 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -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 }; + } + }); }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 443d64e22..e8cdbb540 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -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; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-project-router.ts b/backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts similarity index 97% rename from backend/src/server/routes/v1/identity-project-router.ts rename to backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts index fd39c7efe..2ac530d00 100644 --- a/backend/src/server/routes/v1/identity-project-router.ts +++ b/backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts @@ -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 }) diff --git a/backend/src/server/routes/v1/deprecated-project-router.ts b/backend/src/server/routes/v1/deprecated-project-router.ts index 687d95b9b..f6efdb78a 100644 --- a/backend/src/server/routes/v1/deprecated-project-router.ts +++ b/backend/src/server/routes/v1/deprecated-project-router.ts @@ -614,8 +614,10 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider integrationId: z.string(), accessRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField, + secretSyncErrorChannels: validateSlackChannelsField, isAccessRequestNotificationEnabled: z.boolean(), - isSecretRequestNotificationEnabled: z.boolean() + isSecretRequestNotificationEnabled: z.boolean(), + isSecretSyncErrorNotificationEnabled: z.boolean() }), z.object({ integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), @@ -633,7 +635,9 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts index c9b93965a..275ed5475 100644 --- a/backend/src/server/routes/v1/identity-org-membership-router.ts +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -1,9 +1,10 @@ import { z } from "zod"; -import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; -import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { AccessScope, IdentitiesSchema, MembershipRolesSchema, TemporaryPermissionMode } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, ORG_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -15,7 +16,7 @@ const sanitizedOrgIdentityMembershipSchema = z.object({ updatedAt: z.date() }); -export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProvider) => { +export const registerIdentityOrgMembershipRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/identity-memberships/:identityId", @@ -25,8 +26,7 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - // this is hidden so not updating tags - tags: [ApiDocsTags.ProjectIdentities], + tags: [ApiDocsTags.OrgIdentityMembership], description: "Create org identity membership", security: [ { @@ -34,38 +34,40 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } ], params: z.object({ - identityId: z.string().trim() + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.identityId) }), body: z.object({ roles: z .array( z.union([ z.object({ - role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z .literal(false) .default(false) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary) }), z.object({ - role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), - isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary), temporaryMode: z .nativeEnum(TemporaryPermissionMode) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), temporaryRange: z .string() .refine((val) => ms(val) > 0, "Temporary range must be a positive number") - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), temporaryAccessStartTime: z .string() .datetime() - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) }) ]) ) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) - .max(1) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .min(1) }), response: { 200: z.object({ @@ -86,12 +88,115 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } }); + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + return { identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } }; } }); + server.route({ + method: "PATCH", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Update org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + roles: MembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + roles: membership.roles.map((el) => ({ ...el, membershipId: membership.id })) + }; + } + }); + server.route({ method: "DELETE", url: "/identity-memberships/:identityId", @@ -101,15 +206,15 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - tags: [ApiDocsTags.ProjectIdentities], - description: "Delete org identity memberships", + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Delete org identity membership", security: [ { bearerAuth: [] } ], params: z.object({ - identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.DELETE_IDENTITY_MEMBERSHIP.identityId) }), response: { 200: z.object({ @@ -129,9 +234,226 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } }); + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId + } + } + }); + return { identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Get org identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()) + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List available identities for org membership", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional(), + identityName: z.string().describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.identityName).optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName + } + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-project-membership-router.ts b/backend/src/server/routes/v1/identity-project-membership-router.ts new file mode 100644 index 000000000..74ffdb39b --- /dev/null +++ b/backend/src/server/routes/v1/identity-project-membership-router.ts @@ -0,0 +1,493 @@ +import { z } from "zod"; + +import { + AccessScope, + IdentitiesSchema, + IdentityProjectMembershipsSchema, + ProjectMembershipRole, + TemporaryPermissionMode +} from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PROJECT_IDENTITIES, PROJECT_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIdentityProjectMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim(), + identityId: z.string().trim() + }), + body: z.object({ + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + identityId: req.params.identityId, + roles: roles || [{ role, isTemporary: false }] + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.CREATE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "PATCH", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Update project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "DELETE", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "GET", + url: "/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "List project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(1000) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "Get project identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional(), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()), + metadata: z + .object({ + id: z.string().trim().min(1), + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "List available identities for project membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(1000) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.identityName) + .optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName + } + }); + + return { identities }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index f8e6c78ee..ba8506be4 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -60,7 +60,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.createIdentity({ + const identity = await server.services.identityV1.createIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -136,7 +136,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.updateIdentity({ + const identity = await server.services.identityV1.updateIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -189,7 +189,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.deleteIdentity({ + const identity = await server.services.identityV1.deleteIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -258,7 +258,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.getIdentityById({ + const identity = await server.services.identityV1.getIdentityById({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -308,7 +308,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.listOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -402,7 +402,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.searchOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -468,7 +468,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identityMemberships = await server.services.identity.listProjectIdentitiesByIdentityId({ + const identityMemberships = await server.services.identityV1.listProjectIdentitiesByIdentityId({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 4300f5698..4d8b87e60 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -6,14 +6,17 @@ import { registerCmekRouter } from "@app/server/routes/v1/cmek-router"; import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"; import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers"; +import { getConfig } from "@app/lib/config/env"; import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; +import { registerBddNockRouter } from "./bdd-nock-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerDeprecatedIdentityProjectMembershipRouter } from "./deprecated-identity-project-membership-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; @@ -33,8 +36,8 @@ import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-rou import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; -import { registerOrgIdentityMembershipRouter } from "./identity-org-membership-router"; -import { registerIdentityProjectRouter } from "./identity-project-router"; +import { registerIdentityOrgMembershipRouter } from "./identity-org-membership-router"; +import { registerIdentityProjectMembershipRouter } from "./identity-project-membership-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; @@ -45,6 +48,7 @@ import { registerInviteOrgRouter } from "./invite-org-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; import { registerNotificationRouter } from "./notification-router"; import { registerOrgAdminRouter } from "./org-admin-router"; +import { registerOrgIdentityRouter } from "./org-identity-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; @@ -52,6 +56,7 @@ import { registerPkiCollectionRouter } from "./pki-collection-router"; import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; import { PKI_SYNC_REGISTER_ROUTER_MAP, registerPkiSyncRouter } from "./pki-sync-routers"; import { registerProjectEnvRouter } from "./project-env-router"; +import { registerProjectIdentityRouter } from "./project-identity-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; @@ -90,8 +95,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/auth" } ); await server.register(registerPasswordRouter, { prefix: "/password" }); - await server.register(registerOrgRouter, { prefix: "/organization" }); - await server.register(registerOrgIdentityMembershipRouter, { prefix: "/organization" }); + await server.register( + async (orgRouter) => { + await orgRouter.register(registerOrgRouter); + await orgRouter.register(registerOrgIdentityRouter); + await orgRouter.register(registerIdentityOrgMembershipRouter); + }, + { prefix: "/organization" } + ); await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); @@ -126,14 +137,19 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (projectRouter) => { await projectRouter.register(registerProjectRouter); await projectRouter.register(registerProjectMembershipRouter); + await projectRouter.register(registerProjectIdentityRouter); await projectRouter.register(registerProjectEnvRouter); await projectRouter.register(registerSecretTagRouter); await projectRouter.register(registerGroupProjectRouter); - await projectRouter.register(registerIdentityProjectRouter); + await projectRouter.register(registerDeprecatedIdentityProjectMembershipRouter); }, { prefix: "/projects" } ); + await server.register(registerIdentityProjectMembershipRouter, { + prefix: "/projects/:projectId/memberships" + }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); @@ -223,4 +239,10 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerEventRouter, { prefix: "/events" }); await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); + + // Note: This is a special route for BDD tests. It's only available in development mode and only for BDD tests. + // This route should NEVER BE ENABLED IN PRODUCTION! + if (getConfig().isBddNockApiEnabled) { + await server.register(registerBddNockRouter, { prefix: "/bdd-nock" }); + } }; diff --git a/backend/src/server/routes/v1/org-identity-router.ts b/backend/src/server/routes/v1/org-identity-router.ts new file mode 100644 index 000000000..7376959d8 --- /dev/null +++ b/backend/src/server/routes/v1/org-identity-router.ts @@ -0,0 +1,286 @@ +import { z } from "zod"; + +import { AccessScope, IdentitiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, IDENTITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const metadataSchema = z.object({ + key: z.string().trim().min(1, "Metadata key cannot be empty"), + value: z.string().trim().min(1, "Metadata value cannot be empty") +}); + +const sanitizedIdentitySchema = IdentitiesSchema.pick({ + id: true, + name: true, + orgId: true, + projectId: true, + createdAt: true, + updatedAt: true, + hasDeleteProtection: true +}).extend({ + authMethods: z.array(z.string()).optional(), + metadata: z.array(metadataSchema).optional() +}); + +export const registerOrgIdentityRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identities", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Create an identity", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z.string().trim().min(1).describe(IDENTITIES.CREATE.name), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.CREATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.createIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY, + metadata: { + identityId: identity.id, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "PATCH", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Update an identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.UPDATE.identityId) + }), + body: z.object({ + name: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.name), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.UPDATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.updateIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY, + metadata: { + identityId: req.params.identityId, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "DELETE", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Delete an identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.DELETE.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.deleteIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Get an identity by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.GET_BY_ID.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.getIdentityById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "List identities", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(IDENTITIES.LIST.offset).optional(), + limit: z.coerce.number().min(1).max(1000).default(20).describe(IDENTITIES.LIST.limit).optional(), + search: z.string().trim().describe(IDENTITIES.LIST.search).optional() + }), + response: { + 200: z.object({ + identities: z.array(sanitizedIdentitySchema), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { docs: identities, count: totalCount } = await server.services.identityV2.listIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + search: req.query.search + } + }); + + return { identities, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v1/pki-alert-router.ts b/backend/src/server/routes/v1/pki-alert-router.ts index 43ce91e88..60a906c3a 100644 --- a/backend/src/server/routes/v1/pki-alert-router.ts +++ b/backend/src/server/routes/v1/pki-alert-router.ts @@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -16,7 +17,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiAlerting], description: "Create PKI alert", body: z.object({ @@ -52,7 +52,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } @@ -70,7 +71,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiAlerting], description: "Get PKI alert", params: z.object({ @@ -112,7 +112,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiAlerting], description: "Update PKI alert", params: z.object({ @@ -152,7 +151,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } @@ -170,7 +170,6 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiAlerting], description: "Delete PKI alert", params: z.object({ diff --git a/backend/src/server/routes/v1/project-identity-router.ts b/backend/src/server/routes/v1/project-identity-router.ts new file mode 100644 index 000000000..0eabe0a13 --- /dev/null +++ b/backend/src/server/routes/v1/project-identity-router.ts @@ -0,0 +1,313 @@ +import { z } from "zod"; + +import { AccessScope, IdentitiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, IDENTITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const metadataSchema = z.object({ + key: z.string().trim().min(1, "Metadata key cannot be empty"), + value: z.string().trim().min(1, "Metadata value cannot be empty") +}); + +const sanitizedIdentitySchema = IdentitiesSchema.pick({ + id: true, + name: true, + orgId: true, + projectId: true, + createdAt: true, + updatedAt: true, + hasDeleteProtection: true +}).extend({ + activeLockoutAuthMethods: z.string().array().optional(), + authMethods: z.string().array().optional(), + metadata: z + .object({ + key: z.string(), + value: z.string(), + id: z.string() + }) + .array() + .optional() +}); + +export const registerProjectIdentityRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/identities", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Create an identity in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project to create the identity in") + }), + body: z.object({ + name: z.string().trim().min(1).describe(IDENTITIES.CREATE.name), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.CREATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.createIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY, + metadata: { + identityId: identity.id, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Update an identity in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.UPDATE.identityId) + }), + body: z.object({ + name: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.name), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.UPDATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.updateIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: req.params.projectId, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY, + metadata: { + identityId: req.params.identityId, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Delete an identity from a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.DELETE.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.deleteIdentity({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Get an identity by ID in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.GET_BY_ID.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.getIdentityById({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "List identities in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project") + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(IDENTITIES.LIST.offset).optional(), + limit: z.coerce.number().min(1).max(1000).default(20).describe(IDENTITIES.LIST.limit).optional(), + search: z.string().trim().describe(IDENTITIES.LIST.search).optional() + }), + response: { + 200: z.object({ + identities: z.array(sanitizedIdentitySchema), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { docs: identities, count: totalCount } = await server.services.identityV2.listIdentities({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + search: req.query.search + } + }); + + return { identities, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 1054d359b..e3838cddc 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -162,7 +162,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template), type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), - shouldCreateDefaultEnvs: z.boolean().optional().default(true) + shouldCreateDefaultEnvs: z.boolean().optional().default(true), + hasDeleteProtection: z.boolean().optional().default(false) }), response: { 200: z.object({ @@ -183,7 +184,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { kmsKeyId: req.body.kmsKeyId, template: req.body.template, type: req.body.type, - createDefaultEnvs: req.body.shouldCreateDefaultEnvs + createDefaultEnvs: req.body.shouldCreateDefaultEnvs, + hasDeleteProtection: req.body.hasDeleteProtection }); await server.services.telemetry.sendPostHogEvents({ @@ -769,7 +771,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), @@ -873,7 +877,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { accessRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField, isAccessRequestNotificationEnabled: z.boolean(), - isSecretRequestNotificationEnabled: z.boolean() + isSecretRequestNotificationEnabled: z.boolean(), + secretSyncErrorChannels: validateSlackChannelsField, + isSecretSyncErrorNotificationEnabled: z.boolean() }), z.object({ integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), @@ -891,7 +897,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 32b09b337..63f673d3f 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -550,12 +550,14 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { providerAuthToken: req.body.providerAuthToken }); - void res.setCookie("jid", data.token.refresh, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: appCfg.HTTPS_ENABLED - }); + if ([AuthMethod.GOOGLE, AuthMethod.GITHUB, AuthMethod.GITLAB].includes(data.decodedProviderToken.authMethod)) { + void res.setCookie("jid", data.token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + } addAuthOriginDomainCookie(res); diff --git a/backend/src/server/routes/v2/deprecated-project-membership-router.ts b/backend/src/server/routes/v2/deprecated-project-membership-router.ts index 4dff4d5ea..aa693bcb5 100644 --- a/backend/src/server/routes/v2/deprecated-project-membership-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-membership-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { AccessScope, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; +import { AccessScope, OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -51,6 +51,19 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; + + await server.services.membershipUser.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }], + usernames: usernamesAndEmails + } + }); + const { memberships } = await server.services.membershipUser.createMembership({ permission: req.permission, scopeData: { diff --git a/backend/src/server/routes/v2/deprecated-project-router.ts b/backend/src/server/routes/v2/deprecated-project-router.ts index 55493681a..82b351359 100644 --- a/backend/src/server/routes/v2/deprecated-project-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-router.ts @@ -110,7 +110,8 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider .default(InfisicalProjectTemplate.Default) .describe(PROJECTS.CREATE.template), type: z.nativeEnum(ProjectType).default(ProjectType.SecretManager), - shouldCreateDefaultEnvs: z.boolean().optional().default(true) + shouldCreateDefaultEnvs: z.boolean().optional().default(true), + hasDeleteProtection: z.boolean().optional().default(false) }), response: { 200: z.object({ @@ -131,7 +132,8 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider kmsKeyId: req.body.kmsKeyId, template: req.body.template, type: req.body.type, - createDefaultEnvs: req.body.shouldCreateDefaultEnvs + createDefaultEnvs: req.body.shouldCreateDefaultEnvs, + hasDeleteProtection: req.body.hasDeleteProtection }); await server.services.telemetry.sendPostHogEvents({ diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index 630e09dda..f1295209f 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -70,7 +70,7 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.listOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index db4ebb176..d3d91a3ba 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; @@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v2/pki-alert-router.ts b/backend/src/server/routes/v2/pki-alert-router.ts new file mode 100644 index 000000000..459b5cc6c --- /dev/null +++ b/backend/src/server/routes/v2/pki-alert-router.ts @@ -0,0 +1,443 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CreatePkiAlertV2Schema, + createSecureAlertBeforeValidator, + PkiAlertChannelType, + PkiAlertEventType, + PkiFilterRuleSchema, + UpdatePkiAlertV2Schema +} from "@app/services/pki-alert-v2/pki-alert-v2-types"; + +export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create a new PKI alert", + tags: [ApiDocsTags.PkiAlerting], + body: CreatePkiAlertV2Schema.extend({ + projectId: z.string().uuid().describe("Project ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.createAlert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List PKI alerts for a project", + tags: [ApiDocsTags.PkiAlerting], + querystring: z.object({ + projectId: z.string().uuid(), + search: z.string().optional(), + eventType: z.nativeEnum(PkiAlertEventType).optional(), + enabled: z.coerce.boolean().optional(), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + alerts: z.array( + z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const alerts = await server.services.pkiAlertV2.listAlerts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return alerts; + } + }); + + server.route({ + method: "GET", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get a PKI alert by ID", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.getAlertById({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.GET_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "PATCH", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + body: UpdatePkiAlertV2Schema, + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.updateAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.UPDATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "DELETE", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.deleteAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.DELETE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/:alertId/certificates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List certificates that match an alert's filter rules", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + querystring: z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listMatchingCertificates({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return result; + } + }); + + server.route({ + method: "POST", + url: "/preview/certificates", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Preview certificates that would match the given filter rules", + tags: [ApiDocsTags.PkiAlerting], + body: z.object({ + projectId: z.string().uuid().describe("Project ID"), + filters: z.array(PkiFilterRuleSchema), + alertBefore: z + .string() + .refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'") + .describe("Alert timing (e.g., '30d', '1w')"), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listCurrentMatchingCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return result; + } + }); +}; diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index d2d696596..f590aa111 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -20,6 +20,7 @@ import { } from "@app/services/certificate-common/certificate-constants"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; interface CertificateRequestForService { @@ -204,7 +205,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + enrollmentType: EnrollmentType.API }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 47c3c2cb8..4ee4566c1 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); - await server.register(registerCertificatesRouter, { prefix: "/certificates" }); + await server.register(registerCertificatesRouter, { prefix: "/pki/certificates" }); }; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts index eb0521c64..1f7fc808d 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts @@ -1,4 +1,5 @@ export enum AzureClientSecretsConnectionMethod { OAuth = "oauth", - ClientSecret = "client-secret" + ClientSecret = "client-secret", + Certificate = "certificate" } diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 22cec0ae7..7916ac92d 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -1,9 +1,14 @@ /* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; +import type { KeyObject } from "crypto"; +import RE2 from "re2"; +import { v4 as uuidv4 } from "uuid"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { decryptAppConnectionCredentials, encryptAppConnectionCredentials, @@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureClientSecretsConnectionCertificateCredentials, TAzureClientSecretsConnectionClientSecretCredentials, TAzureClientSecretsConnectionConfig, TAzureClientSecretsConnectionCredentials } from "./azure-client-secrets-connection-types"; +const generateClientAssertion = ( + clientId: string, + tenantId: string, + privateKey: string, + certificate: string +): string => { + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const certBuffer = Buffer.from( + certificate + .replace(new RE2("-----BEGIN CERTIFICATE-----"), "") + .replace(new RE2("-----END CERTIFICATE-----"), "") + .replace(new RE2("\\s", "g"), ""), + "base64" + ); + + // thumbprint of the certificate is used for the jwt header + const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex"); + const x5t = Buffer.from(thumbprint, "hex").toString("base64url"); + + // JWT Header + const header = { + alg: "RS256", + typ: "JWT", + x5t + }; + + const now = Math.floor(Date.now() / 1000); + const payload = { + aud: tokenEndpoint, + exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself) + iss: clientId, + jti: uuidv4(), // random ID for the JWT + nbf: now, // not before the jwt is valid + sub: clientId + }; + + // encode header and payload + const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signatureInput = `${encodedHeader}.${encodedPayload}`; + + let keyObject: KeyObject; + + try { + if (privateKey.includes("BEGIN PRIVATE KEY")) { + keyObject = crypto.nativeCrypto.createPrivateKey(privateKey); + } else { + // if user forgot to wrap in begin/end private key, decode and use as der format + keyObject = crypto.nativeCrypto.createPrivateKey({ + key: Buffer.from(privateKey, "base64"), + format: "der", + type: "pkcs8" + }); + } + } catch (error) { + throw new BadRequestError({ + message: "Invalid private key format provided. Expected PEM format private key." + }); + } + + // sign with private key + const signer = crypto.nativeCrypto.createSign("RSA-SHA256"); + signer.update(signatureInput); + signer.end(); + const signature = signer.sign(keyObject, "base64url"); + + return `${signatureInput}.${signature}`; +}; + export const getAzureClientSecretsConnectionListItem = () => { const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig(); @@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => { app: AppConnection.AzureClientSecrets as const, methods: Object.values(AzureClientSecretsConnectionMethod) as [ AzureClientSecretsConnectionMethod.OAuth, - AzureClientSecretsConnectionMethod.ClientSecret + AzureClientSecretsConnectionMethod.ClientSecret, + AzureClientSecretsConnectionMethod.Certificate ], oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID }; @@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async ( const { refreshToken } = credentials; const currentTime = Date.now(); switch (appConnection.method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if ( !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET @@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); return data.access_token; - case AzureClientSecretsConnectionMethod.ClientSecret: + } + case AzureClientSecretsConnectionMethod.ClientSecret: { const accessTokenCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, projectId: appConnection.projectId, @@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); return clientData.access_token; + } + + case AzureClientSecretsConnectionMethod.Certificate: { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCertificateCredentials; + const { accessToken, expiresAt, clientId, tenantId, certificateBody, privateKey } = accessTokenCredentials; + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody); + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://graph.microsoft.com/.default`, + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion + }) + ); + + const updatedClientCredentials = { + ...accessTokenCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}` @@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA } = getConfig(); switch (method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if (!SITE_URL) { throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); } @@ -221,8 +343,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + } - case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureClientSecretsConnectionMethod.ClientSecret: { const { tenantId, clientId, clientSecret } = inputCredentials; try { const { data: clientData } = await request.post( @@ -255,6 +378,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA }); } } + } + case AzureClientSecretsConnectionMethod.Certificate: { + const { tenantId, certificateBody, privateKey, clientId } = inputCredentials; + try { + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody); + + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const params = new URLSearchParams({ + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion, + scope: "https://graph.microsoft.com/.default", + grant_type: "client_credentials" + }); + + const response = await request.post(tokenEndpoint, params.toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + + return { + tenantId, + clientId, + certificateBody, + privateKey, + accessToken: response.data.access_token, + expiresAt: Date.now() + response.data.expires_in * 1000 + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else if (e instanceof BadRequestError) { + throw e; + } else { + logger.error( + e, + "validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication" + ); + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts index d9f178a06..dd387894e 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z. .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) }); +export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({ + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId), + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId), + certificateBody: z + .string() + .trim() + .min(1, "Certificate body required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody), + privateKey: z + .string() + .trim() + .min(1, "Private Key required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey) +}); + export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({ clientId: z.string(), clientSecret: z.string(), @@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z expiresAt: z.number() }); +export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({ + clientId: z.string(), + tenantId: z.string(), + certificateBody: z.string(), + privateKey: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials ) + }), + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.Certificate) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) }) ]); @@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z credentials: z .union([ AzureClientSecretsConnectionOAuthInputCredentialsSchema, - AzureClientSecretsConnectionClientSecretInputCredentialsSchema + AzureClientSecretsConnectionClientSecretInputCredentialsSchema, + AzureClientSecretsConnectionCertificateInputCredentialsSchema ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials) @@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema }) ]) ); @@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion( clientId: true, tenantId: true }) + }), + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({ + tenantId: true, + clientId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts index 1ad5a3411..e8a66cbd9 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureClientSecretsConnectionCertificateOutputCredentialsSchema, AzureClientSecretsConnectionClientSecretOutputCredentialsSchema, AzureClientSecretsConnectionOAuthOutputCredentialsSchema, AzureClientSecretsConnectionSchema, @@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer< typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema >; +export type TAzureClientSecretsConnectionCertificateCredentials = z.infer< + typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 497414a60..ef54ac0be 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 0dea986d6..52761e6a0 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -1,5 +1,5 @@ import * as x509 from "@peculiar/x509"; -import acme from "acme-client"; +import acme, { CsrBuffer } from "acme-client"; import { TableName } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -29,6 +29,8 @@ import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-ut import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { getConfig } from "@app/lib/config/env"; +import { Knex } from "knex"; import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; import { CaStatus, CaType } from "../certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; @@ -64,6 +66,20 @@ type TAcmeCertificateAuthorityFnsDeps = { projectDAL: Pick; }; +type TOrderCertificateDeps = { + appConnectionDAL: Pick; + certificateAuthorityDAL: Pick; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + projectDAL: Pick; +}; + type DBConfigurationColumn = { dnsProvider: string; directoryUrl: string; @@ -104,6 +120,245 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; +export const orderCertificate = async ( + { + caId, + subscriberId, + commonName, + altNames, + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages + }: { + caId: string; + subscriberId?: string; + commonName: string; + altNames?: string[]; + csr: CsrBuffer; + csrPrivateKey?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + }, + deps: TOrderCertificateDeps, + tx?: Knex +) => { + const { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL + } = deps; + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); + if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) { + throw new BadRequestError({ message: "CA is not an ACME CA" }); + } + + const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); + if (acmeCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + let accountKey: Buffer | undefined; + if (acmeCa.credentials) { + const decryptedCredentials = await kmsDecryptor({ + cipherTextBlob: acmeCa.credentials as Buffer + }); + + const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( + JSON.parse(decryptedCredentials.toString("utf8")) + ); + + accountKey = Buffer.from(parsedCredentials.accountKey, "base64"); + } + if (!accountKey) { + accountKey = await acme.crypto.createPrivateRsaKey(); + const newCredentials = { + accountKey: accountKey.toString("base64") + }; + const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({ + plainText: Buffer.from(JSON.stringify(newCredentials)) + }); + await externalCertificateAuthorityDAL.update( + { + caId: acmeCa.id + }, + { + credentials: encryptedNewCredentials + } + ); + } + + await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); + + const acmeClientOptions: acme.ClientOptions = { + directoryUrl: acmeCa.configuration.directoryUrl, + accountKey + }; + + if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { + acmeClientOptions.externalAccountBinding = { + kid: acmeCa.configuration.eabKid, + hmacKey: acmeCa.configuration.eabHmacKey + }; + } + + const acmeClient = new acme.Client(acmeClientOptions); + + const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId); + const connection = await decryptAppConnection(appConnection, kmsService); + + const pem = await acmeClient.auto({ + csr, + email: acmeCa.configuration.accountEmail, + challengePriority: ["dns-01"], + // For ACME development mode, we mock the DNS challenge API calls. So, no real DNS records are created. + // We need to disable the challenge verification to avoid errors. + skipChallengeVerification: getConfig().isAcmeDevelopmentMode && getConfig().ACME_SKIP_UPSTREAM_VALIDATION, + termsOfServiceAgreed: true, + + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + if (challenge.type !== "dns-01") { + throw new Error("Unsupported challenge type"); + } + + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } + } + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } + } + } + }); + + const [leafCert, parentCert] = acme.crypto.splitPemChain(pem); + const certObj = new x509.X509Certificate(leafCert); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = parentCert.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = csrPrivateKey + ? await kmsEncryptor({ + plainText: Buffer.from(csrPrivateKey) + }) + : { cipherTextBlob: undefined }; + + return (tx || certificateDAL).transaction(async (innerTx: Knex) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + pkiSubscriberId: subscriberId, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames: altNames?.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages, + extendedKeyUsages, + projectId: ca.projectId + }, + innerTx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + innerTx + ); + + if (encryptedPrivateKey !== undefined) { + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + innerTx + ); + } + + return cert; + }); +}; + export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -322,77 +577,6 @@ export const AcmeCertificateAuthorityFns = ({ if (!subscriber.caId) { throw new BadRequestError({ message: "Subscriber does not have a CA" }); } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); - if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) { - throw new BadRequestError({ message: "CA is not an ACME CA" }); - } - - const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); - if (acmeCa.status !== CaStatus.ACTIVE) { - throw new BadRequestError({ message: "CA is disabled" }); - } - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - let accountKey: Buffer | undefined; - if (acmeCa.credentials) { - const decryptedCredentials = await kmsDecryptor({ - cipherTextBlob: acmeCa.credentials as Buffer - }); - - const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( - JSON.parse(decryptedCredentials.toString("utf8")) - ); - - accountKey = Buffer.from(parsedCredentials.accountKey, "base64"); - } - if (!accountKey) { - accountKey = await acme.crypto.createPrivateRsaKey(); - const newCredentials = { - accountKey: accountKey.toString("base64") - }; - const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({ - plainText: Buffer.from(JSON.stringify(newCredentials)) - }); - await externalCertificateAuthorityDAL.update( - { - caId: acmeCa.id - }, - { - credentials: encryptedNewCredentials - } - ); - } - - await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); - - const acmeClientOptions: acme.ClientOptions = { - directoryUrl: acmeCa.configuration.directoryUrl, - accountKey - }; - - if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { - acmeClientOptions.externalAccountBinding = { - kid: acmeCa.configuration.eabKid, - hmacKey: acmeCa.configuration.eabHmacKey - }; - } - - const acmeClient = new acme.Client(acmeClientOptions); - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); @@ -407,131 +591,28 @@ export const AcmeCertificateAuthorityFns = ({ skLeaf ); - const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId); - const connection = await decryptAppConnection(appConnection, kmsService); - - const pem = await acmeClient.auto({ - csr: certificateCsr, - email: acmeCa.configuration.accountEmail, - challengePriority: ["dns-01"], - termsOfServiceAgreed: true, - - challengeCreateFn: async (authz, challenge, keyAuthorization) => { - if (challenge.type !== "dns-01") { - throw new Error("Unsupported challenge type"); - } - - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted - - switch (acmeCa.configuration.dnsProviderConfig.provider) { - case AcmeDnsProvider.Route53: { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - case AcmeDnsProvider.Cloudflare: { - await cloudflareInsertTxtRecord( - connection as TCloudflareConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - default: { - throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); - } - } + await orderCertificate( + { + caId: subscriber.caId, + subscriberId: subscriber.id, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames, + csr: certificateCsr, + csrPrivateKey: skLeaf, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] }, - challengeRemoveFn: async (authz, challenge, keyAuthorization) => { - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted - - switch (acmeCa.configuration.dnsProviderConfig.provider) { - case AcmeDnsProvider.Route53: { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - case AcmeDnsProvider.Cloudflare: { - await cloudflareDeleteTxtRecord( - connection as TCloudflareConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - default: { - throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); - } - } + { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL } - }); - - const [leafCert, parentCert] = acme.crypto.splitPemChain(pem); - const certObj = new x509.X509Certificate(leafCert); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(certObj.rawData)) - }); - - const certificateChainPem = parentCert.trim(); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChainPem) - }); - - const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ - plainText: Buffer.from(skLeaf) - }); - - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( - { - caId: ca.id, - pkiSubscriberId: subscriber.id, - status: CertStatus.ACTIVE, - friendlyName: subscriber.commonName, - commonName: subscriber.commonName, - altNames: subscriber.subjectAlternativeNames.join(","), - serialNumber: certObj.serialNumber, - notBefore: certObj.notBefore, - notAfter: certObj.notAfter, - keyUsages: subscriber.keyUsages as CertKeyUsage[], - extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], - projectId: ca.projectId - }, - tx - ); - - await certificateBodyDAL.create( - { - certId: cert.id, - encryptedCertificate, - encryptedCertificateChain - }, - tx - ); - - await certificateSecretDAL.create( - { - certId: cert.id, - encryptedPrivateKey - }, - tx - ); - }); - + ); await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index b66475bbf..d2f468248 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -65,9 +65,27 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; + const findByIdWithOwnerOrgId = async ( + id: string, + tx?: Knex + ): Promise<(TCertificateProfile & { ownerOrgId: string }) | undefined> => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .join(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select(db.ref("orgId").withSchema(TableName.Project).as("ownerOrgId")) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first()) as (TCertificateProfile & { ownerOrgId: string }) | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with owner org id" }); + } + }; + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { try { const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) .leftJoin( TableName.CertificateAuthority, `${TableName.PkiCertificateProfile}.caId`, @@ -88,8 +106,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( + db.ref("id").withSchema(TableName.Project).as("projectId"), + db.ref("orgId").withSchema(TableName.Project).as("orgId"), db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), @@ -107,7 +132,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -134,15 +161,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["apiConfig"]) : undefined; - const certificateAuthority = - result.caId && result.caProjectId && result.caStatus && result.caName - ? ({ - id: result.caId, - projectId: result.caProjectId, - status: result.caStatus, - name: result.caName - } as TCertificateProfileWithConfigs["certificateAuthority"]) - : undefined; + const acmeConfig = result.acmeConfigId + ? ({ + id: result.acmeConfigId, + encryptedEabSecret: result.acmeConfigEncryptedEabSecret + } as TCertificateProfileWithConfigs["acmeConfig"]) + : undefined; + + const certificateAuthority = { + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]; const certificateTemplate = result.templateId && result.templateProjectId && result.templateName @@ -154,6 +185,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["certificateTemplate"]) : undefined; + const project = { + id: result.projectId, + orgId: result.orgId + } as TCertificateProfileWithConfigs["project"]; + const transformedResult: TCertificateProfileWithConfigs = { id: result.id, projectId: result.projectId, @@ -164,10 +200,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { enrollmentType: result.enrollmentType as EnrollmentType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, apiConfig, + acmeConfig, + project, certificateAuthority, certificateTemplate }; @@ -241,6 +280,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), @@ -252,7 +296,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") ); const results = (await query @@ -279,6 +324,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } : undefined; + const acmeConfig = result.acmeId + ? { + id: result.acmeId as string + } + : undefined; + const baseProfile = { id: result.id, projectId: result.projectId, @@ -292,7 +343,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, - apiConfig + apiConfig, + acmeConfig }; return baseProfile as TCertificateProfileWithConfigs; @@ -416,6 +468,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; + const getLatestActiveCertificateForProfile = async (profileId: string, tx?: Knex) => { + try { + const now = new Date(); + + const certificate = await (tx || db)(TableName.Certificate) + .where("profileId", profileId) + .where("status", "active") + .where("notAfter", ">", now) + .whereNull("revokedAt") + .orderBy("createdAt", "desc") + .first(); + + return certificate; + } catch (error) { + throw new DatabaseError({ error, name: "Get latest active certificate by profile" }); + } + }; + const isProfileInUse = async (profileId: string, tx?: Knex) => { try { const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); @@ -432,12 +502,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { updateById, deleteById, findById, + findByIdWithOwnerOrgId, findByIdWithConfigs, findBySlugAndProjectId, findByProjectId, countByProjectId, findByNameAndProjectId, getCertificatesByProfile, + getLatestActiveCertificateForProfile, isProfileInUse }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index 8ac494fe6..bf88593bd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z if (data.apiConfig) { return false; } + if (data.acmeConfig) { + return false; + } } if (data.enrollmentType === EnrollmentType.API) { if (!data.apiConfig) { @@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z if (data.estConfig) { return false; } + if (data.acmeConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.ACME) { + if (!data.acmeConfig) { + return false; + } + if (data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } } return true; }, diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index bb30b8d5c..8865ffb9b 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -6,10 +6,16 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import type { TKmsServiceFactory } from "../kms/kms-service"; @@ -142,6 +148,17 @@ describe("CertificateProfileService", () => { delete: vi.fn() } as unknown as TEstEnrollmentConfigDALFactory; + const mockAcmeEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "acme-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TAcmeEnrollmentConfigDALFactory; + const mockPermissionService = { getProjectPermission: vi.fn().mockResolvedValue({ permission: { @@ -150,6 +167,10 @@ describe("CertificateProfileService", () => { }) } as unknown as Pick; + const mockLicenseService = { + getPlan: vi.fn() + } as unknown as Pick; + const mockKmsService = { encryptWithKmsKey: vi .fn() @@ -166,6 +187,54 @@ describe("CertificateProfileService", () => { transaction: vi.fn() } as unknown as Pick; + const mockCertificateBodyDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateBodyDALFactory; + + const mockCertificateSecretDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateSecretDALFactory; + + const mockCertificateAuthorityDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateAuthorityDALFactory; + + const mockCertificateAuthorityCertDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateAuthorityCertDALFactory; + beforeEach(() => { vi.spyOn(ForbiddenError, "from").mockReturnValue({ throwUnlessCan: vi.fn() @@ -182,7 +251,13 @@ describe("CertificateProfileService", () => { certificateTemplateV2DAL: mockCertificateTemplateV2DAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL, + certificateBodyDAL: mockCertificateBodyDAL, + certificateSecretDAL: mockCertificateSecretDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, permissionService: mockPermissionService, + licenseService: mockLicenseService, kmsService: mockKmsService, projectDAL: mockProjectDAL }); @@ -206,6 +281,13 @@ describe("CertificateProfileService", () => { }; beforeEach(() => { + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); @@ -234,6 +316,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + acmeConfigId: null, projectId: "project-123" }, undefined @@ -335,6 +418,24 @@ describe("CertificateProfileService", () => { expect(result).toEqual(sampleProfile); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); }); + + it("should throw BadRequestError when plan does not support ACME", async () => { + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: false + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrowError( + new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }) + ); + }); }); describe("updateProfile", () => { @@ -629,6 +730,13 @@ describe("CertificateProfileService", () => { } }; + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f858a8d4f..fe58f958f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -4,19 +4,27 @@ import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { + ProjectPermissionCertificateActions, ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; -import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TProjectDALFactory } from "../project/project-dal"; @@ -31,6 +39,36 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; +const generateAndEncryptAcmeEabSecret = async ( + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const appCfg = getConfig(); + const secret = crypto.randomBytes(32).toString("hex"); + const secretHash = await crypto.hashing().createHash(secret, appCfg.SALT_ROUNDS); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(secretHash) + }); + + return { encryptedEabSecret: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to generate ACME EAB secret: ${(error as Error).message}` }); + } +}; + const validateAndEncryptPemCaChain = async ( caChain: string, projectId: string, @@ -95,9 +133,13 @@ const decryptCaChain = async ( } }; -export type TCertificateProfileCreateData = Omit & { +export type TCertificateProfileCreateData = Omit< + TCertificateProfileInsert, + "estConfigId" | "apiConfigId" | "acmeConfigId" +> & { estConfig?: TEstConfigData; apiConfig?: TApiConfigData; + acmeConfig?: TAcmeConfigData; }; type TCertificateProfileServiceFactoryDep = { @@ -105,7 +147,13 @@ type TCertificateProfileServiceFactoryDep = { certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; permissionService: Pick; + licenseService: Pick; kmsService: Pick; projectDAL: Pick; }; @@ -124,7 +172,11 @@ export const certificateProfileServiceFactory = ({ certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, + acmeEnrollmentConfigDAL, + certificateBodyDAL, + certificateSecretDAL, permissionService, + licenseService, kmsService, projectDAL }: TCertificateProfileServiceFactoryDep) => { @@ -156,6 +208,17 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiAcme) { + throw new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }); + } + // Validate that certificate template exists and belongs to the same project if (data.certificateTemplateId) { const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); @@ -188,11 +251,14 @@ export const certificateProfileServiceFactory = ({ message: "API enrollment requires API configuration" }); } + // TODO: acme type currently doesn't require config obj, but add a check in the future if + // we have options // Create enrollment configs and profile const profile = await certificateProfileDAL.transaction(async (tx) => { let estConfigId: string | null = null; let apiConfigId: string | null = null; + let acmeConfigId: string | null = null; if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { const appCfg = getConfig(); @@ -228,16 +294,21 @@ export const certificateProfileServiceFactory = ({ tx ); apiConfigId = apiConfig.id; + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { + const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); + const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + acmeConfigId = acmeConfig.id; } // Create the profile with the created config IDs - const { estConfig, apiConfig, ...profileData } = data; + const { estConfig, apiConfig, acmeConfig, ...profileData } = data; const profileResult = await certificateProfileDAL.create( { ...profileData, projectId, estConfigId, - apiConfigId + apiConfigId, + acmeConfigId }, tx ); @@ -439,6 +510,12 @@ export const certificateProfileServiceFactory = ({ profile.estConfig.caChain = ""; } } + if (profile.enrollmentType === EnrollmentType.ACME && profile.acmeConfig) { + profile.acmeConfig.directoryUrl = buildUrl(profile.id, "/directory"); + if (profile.acmeConfig.encryptedEabSecret) { + profile.acmeConfig.encryptedEabSecret = undefined; + } + } return { ...profile, @@ -574,7 +651,10 @@ export const certificateProfileServiceFactory = ({ const result: TCertificateProfileWithConfigs = { ...converted, estConfig: decryptedEstConfig, - apiConfig: profileWithConfigs.apiConfig + apiConfig: profileWithConfigs.apiConfig, + acmeConfig: profileWithConfigs.acmeConfig + ? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") } + : undefined }; return result; @@ -674,6 +754,106 @@ export const certificateProfileServiceFactory = ({ return certificates; }; + const getLatestActiveCertificateBundle = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }) => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId); + + if (!cert) { + return null; + } + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: cert.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificate = certObj.toString("pem"); + + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain! + }); + + const certificateChain = decryptedCertChain.toString(); + + let privateKey = null; + try { + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + privateKey = certPrivateKey; + } catch (error) { + // Private key might not exist for ACME certificates or other external workflows + // where the key is generated client-side + if (error instanceof NotFoundError) { + privateKey = null; + } else { + throw error; + } + } + + return { + certificate, + certificateChain, + privateKey, + profile, + certObj: cert + }; + }; + const getEstConfigurationByProfile = async ( params: | { @@ -737,6 +917,59 @@ export const certificateProfileServiceFactory = ({ }; }; + const revealAcmeEabSecret = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.enrollmentType !== EnrollmentType.ACME) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for ACME enrollment" + }); + } + if (!profile.acmeConfig) { + throw new NotFoundError({ message: "ACME configuration not found for this profile" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret! }); + return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") }; + }; + return { createProfile, updateProfile, @@ -746,6 +979,8 @@ export const certificateProfileServiceFactory = ({ listProfiles, deleteProfile, getProfileCertificates, - getEstConfigurationByProfile + getLatestActiveCertificateBundle, + getEstConfigurationByProfile, + revealAcmeEabSecret }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 5dac470c8..030548e97 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -6,7 +6,8 @@ import { export enum EnrollmentType { API = "api", - EST = "est" + EST = "est", + ACME = "acme" } export type TCertificateProfile = Omit & { @@ -28,9 +29,14 @@ export type TCertificateProfileUpdate = Omit { expect(result.isValid).toBe(true); }); - it("should handle camelCase key usage mapping correctly", async () => { - const templateWithOptionalUsages = { - ...sampleTemplate, - keyUsages: { - requiredUsages: { - all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] - }, - optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } - }, - extendedKeyUsages: { - requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, - optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } - } - }; - mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); - - const requestWithCamelCaseUsages = { - ...validRequest, - keyUsages: [ - CertKeyUsageType.DIGITAL_SIGNATURE, - CertKeyUsageType.NON_REPUDIATION, - CertKeyUsageType.KEY_AGREEMENT, - CertKeyUsageType.CRL_SIGN, - CertKeyUsageType.DECIPHER_ONLY - ], - extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] - }; - - const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); - expect(result.isValid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - it("should validate wildcard patterns in allow attributes", async () => { const wildcardTemplate = { ...sampleTemplate, diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts index 0046ae0e5..656c12e64 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -482,7 +482,7 @@ export const certificateTemplateV2ServiceFactory = ({ } // Check ALLOWED key usages - if present, all usages must be in allowed list - if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + if (request.keyUsages && keyUsagePolicy) { const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); if (invalidUsages.length > 0) { @@ -517,12 +517,7 @@ export const certificateTemplateV2ServiceFactory = ({ } // Check ALLOWED extended key usages - if present, all usages must be in allowed list - if ( - request.extendedKeyUsages && - extendedKeyUsagePolicy && - extendedKeyUsagePolicy.allowed && - extendedKeyUsagePolicy.allowed.length > 0 - ) { + if (request.extendedKeyUsages && extendedKeyUsagePolicy) { const allAllowedExtendedUsages = [ ...(extendedKeyUsagePolicy.required || []), ...(extendedKeyUsagePolicy.allowed || []) diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index d11cce056..6c664e324 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -7,6 +7,7 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -69,6 +70,10 @@ describe("CertificateV3Service", () => { getTemplateV2ById: vi.fn() }; + const mockAcmeAccountDAL: Pick = { + findById: vi.fn() + }; + const mockInternalCaService: Pick = { signCertFromCa: vi.fn(), @@ -132,6 +137,7 @@ describe("CertificateV3Service", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, + acmeAccountDAL: mockAcmeAccountDAL, internalCaService: mockInternalCaService, permissionService: mockPermissionService, certificateSyncDAL: { @@ -697,6 +703,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }); @@ -731,6 +738,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow(ForbiddenRequestError); @@ -740,6 +748,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow("Profile is not configured for api enrollment"); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 51c79f135..7b6538ab2 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -9,6 +9,7 @@ import { ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -70,6 +71,7 @@ type TCertificateV3ServiceFactoryDep = { certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; + acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, "validateCertificateRequest" | "getTemplateV2ById" @@ -93,6 +95,7 @@ const validateProfileAndPermissions = async ( actorAuthMethod: ActorAuthMethod, actorOrgId: string, certificateProfileDAL: Pick, + acmeAccountDAL: Pick, permissionService: Pick, requiredEnrollmentType: EnrollmentType ) => { @@ -107,6 +110,19 @@ const validateProfileAndPermissions = async ( }); } + if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) { + const account = await acmeAccountDAL.findById(actorId); + if (!account) { + throw new NotFoundError({ message: "ACME account not found" }); + } + if (account.profileId !== profile.id) { + throw new ForbiddenRequestError({ + message: "ACME account is not associated with this profile" + }); + } + return profile; + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -336,6 +352,7 @@ export const certificateV3ServiceFactory = ({ certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, + acmeAccountDAL, certificateTemplateV2Service, internalCaService, permissionService, @@ -358,6 +375,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); @@ -484,7 +502,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + enrollmentType }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -493,8 +512,9 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, - EnrollmentType.API + enrollmentType ); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); @@ -523,6 +543,10 @@ export const certificateV3ServiceFactory = ({ const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = extractAlgorithmsFromCSR(csr); + mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm; + mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; + mappedCertificateRequest.validity = validity; + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, mappedCertificateRequest @@ -595,6 +619,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); @@ -654,7 +679,7 @@ export const certificateV3ServiceFactory = ({ status: CertificateOrderStatus.VALID })), authorizations: [], - finalize: `/api/v3/certificates/orders/${orderId}/completed`, + finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`, certificate: certificateResult.certificate, projectId: certificateResult.projectId, profileName: certificateResult.profileName diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index a62a25b73..8a2cf70f7 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -6,6 +6,7 @@ import { CertKeyUsageType, CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; export type TIssueCertificateFromProfileDTO = { profileId: string; @@ -35,6 +36,7 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; + enrollmentType: EnrollmentType; } & Omit; export type TOrderCertificateFromProfileDTO = { diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts index b2bc4df41..d2d2aa3ce 100644 --- a/backend/src/services/certificate/certificate-fns.ts +++ b/backend/src/services/certificate/certificate-fns.ts @@ -1,4 +1,5 @@ import * as x509 from "@peculiar/x509"; +import forge from "node-forge"; import RE2 from "re2"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -104,3 +105,53 @@ export const getCertificateCredentials = async ({ throw new BadRequestError({ message: `Failed to process private key for certificate with ID '${certId}'` }); } }; + +export const generatePkcs12FromCertificate = async ({ + certificate, + certificateChain, + privateKey, + password, + alias +}: { + certificate: string; + certificateChain: string; + privateKey: string; + password: string; + alias: string; +}): Promise => { + try { + if (!password || password.trim() === "") { + throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" }); + } + + const cert = forge.pki.certificateFromPem(certificate); + const key = forge.pki.privateKeyFromPem(privateKey); + + const chainCerts = []; + if (certificateChain) { + const chainPems = splitPemChain(certificateChain); + for (const chainPem of chainPems) { + try { + const chainCert = forge.pki.certificateFromPem(chainPem); + chainCerts.push(chainCert); + } catch (error) { + // Skip invalid certificates in chain + } + } + } + + // Generate PKCS12 file + const p12Asn1 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chainCerts], password, { + algorithm: "aes256", // Modern AES-256 encryption + friendlyName: alias + }); + + const p12Der = forge.asn1.toDer(p12Asn1).getBytes(); + + return Buffer.from(p12Der, "binary"); + } catch (error) { + throw new BadRequestError({ + message: `Failed to generate PKCS12 keystore: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index eb8006f00..b632e76fb 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -29,7 +29,12 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; -import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; +import { + generatePkcs12FromCertificate, + getCertificateCredentials, + revocationReasonToCrlCode, + splitPemChain +} from "./certificate-fns"; import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { CertExtendedKeyUsage, @@ -40,6 +45,7 @@ import { TGetCertBodyDTO, TGetCertBundleDTO, TGetCertDTO, + TGetCertPkcs12DTO, TGetCertPrivateKeyDTO, TImportCertDTO, TRevokeCertDTO @@ -656,6 +662,71 @@ export const certificateServiceFactory = ({ }; }; + const getCertPkcs12 = async ({ + serialNumber, + password, + alias, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCertPkcs12DTO) => { + if (!password || password.trim() === "") { + throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" }); + } + + if (password.length < 6) { + throw new BadRequestError({ + message: "Password must be at least 6 characters long for PKCS12 keystore security" + }); + } + + if (!alias || alias.trim() === "") { + throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" }); + } + const cert = await certificateDAL.findOne({ serialNumber }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: cert.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + // Get certificate bundle (certificate, chain, private key) + const { certificate, certificateChain, privateKey } = await getCertBundle({ + serialNumber, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + if (!privateKey) { + throw new BadRequestError({ message: "Certificate private key is required for PKCS12 export" }); + } + + const pkcs12Data = await generatePkcs12FromCertificate({ + certificate, + certificateChain: certificateChain || "", + privateKey, + password, + alias + }); + + return { + pkcs12Data, + cert + }; + }; + return { getCert, getCertPrivateKey, @@ -663,6 +734,7 @@ export const certificateServiceFactory = ({ revokeCert, getCertBody, importCert, - getCertBundle + getCertBundle, + getCertPkcs12 }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index d654c96ba..085bb9588 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -119,6 +119,12 @@ export type TGetCertBundleDTO = { serialNumber: string; } & Omit; +export type TGetCertPkcs12DTO = { + serialNumber: string; + password: string; + alias: string; +} & Omit; + export type TGetCertificateCredentialsDTO = { certId: string; projectId: string; diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts new file mode 100644 index 000000000..afa8f17ef --- /dev/null +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TAcmeEnrollmentConfigDALFactory = ReturnType; + +export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { + const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); + + const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + throw new Error("Failed to create ACME enrollment config"); + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create ACME enrollment config" }); + } + }; + + const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + return null; + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update ACME enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); + + return acmeConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); + } + }; + + return { + ...acmeEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index d2e03e4da..7fe5a475d 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -1,3 +1,8 @@ +import { + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-acme-enrollment-configs"; import { TPkiApiEnrollmentConfigs, TPkiApiEnrollmentConfigsInsert, @@ -17,6 +22,10 @@ export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; +export type TAcmeEnrollmentConfig = TPkiAcmeEnrollmentConfigs; +export type TAcmeEnrollmentConfigInsert = TPkiAcmeEnrollmentConfigsInsert; +export type TAcmeEnrollmentConfigUpdate = TPkiAcmeEnrollmentConfigsUpdate; + export interface TEstConfigData { disableBootstrapCaValidation: boolean; passphrase: string; @@ -27,3 +36,5 @@ export interface TApiConfigData { autoRenew: boolean; renewBeforeDays?: number; } + +export interface TAcmeConfigData {} diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 3479d929e..244e98908 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -186,8 +186,8 @@ export const identityAccessTokenServiceFactory = ({ const fnValidateIdentityAccessToken = async ( token: TIdentityAccessTokenJwtPayload, - subOrganizationSelector?: string, - ipAddress?: string + ipAddress?: string, + subOrganizationSelector?: string ) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, @@ -227,27 +227,25 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + throw new BadRequestError({ message: "Identity does not belong to this organization" }); } orgId = subOrganization.id; orgName = subOrganization.name; parentOrgId = subOrganization.parentOrgId as string; } else { - const organization = await orgDAL.findOne({ id: rootOrgId }); - const identityOrgMembership = await membershipIdentityDAL.findOne({ scope: AccessScope.Organization, actorIdentityId: identityAccessToken.identityId, - scopeOrgId: rootOrgId + scopeOrgId: identityOrgDetails.id }); if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + throw new BadRequestError({ message: "Identity does not belong to this organization" }); } - orgId = rootOrgId; - orgName = organization.name; + orgId = identityOrgDetails.id; + orgName = identityOrgDetails.name; parentOrgId = rootOrgId; } diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index 525da1e10..fba7fee98 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -11,6 +11,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; @@ -51,7 +52,7 @@ type TIdentityAliCloudAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -105,7 +106,18 @@ export const identityAliCloudAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, lastLoginTime: new Date() @@ -200,7 +212,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -214,16 +226,34 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -280,7 +310,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -300,16 +330,31 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -350,7 +395,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -362,15 +407,31 @@ export const identityAliCloudAuthServiceFactory = ({ const alicloudIdentityAuth = await identityAliCloudAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -389,7 +450,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { @@ -397,45 +458,61 @@ export const identityAliCloudAuthServiceFactory = ({ message: "The identity does not have Alibaba Cloud auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke Alibaba Cloud auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke Alibaba Cloud auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityAliCloudAuth = await identityAliCloudAuthDAL.transaction(async (tx) => { const deletedAliCloudAuth = await identityAliCloudAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.ALICLOUD_AUTH }, tx); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 81fab3fde..b3b6bbfce 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import axios from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +13,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -50,7 +52,7 @@ type TIdentityAwsAuthServiceFactoryDep = { identityAwsAuthDAL: Pick; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -179,7 +181,18 @@ export const identityAwsAuthServiceFactory = ({ const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, lastLoginTime: new Date() @@ -286,7 +299,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -300,16 +313,34 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -370,7 +401,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -389,16 +420,31 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -441,7 +487,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -453,15 +499,31 @@ export const identityAwsAuthServiceFactory = ({ const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...awsIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -480,7 +542,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { @@ -488,45 +550,60 @@ export const identityAwsAuthServiceFactory = ({ message: "The identity does not have aws auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke aws auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke aws auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { const deletedAwsAuth = await identityAwsAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AWS_AUTH }, tx); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 17f274e7c..3c05511d1 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -46,7 +47,7 @@ type TIdentityAzureAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -99,7 +100,18 @@ export const identityAzureAuthServiceFactory = ({ const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginTime: new Date() @@ -192,7 +204,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -205,16 +217,34 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -275,7 +305,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -293,16 +323,31 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -348,7 +393,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -359,16 +404,31 @@ export const identityAzureAuthServiceFactory = ({ const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityAzureAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -387,7 +447,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -395,43 +455,59 @@ export const identityAzureAuthServiceFactory = ({ message: "The identity does not have azure auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke azure auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke azure auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { const deletedAzureAuth = await identityAzureAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AZURE_AUTH }, tx); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 3e0035e82..847abd81f 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -44,7 +45,7 @@ type TIdentityGcpAuthServiceFactoryDep = { identityGcpAuthDAL: Pick; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -139,7 +140,18 @@ export const identityGcpAuthServiceFactory = ({ const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, lastLoginTime: new Date() @@ -232,7 +244,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -246,16 +258,34 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -317,7 +347,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -336,16 +366,31 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -392,7 +437,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -404,16 +449,31 @@ export const identityGcpAuthServiceFactory = ({ const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityGcpAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -432,7 +492,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -441,43 +501,58 @@ export const identityGcpAuthServiceFactory = ({ message: "The identity does not have gcp auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke gcp auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke gcp auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { const deletedGcpAuth = await identityGcpAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.GCP_AUTH }, tx); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 3cf93fd16..82935e4a4 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -1,10 +1,16 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityJwtAuthsUpdate +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +18,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -50,7 +57,7 @@ type TIdentityJwtAuthServiceFactoryDep = { identityJwtAuthDAL: TIdentityJwtAuthDALFactory; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; orgDAL: Pick; @@ -213,8 +220,22 @@ export const identityJwtAuthServiceFactory = ({ const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -309,7 +330,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { @@ -322,16 +343,35 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -416,7 +456,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -435,17 +475,32 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -524,7 +579,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -534,17 +589,32 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -576,7 +646,7 @@ export const identityJwtAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -586,45 +656,60 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke jwt auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke jwt auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index b633dc433..9322e48cb 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import axios, { AxiosError } from "axios"; import https from "https"; @@ -6,6 +6,7 @@ import RE2 from "re2"; import { AccessScope, + ActionProjectType, IdentityAuthMethod, OrganizationActionScope, TIdentityKubernetesAuthsUpdate @@ -25,6 +26,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -69,7 +71,7 @@ type TIdentityKubernetesAuthServiceFactoryDep = { >; identityAccessTokenDAL: Pick; membershipIdentityDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; gatewayService: TGatewayServiceFactory; @@ -448,8 +450,22 @@ export const identityKubernetesAuthServiceFactory = ({ const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -549,7 +565,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -563,16 +579,34 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -679,7 +713,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -699,16 +733,31 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -831,7 +880,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -846,16 +895,31 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.scopeOrgId @@ -897,7 +961,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -906,43 +970,58 @@ export const identityKubernetesAuthServiceFactory = ({ message: "The identity does not have kubernetes auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke kubernetes auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke kubernetes auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const deletedKubernetesAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.KUBERNETES_AUTH }, tx); diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index d327dabee..455b52412 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import slugify from "@sindresorhus/slugify"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -17,6 +17,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; @@ -61,7 +62,7 @@ type TIdentityLdapAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: TKmsServiceFactory; identityDAL: Pick; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; @@ -177,8 +178,22 @@ export const identityLdapAuthServiceFactory = ({ try { const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -276,7 +291,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -290,7 +305,7 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ + const { permission: orgPermission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, actorId, @@ -298,10 +313,30 @@ export const identityLdapAuthServiceFactory = ({ actorAuthMethod, actorOrgId }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } if (templateId) { - ForbiddenError.from(permission).throwUnlessCan( + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate ); @@ -451,7 +486,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -470,7 +505,7 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ + const { permission: orgPermission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, actorId, @@ -478,10 +513,30 @@ export const identityLdapAuthServiceFactory = ({ actorAuthMethod, actorOrgId }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionIdentityActions.Edit, + OrgPermissionSubjects.Identity + ); + } if (templateId) { - ForbiddenError.from(permission).throwUnlessCan( + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate ); @@ -618,7 +673,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -630,14 +685,31 @@ export const identityLdapAuthServiceFactory = ({ const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -650,7 +722,6 @@ export const identityLdapAuthServiceFactory = ({ ? decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedLdapCaCertificate }).toString() : undefined; - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...ldapIdentityAuth, orgId: identityMembershipOrg.scopeOrgId, bindDN, bindPass, ldapCaCertificate }; }; @@ -669,7 +740,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { @@ -677,45 +748,62 @@ export const identityLdapAuthServiceFactory = ({ message: "The identity does not have LDAP Auth attached" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke LDAP auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke LDAP auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { const [deletedLdapAuth] = await identityLdapAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.LDAP_AUTH }, tx); @@ -824,15 +912,31 @@ export const identityLdapAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index c75abc76b..05e56c77d 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +12,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; @@ -49,7 +50,7 @@ type TIdentityOciAuthServiceFactoryDep = { identityOciAuthDAL: Pick; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -110,8 +111,22 @@ export const identityOciAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -203,7 +218,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -217,15 +232,34 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -285,7 +319,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -304,15 +338,31 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -355,7 +405,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -367,15 +417,31 @@ export const identityOciAuthServiceFactory = ({ const ociIdentityAuth = await identityOciAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...ociIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -394,7 +460,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { @@ -402,45 +468,62 @@ export const identityOciAuthServiceFactory = ({ message: "The identity does not have OCI auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke OCI auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke OCI auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityOciAuth = await identityOciAuthDAL.transaction(async (tx) => { const deletedOciAuth = await identityOciAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OCI_AUTH }, tx); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index a03beeb3b..a253c1e95 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -1,11 +1,17 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import axios from "axios"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityOidcAuthsUpdate +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,6 +19,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -23,6 +30,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; @@ -50,7 +58,7 @@ type TIdentityOidcAuthServiceFactoryDep = { identityOidcAuthDAL: TIdentityOidcAuthDALFactory; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; orgDAL: Pick; @@ -111,25 +119,106 @@ export const identityOidcAuthServiceFactory = ({ requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined }); - const { kid } = decodedToken.header as { kid: string }; - const oidcSigningKey = await client.getSigningKey(kid); + const { kid } = decodedToken.header as { kid?: string }; - let tokenData: Record; - try { - tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { + let tokenData: Record | undefined; + + // If kid is provided, try to get the specific signing key + if (kid) { + let oidcSigningKey; + try { + oidcSigningKey = await client.getSigningKey(kid); + } catch (error) { + if (error instanceof Error && error.name === "SigningKeyNotFoundError") { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.` + }); + } throw new UnauthorizedError({ - message: `Access denied: ${error.message}` + message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + }); + } + + try { + tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + throw error; + } + } else { + // If kid is not provided, try all available signing keys + logger.warn( + `OIDC login without KID header [identityId=${identityOidcAuth.identityId}] [orgId=${org.id}] [ip=${requestContext.get("ip")}]` + ); + + let allSigningKeys; + try { + allSigningKeys = await client.getSigningKeys(); + } catch (error) { + throw new UnauthorizedError({ + message: `Access denied: Failed to retrieve signing keys from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + }); + } + + if (!allSigningKeys || allSigningKeys.length === 0) { + throw new UnauthorizedError({ + message: "Access denied: No signing keys available from OIDC provider's JWKS endpoint." + }); + } + + // Limit the number of keys to try to prevent abuse + const MAX_KEYS_TO_TRY = 10; + if (allSigningKeys.length > MAX_KEYS_TO_TRY) { + throw new UnauthorizedError({ + message: `Access denied: OIDC provider has ${allSigningKeys.length} signing keys. Tokens must include 'kid' header when provider has more than ${MAX_KEYS_TO_TRY} keys.` + }); + } + + let lastError: Error | null = null; + let verified = false; + + // Try each signing key until one works + for (const signingKey of allSigningKeys) { + try { + tokenData = crypto.jwt().verify(oidcJwt, signingKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + verified = true; + break; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + lastError = error; + // Continue trying other keys + } else { + throw error; + } + } + } + + if (!verified) { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature with any available signing key. ${lastError ? lastError.message : "Invalid token"}` }); } - throw error; } + // Ensure tokenData was successfully assigned + if (!tokenData) { + throw new UnauthorizedError({ + message: "Access denied: Failed to verify JWT token" + }); + } + + const verifiedTokenData: Record = tokenData; + if (identityOidcAuth.boundSubject) { - if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { + if (!doesFieldValueMatchOidcPolicy(verifiedTokenData.sub, identityOidcAuth.boundSubject)) { throw new ForbiddenRequestError({ message: "Access denied: OIDC subject not allowed." }); @@ -140,7 +229,7 @@ export const identityOidcAuthServiceFactory = ({ if ( !identityOidcAuth.boundAudiences .split(", ") - .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) + .some((policyValue) => doesAudValueMatchOidcPolicy(verifiedTokenData.aud, policyValue)) ) { throw new UnauthorizedError({ message: "Access denied: OIDC audience not allowed." @@ -151,7 +240,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -172,7 +261,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` @@ -184,8 +273,22 @@ export const identityOidcAuthServiceFactory = ({ const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -236,7 +339,7 @@ export const identityOidcAuthServiceFactory = ({ }); } - return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: verifiedTokenData }; } catch (error) { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { authAttemptCounter.add(1, { @@ -284,7 +387,7 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { @@ -297,16 +400,35 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -380,7 +502,7 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -399,16 +521,32 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -473,7 +611,7 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -483,15 +621,31 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); @@ -518,7 +672,7 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -528,46 +682,62 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke oidc auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke oidc auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OIDC_AUTH }, tx); diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index adcdd8be8..c264717d0 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -293,7 +293,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity), db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("orgId").as("identityOrgId").withSchema(TableName.Identity), + db.ref("projectId").as("identityProjectId").withSchema(TableName.Identity), db.ref("id").withSchema(TableName.Membership), + db.ref("lastLoginAuthMethod").withSchema(TableName.Membership), + db.ref("lastLoginTime").withSchema(TableName.Membership), db.ref("role").withSchema(TableName.MembershipRole), db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), db.ref("customRoleId").withSchema(TableName.MembershipRole), @@ -334,6 +338,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { parentMapper: ({ identityId, identityName, + identityOrgId, + identityProjectId, uaId, alicloudId, awsId, @@ -346,7 +352,9 @@ export const identityProjectDALFactory = (db: TDbClient) => { id, createdAt, updatedAt, - projectName + projectName, + lastLoginAuthMethod, + lastLoginTime }) => ({ id, identityId, @@ -355,6 +363,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + projectId: identityProjectId, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -367,6 +377,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { tokenId }) }, + // TODO: scott - not sure why these aren't properly typed? + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginAuthMethod, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginTime, project: { id: projectId, name: projectName diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index abe0446da..363711e16 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -60,8 +60,14 @@ export const identityProjectServiceFactory = ({ }); const totalCount = await identityProjectDAL.getCountByProjectId(projectId, { search }); + const filteredMemberships = identityMemberships.filter((el) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: el.identity.id }) + ) + ); - return { identityMemberships, totalCount }; + return { identityMemberships: filteredMemberships, totalCount }; }; const getProjectIdentityByIdentityId = async ({ diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 60670d035..630638a06 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -43,7 +44,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: Pick; orgDAL: Pick; }; @@ -130,8 +131,22 @@ export const identityTlsCertAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -223,7 +238,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -237,15 +252,34 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -309,7 +343,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -329,15 +363,31 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -392,7 +442,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -404,15 +454,32 @@ export const identityTlsCertAuthServiceFactory = ({ const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } + const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.scopeOrgId @@ -440,7 +507,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { @@ -448,44 +515,61 @@ export const identityTlsCertAuthServiceFactory = ({ message: "The identity does not have TLS Certificate auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke TLS Certificate auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke TLS Certificate auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => { const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx); diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index e6969da61..20c692134 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -1,6 +1,12 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TableName } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TableName +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -8,6 +14,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -49,7 +56,7 @@ type TIdentityTokenAuthServiceFactoryDep = { TIdentityAccessTokenDALFactory, "create" | "find" | "update" | "findById" | "findOne" | "updateById" | "delete" >; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -87,7 +94,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -101,15 +108,34 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -167,7 +193,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -187,15 +213,31 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -239,7 +281,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -251,15 +293,31 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityTokenAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -282,7 +340,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -291,44 +349,61 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke token auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke token auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ @@ -367,45 +442,61 @@ export const identityTokenAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to create token for identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create token for identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); const identity = await identityDAL.findById(identityTokenAuth.identityId); @@ -413,7 +504,18 @@ export const identityTokenAuthServiceFactory = ({ const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, lastLoginTime: new Date() }, tx ); @@ -478,15 +580,32 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const tokens = await identityAccessTokenDAL.find( { @@ -531,43 +650,60 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update token for identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId: identityMembershipOrg.identity.id }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update token for identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const [token] = await identityAccessTokenDAL.update( { authMethod: IdentityAuthMethod.TOKEN_AUTH, @@ -603,24 +739,43 @@ export const identityTokenAuthServiceFactory = ({ await validateIdentityUpdateForSuperAdminPrivileges(identityAccessToken.identityId, isActorSuperAdmin); - const identityOrgMembership = await membershipIdentityDAL.findOne({ - actorIdentityId: identityAccessToken.identityId, - scope: AccessScope.Organization + const identityOrgMembership = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId: identityAccessToken.identityId }); if (!identityOrgMembership) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityAccessToken.identityId}` }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityOrgMembership.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityOrgMembership.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityOrgMembership.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityOrgMembership.identity.id }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityOrgMembership.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const [revokedToken] = await identityAccessTokenDAL.update( { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 26bd01627..5ea5c4a6e 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -51,7 +52,7 @@ type TIdentityUaServiceFactoryDep = { identityUaClientSecretDAL: TIdentityUaClientSecretDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; membershipIdentityDAL: TMembershipIdentityDALFactory; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; keyStore: Pick< @@ -231,7 +232,18 @@ export const identityUaServiceFactory = ({ const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, lastLoginTime: new Date() @@ -343,7 +355,7 @@ export const identityUaServiceFactory = ({ message: "Failed to add universal auth to already configured identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -351,16 +363,35 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { @@ -456,7 +487,7 @@ export const identityUaServiceFactory = ({ }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -467,15 +498,31 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map((clientSecretTrustedIp) => { @@ -550,19 +597,35 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...uaIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -587,46 +650,62 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke universal auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke universal auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { const deletedUniversalAuth = await identityUaDAL.delete({ identityId }, tx); return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; @@ -658,47 +737,65 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to create client secret for identity.", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create client secret for identity.", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const appCfg = getConfig(); const clientSecret = crypto.randomBytes(32).toString("hex"); const clientSecretHash = await crypto.hashing().createHash(clientSecret, appCfg.SALT_ROUNDS); @@ -744,47 +841,63 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to get identity client secret with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.GetToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to get identity client secret with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const identityUniversalAuth = await identityUaDAL.findOne({ identityId }); @@ -818,7 +931,7 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -828,43 +941,57 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to read identity client secret of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.GetToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to read identity client secret of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } return { ...clientSecret, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -890,7 +1017,7 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -900,45 +1027,63 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.DeleteToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) { - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke identity client secret with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.DeleteToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); - } + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.DeleteToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Delete, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) { + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke identity client secret with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } const updatedClientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { isClientSecretRevoked: true }); @@ -967,20 +1112,35 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*` }); diff --git a/backend/src/services/identity-v2/identity-dal.ts b/backend/src/services/identity-v2/identity-dal.ts new file mode 100644 index 000000000..b150cc51e --- /dev/null +++ b/backend/src/services/identity-v2/identity-dal.ts @@ -0,0 +1,181 @@ +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, IdentitiesSchema, TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +import { buildAuthMethods } from "../identity/identity-fns"; + +export type TIdentityV2DALFactory = ReturnType; + +export const identityV2DALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Identity); + + const getIdentityById = async (scopeData: AccessScopeData, identityId: string) => { + const doc = await db + .replicaNode()(TableName.Identity) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder.on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`); + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) + .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) + .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) + .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) + .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) + .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .where(`${TableName.Identity}.id`, identityId) + .where(`${TableName.Identity}.orgId`, scopeData.orgId) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Identity}.projectId`, scopeData.projectId); + } else { + void qb.whereNull(`${TableName.Identity}.projectId`); + } + }) + .select( + selectAllTableCols(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + ); + + if (!doc) return doc; + + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (el) => { + const { + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + alicloudId, + tokenId, + jwtId, + ociId, + ldapId, + tlsCertId + } = el; + return { + ...IdentitiesSchema.parse(el), + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + alicloudId, + jwtId, + ldapId, + ociId, + tlsCertId + }) + }; + }, + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return formattedDoc?.[0]; + }; + + const listIdentities = async ( + scopeData: AccessScopeData, + filter: { limit?: number; offset?: number; search?: string } = {} + ) => { + const query = db + .replicaNode()(TableName.Identity) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder.on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`); + }) + .where(`${TableName.Identity}.orgId`, scopeData.orgId) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Identity}.projectId`, scopeData.projectId); + } else { + void qb.whereNull(`${TableName.Identity}.projectId`); + } + }) + .select( + selectAllTableCols(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ) + .select(db.raw(`count(distinct ??) over () as ??`, [`${TableName.Identity}.id`, "count"])); + + if (filter.limit) void query.limit(filter.limit); + if (filter.offset) void query.offset(filter.offset || 0); + + if (filter.search) void query.whereILike(`${TableName.Identity}.name`, `%${filter.search}%`); + + const docs = await query; + + const formattedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => IdentitiesSchema.parse(el), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return { docs: formattedDoc, count: Number((docs?.[0] as unknown as { count: number })?.count) }; + }; + + return { ...orm, listIdentities, getIdentityById }; +}; diff --git a/backend/src/services/identity-v2/identity-fns.ts b/backend/src/services/identity-v2/identity-fns.ts new file mode 100644 index 000000000..fde9ef2d3 --- /dev/null +++ b/backend/src/services/identity-v2/identity-fns.ts @@ -0,0 +1,24 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; + +export const getIdentityActiveLockoutAuthMethods = async ( + identityId: string, + keyStore: Pick +) => { + const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${identityId}:*`); + + const activeLockoutAuthMethods = new Set(); + for await (const key of activeLockouts) { + const parts = key.split(":"); + if (parts.length > 3) { + const lockoutRaw = await keyStore.getItem(key); + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; + if (lockout.lockedOut) { + activeLockoutAuthMethods.add(parts[3]); + } + } + } + } + + return Array.from(activeLockoutAuthMethods); +}; diff --git a/backend/src/services/identity-v2/identity-service.ts b/backend/src/services/identity-v2/identity-service.ts new file mode 100644 index 000000000..8d161794b --- /dev/null +++ b/backend/src/services/identity-v2/identity-service.ts @@ -0,0 +1,251 @@ +import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { getIdentityActiveLockoutAuthMethods } from "@app/services/identity-v2/identity-fns"; + +import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TIdentityV2DALFactory } from "./identity-dal"; +import { + TCreateIdentityV2DTO, + TDeleteIdentityV2DTO, + TGetIdentityByIdV2DTO, + TListIdentityV2DTO, + TUpdateIdentityV2DTO +} from "./identity-types"; +import { newOrgIdentityFactory } from "./org/org-identity-factory"; +import { newProjectIdentityFactory } from "./project/project-identity-factory"; + +type TScopedIdentityV2ServiceFactoryDep = { + identityDAL: TIdentityV2DALFactory; + permissionService: TPermissionServiceFactory; + licenseService: Pick; + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; + identityMetadataDAL: TIdentityMetadataDALFactory; + keyStore: Pick; +}; + +export type TScopedIdentityV2ServiceFactory = ReturnType; + +export const identityV2ServiceFactory = ({ + identityDAL, + permissionService, + licenseService, + membershipIdentityDAL, + membershipRoleDAL, + identityMetadataDAL, + keyStore +}: TScopedIdentityV2ServiceFactoryDep) => { + const orgFactory = newOrgIdentityFactory({ + permissionService + }); + const projectFactory = newProjectIdentityFactory({ + permissionService + }); + + const scopeFactory = { + [AccessScope.Organization]: orgFactory, + [AccessScope.Project]: projectFactory, + // namespace will get stripped off + [AccessScope.Namespace]: orgFactory + }; + + const createIdentity = async (dto: TCreateIdentityV2DTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onCreateIdentityGuard(dto); + + const plan = await licenseService.getPlan(dto.permission.orgId); + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to create identity due to identity limit reached. Upgrade plan to create more identities." + }); + } + + const identity = await identityDAL.transaction(async (tx) => { + const newIdentity = await identityDAL.create( + { + name: data.name, + hasDeleteProtection: data.hasDeleteProtection, + orgId: dto.permission.orgId, + projectId: scopeData.scope === AccessScope.Project ? scopeData.projectId : null + }, + tx + ); + const orgMembership = await membershipIdentityDAL.create( + { + scope: AccessScope.Organization, + actorIdentityId: newIdentity.id, + scopeOrgId: dto.permission.orgId + }, + tx + ); + + const newMembershipIds = [orgMembership.id]; + if (scopeData.scope === AccessScope.Project) { + const projectMembership = await membershipIdentityDAL.create( + { + scope: AccessScope.Project, + actorIdentityId: newIdentity.id, + scopeOrgId: dto.permission.orgId, + scopeProjectId: scopeData.projectId + }, + tx + ); + newMembershipIds.push(projectMembership.id); + } + + await membershipRoleDAL.insertMany( + newMembershipIds.map((membershipId) => ({ + membershipId, + role: OrgMembershipRole.NoAccess + })), + tx + ); + + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + + if (data.metadata && data.metadata.length) { + const rowsToInsert = data.metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId: newIdentity.orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); + } + + return { + ...newIdentity, + authMethods: [], + metadata: insertedMetadata + }; + }); + await licenseService.updateSubscriptionOrgMemberCount(dto.permission.orgId); + + return { identity }; + }; + + const updateIdentity = async (dto: TUpdateIdentityV2DTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateIdentityGuard(dto); + const existingIdentity = await identityDAL.findOne({ + id: dto.selector.identityId, + orgId: dto.permission.orgId, + projectId: dto.scopeData.scope === AccessScope.Project ? dto.scopeData.projectId : null + }); + if (!existingIdentity) + throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const identity = await identityDAL.transaction(async (tx) => { + const updatedIdentity = + data?.name || data?.hasDeleteProtection + ? await identityDAL.updateById( + dto.selector.identityId, + { name: data.name, hasDeleteProtection: data.hasDeleteProtection }, + tx + ) + : existingIdentity; + + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + + if (data.metadata) { + await identityMetadataDAL.delete({ orgId: dto.permission.orgId, identityId: dto.selector.identityId }, tx); + + if (data.metadata.length) { + const rowsToInsert = data.metadata.map(({ key, value }) => ({ + identityId: updatedIdentity.id, + orgId: updatedIdentity.orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); + } + } + + return { + ...updatedIdentity, + metadata: insertedMetadata + }; + }); + + return { identity }; + }; + + const deleteIdentity = async (dto: TDeleteIdentityV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteIdentityGuard(dto); + + const existingIdentity = await identityDAL.findOne({ + id: dto.selector.identityId, + orgId: dto.permission.orgId, + projectId: dto.scopeData.scope === AccessScope.Project ? dto.scopeData.projectId : null + }); + if (!existingIdentity) + throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const deletedIdentity = await identityDAL.deleteById(dto.selector.identityId); + + await licenseService.updateSubscriptionOrgMemberCount(scopeData.orgId); + + return { identity: deletedIdentity }; + }; + + const getIdentityById = async (dto: TGetIdentityByIdV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetIdentityByIdGuard(dto); + + const identity = await identityDAL.getIdentityById(dto.scopeData, dto.selector.identityId); + if (!identity) throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const activeLockoutAuthMethods = await getIdentityActiveLockoutAuthMethods(identity.id, keyStore); + + return { identity: { ...identity, activeLockoutAuthMethods } }; + }; + + const listIdentities = async (dto: TListIdentityV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + const isIdentityAccessible = await factory.onListIdentityGuard(dto); + + const identities = await identityDAL.listIdentities(dto.scopeData, { + search: dto.data.search, + offset: dto.data.offset, + limit: dto.data.limit + }); + + return { ...identities, docs: identities.docs.filter((el) => isIdentityAccessible({ identityId: el.id })) }; + }; + + return { + createIdentity, + updateIdentity, + deleteIdentity, + getIdentityById, + listIdentities + }; +}; diff --git a/backend/src/services/identity-v2/identity-types.ts b/backend/src/services/identity-v2/identity-types.ts new file mode 100644 index 000000000..26bff3a97 --- /dev/null +++ b/backend/src/services/identity-v2/identity-types.ts @@ -0,0 +1,67 @@ +import { AccessScopeData } from "@app/db/schemas"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; + +export interface TIdentityV2Factory { + onCreateIdentityGuard: (arg: TCreateIdentityV2DTO) => Promise; + onUpdateIdentityGuard: (arg: TUpdateIdentityV2DTO) => Promise; + onDeleteIdentityGuard: (arg: TDeleteIdentityV2DTO) => Promise; + onListIdentityGuard: (arg: TListIdentityV2DTO) => Promise<(arg: { identityId: string }) => boolean>; + onGetIdentityByIdGuard: (arg: TGetIdentityByIdV2DTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; +} + +export enum IdentityOrderBy { + Name = "name", + Role = "role" +} + +export type TCreateIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + name: string; + hasDeleteProtection: boolean; + metadata?: { key: string; value: string }[]; + }; +}; + +export type TUpdateIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; + data: Partial<{ + name: string; + hasDeleteProtection: boolean; + metadata?: { key: string; value: string }[]; + }>; +}; + +export type TDeleteIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TGetIdentityByIdV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TListIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: Partial<{ + limit: number; + offset: number; + orderBy: IdentityOrderBy; + orderDirection: OrderByDirection; + search: string; + }>; +}; diff --git a/backend/src/services/identity-v2/org/org-identity-factory.ts b/backend/src/services/identity-v2/org/org-identity-factory.ts new file mode 100644 index 000000000..264bdca82 --- /dev/null +++ b/backend/src/services/identity-v2/org/org-identity-factory.ts @@ -0,0 +1,92 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { InternalServerError } from "@app/lib/errors"; + +import { TIdentityV2Factory } from "../identity-types"; + +type TOrgIdentityFactoryDep = { + permissionService: Pick; +}; + +export const newOrgIdentityFactory = ({ permissionService }: TOrgIdentityFactoryDep): TIdentityV2Factory => { + const getScopeField: TIdentityV2Factory["getScopeField"] = (scopeData) => { + if (scopeData.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: scopeData.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const onCreateIdentityGuard: TIdentityV2Factory["onCreateIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + }; + + const onUpdateIdentityGuard: TIdentityV2Factory["onUpdateIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + }; + + const onDeleteIdentityGuard: TIdentityV2Factory["onDeleteIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + }; + + const onListIdentityGuard: TIdentityV2Factory["onListIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + return () => true; + }; + + const onGetIdentityByIdGuard: TIdentityV2Factory["onGetIdentityByIdGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + return { + onCreateIdentityGuard, + onUpdateIdentityGuard, + onDeleteIdentityGuard, + onListIdentityGuard, + onGetIdentityByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/identity-v2/project/project-identity-factory.ts b/backend/src/services/identity-v2/project/project-identity-factory.ts new file mode 100644 index 000000000..4d242fe02 --- /dev/null +++ b/backend/src/services/identity-v2/project/project-identity-factory.ts @@ -0,0 +1,116 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { AccessScope, ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { InternalServerError } from "@app/lib/errors"; + +import { TIdentityV2Factory } from "../identity-types"; + +type TProjectIdentityFactoryDep = { + permissionService: Pick; +}; + +export const newProjectIdentityFactory = ({ permissionService }: TProjectIdentityFactoryDep): TIdentityV2Factory => { + const getScopeField: TIdentityV2Factory["getScopeField"] = (scopeData) => { + if (scopeData.scope === AccessScope.Project) { + return { key: "projectId" as const, value: scopeData.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const onCreateIdentityGuard: TIdentityV2Factory["onCreateIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + ProjectPermissionSub.Identity + ); + }; + + const onUpdateIdentityGuard: TIdentityV2Factory["onUpdateIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + const onDeleteIdentityGuard: TIdentityV2Factory["onDeleteIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Delete, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + const onListIdentityGuard: TIdentityV2Factory["onListIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + + return (arg) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: arg.identityId }) + ); + }; + + const onGetIdentityByIdGuard: TIdentityV2Factory["onGetIdentityByIdGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + return { + onCreateIdentityGuard, + onUpdateIdentityGuard, + onDeleteIdentityGuard, + onListIdentityGuard, + onGetIdentityByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 66556f5fa..117195ca7 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -159,6 +159,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .join(TableName.Membership, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) .select( selectAllTableCols(TableName.Membership), @@ -188,9 +189,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`paginatedIdentity.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`paginatedIdentity.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`paginatedIdentity.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .leftJoin( TableName.IdentityUniversalAuth, @@ -404,6 +403,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where(`${TableName.Membership}.scopeOrgId`, orgId) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .orderBy( @@ -447,9 +447,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .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`); }) .leftJoin( TableName.IdentityUniversalAuth, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f6ec60e9e..cf8d185c0 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -11,6 +11,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { getIdentityActiveLockoutAuthMethods } from "@app/services/identity-v2/identity-fns"; import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; @@ -142,7 +143,7 @@ export const identityServiceFactory = ({ if (metadata && metadata.length) { const rowsToInsert = metadata.map(({ key, value }) => ({ identityId: newIdentity.id, - orgId, + orgId: newIdentity.orgId, key, value })); @@ -220,6 +221,11 @@ export const identityServiceFactory = ({ } const identityDetails = await identityDAL.findById(id); + + if (identityDetails.projectId) { + throw new BadRequestError({ message: `Identity is managed by project` }); + } + const identity = await identityDAL.transaction(async (tx) => { const newIdentity = identityDetails.orgId === actorOrgId && (name || hasDeleteProtection) @@ -243,13 +249,13 @@ export const identityServiceFactory = ({ value: string; }> = []; - if (metadata) { - await identityMetadataDAL.delete({ orgId: identityOrgMembership.scopeOrgId, identityId: id }, tx); + if (metadata && identityDetails.orgId === actorOrgId) { + await identityMetadataDAL.delete({ orgId: newIdentity.orgId, identityId: id }, tx); if (metadata.length) { const rowsToInsert = metadata.map(({ key, value }) => ({ identityId: newIdentity.id, - orgId: identityOrgMembership.scopeOrgId, + orgId: newIdentity.orgId, key, value })); @@ -286,25 +292,11 @@ export const identityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); - - const activeLockoutAuthMethods = new Set(); - for await (const key of activeLockouts) { - const parts = key.split(":"); - if (parts.length > 3) { - const lockoutRaw = await keyStore.getItem(key); - if (lockoutRaw) { - const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; - if (lockout.lockedOut) { - activeLockoutAuthMethods.add(parts[3]); - } - } - } - } + const activeLockoutAuthMethods = await getIdentityActiveLockoutAuthMethods(id, keyStore); return { ...identity, - identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) } + identity: { ...identity.identity, activeLockoutAuthMethods } }; }; @@ -337,10 +329,14 @@ export const identityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - if (identityOrgMembership.identity.hasDeleteProtection) - throw new BadRequestError({ message: "Identity has delete protection" }); + if (identityOrgMembership.identity.projectId) { + throw new BadRequestError({ message: `Identity is managed by project` }); + } + + if (identityOrgMembership.identity.orgId === actorOrgId) { + if (identityOrgMembership.identity.hasDeleteProtection) + throw new BadRequestError({ message: "Identity has delete protection" }); - if (identityOrgMembership.identity.identityOrgId === actorOrgId) { const deletedIdentity = await identityDAL.deleteById(id); await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.scopeOrgId); return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 682bfef3e..4a90e1edd 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -40,9 +40,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .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`, scopeData.orgId) .where(`${TableName.Membership}.actorIdentityId`, identityId) @@ -92,6 +90,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -134,6 +133,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { const { identityId: actorIdentityId, identityOrgId, + identityProjectId, identityHasDeleteProtection, identityName, uaId, @@ -155,7 +155,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { name: identityName, id: actorIdentityId, hasDeleteProtection: identityHasDeleteProtection, - identityOrgId, + orgId: identityOrgId, + projectId: identityProjectId, authMethods: buildAuthMethods({ uaId, awsId, @@ -213,7 +214,10 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { ] }); - return data?.[0]; + const el = data?.[0]; + if (!el) return el; + + return { ...el, identity: { ...el.identity, metadata: el.metadata } }; } catch (error) { throw new DatabaseError({ error, name: "MembershipGetByIdentityId" }); } @@ -281,6 +285,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .select( db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -310,13 +316,22 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { data: docs, key: "id", parentMapper: (el) => { - const { identityId: actorIdentityId, identityHasDeleteProtection, identityName } = el; + const { + identityId: actorIdentityId, + identityHasDeleteProtection, + identityName, + identityProjectId, + identityOrgId + } = el; return { ...MembershipsSchema.parse(el), + identityId: actorIdentityId, identity: { name: identityName, id: actorIdentityId, - hasDeleteProtection: identityHasDeleteProtection + hasDeleteProtection: identityHasDeleteProtection, + orgId: identityOrgId, + projectId: identityProjectId } }; }, @@ -356,14 +371,20 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { } }; - // this right now only support sub organization - const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + const listAvailableIdentities = async (scopeData: AccessScopeData, rootOrgId: string) => { + // TODO (akhil/scott): need to implement filters + try { - const usersConnectedToOrg = db + const identitiesConnectedToOrg = db .replicaNode()(TableName.Membership) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scope`, AccessScope.Organization) - .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.scope`, scopeData.scope) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) .select("actorIdentityId"); const docs = await db @@ -371,8 +392,16 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) - .whereNotIn(`${TableName.Membership}.actorIdentityId`, usersConnectedToOrg) + .whereNull(`${TableName.Identity}.projectId`) + .where((qb) => { + // if sub org pick from root and if project pick from org of project + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scopeOrgId`, rootOrgId); + } else { + void qb.where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId); + } + }) + .whereNotIn(`${TableName.Membership}.actorIdentityId`, identitiesConnectedToOrg) .select( db.ref("id").withSchema(TableName.Identity), db.ref("name").withSchema(TableName.Identity), diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 16292ea82..ab63c4508 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -55,7 +55,8 @@ export const membershipIdentityServiceFactory = ({ [AccessScope.Project]: newProjectMembershipIdentityFactory({ membershipIdentityDAL, orgDAL, - permissionService + permissionService, + identityDAL }), [AccessScope.Namespace]: newNamespaceMembershipIdentityFactory({}) }; @@ -298,7 +299,7 @@ export const membershipIdentityServiceFactory = ({ const { scopeData } = dto; const factory = scopeFactory[scopeData.scope]; - await factory.onListMembershipIdentityGuard(dto); + const listFilter = await factory.onListMembershipIdentityGuard(dto); const memberships = await membershipIdentityDAL.findIdentities({ scopeData, filter: { @@ -316,7 +317,7 @@ export const membershipIdentityServiceFactory = ({ : undefined } }); - return memberships; + return { ...memberships, data: memberships.data.filter((el) => listFilter({ identityId: el.identity.id })) }; }; const getMembershipByIdentityId = async (dto: TGetMembershipIdentityByIdentityIdDTO) => { @@ -339,13 +340,10 @@ export const membershipIdentityServiceFactory = ({ await factory.onListMembershipIdentityGuard(dto); - const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); - if (!organizationDetails.rootOrgId) return { identities: [] }; + if (scopeData.scope !== AccessScope.Project && dto.permission.rootOrgId === dto.permission.orgId) + return { identities: [] }; - const identities = await membershipIdentityDAL.listAvailableIdentities( - organizationDetails.id, - organizationDetails.rootOrgId - ); + const identities = await membershipIdentityDAL.listAvailableIdentities(dto.scopeData, dto.permission.rootOrgId); return { identities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts index 78923cb14..365a83db4 100644 --- a/backend/src/services/membership-identity/membership-identity-types.ts +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -6,7 +6,7 @@ export interface TMembershipIdentityScopeFactory { onUpdateMembershipIdentityGuard: (arg: TUpdateMembershipIdentityDTO) => Promise; onDeleteMembershipIdentityGuard: (arg: TDeleteMembershipIdentityDTO) => Promise; - onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise; + onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise<(arg: { identityId: string }) => boolean>; onGetMembershipIdentityByIdentityIdGuard: (arg: TGetMembershipIdentityByIdentityIdDTO) => Promise; getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; getScopeDatabaseFields: (scope: AccessScopeData) => { diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts index 8b3bdf6d5..8a95a55f4 100644 --- a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -60,6 +60,10 @@ export const newOrgMembershipIdentityFactory = ({ throw new BadRequestError({ message: "Only identities from parent organization can be invited" }); } + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } + const permissionRoles = await permissionService.getOrgPermissionByRoles( dto.data.roles.map((el) => el.role), dto.permission.orgId @@ -129,6 +133,11 @@ export const newOrgMembershipIdentityFactory = ({ }); } } + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } }; const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( @@ -153,6 +162,10 @@ export const newOrgMembershipIdentityFactory = ({ if (identityDetails.orgId === dto.permission.orgId) { throw new BadRequestError({ message: "Identity cannot exist as orphan" }); } + + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( @@ -167,6 +180,8 @@ export const newOrgMembershipIdentityFactory = ({ scope: OrganizationActionScope.Any }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + return () => true; }; const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = diff --git a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts index f2896047f..82cfd0777 100644 --- a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { AccessScope, ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { @@ -12,6 +12,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity-dal"; @@ -19,6 +20,8 @@ import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; type TProjectMembershipIdentityScopeFactoryDep = { permissionService: Pick; + + identityDAL: Pick; orgDAL: Pick; membershipIdentityDAL: Pick; }; @@ -26,7 +29,8 @@ type TProjectMembershipIdentityScopeFactoryDep = { export const newProjectMembershipIdentityFactory = ({ permissionService, orgDAL, - membershipIdentityDAL + membershipIdentityDAL, + identityDAL }: TProjectMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Project) { @@ -68,6 +72,11 @@ export const newProjectMembershipIdentityFactory = ({ if (!orgMembership) throw new BadRequestError({ message: `Identity ${dto.data.identityId} is missing organization membership` }); + const identityDetails = await identityDAL.findById(dto.data.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create project membership for a project scoped identity" }); + } + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); const permissionRoles = await permissionService.getProjectPermissionByRoles( dto.data.roles.map((el) => el.role), @@ -110,9 +119,14 @@ export const newProjectMembershipIdentityFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId && identityDetails.projectId !== scope.value) { + throw new BadRequestError({ message: "Failed to update project membership for a project scoped identity" }); + } + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); const permissionRoles = await permissionService.getProjectPermissionByRoles( dto.data.roles.filter((el) => el.role !== ProjectMembershipRole.NoAccess).map((el) => el.role), @@ -154,8 +168,13 @@ export const newProjectMembershipIdentityFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Delete, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to delete project membership for a project scoped identity" }); + } }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( @@ -175,6 +194,12 @@ export const newProjectMembershipIdentityFactory = ({ ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity ); + + return (arg) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: arg.identityId }) + ); }; const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = @@ -191,7 +216,7 @@ export const newProjectMembershipIdentityFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Read, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); }; diff --git a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts index 918b96e89..b1f9fec02 100644 --- a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts +++ b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts @@ -1,16 +1,20 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TMicrosoftTeamsIntegrations } from "@app/db/schemas"; +import { TProjectMicrosoftTeamsConfigs } from "@app/db/schemas/project-microsoft-teams-configs"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType; +export type TProjectMicrosoftTeamsConfigWithIntegrations = TProjectMicrosoftTeamsConfigs & TMicrosoftTeamsIntegrations; export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => { const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs); const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { - return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs) + return (tx || db.replicaNode())( + TableName.ProjectMicrosoftTeamsConfigs + ) .join( TableName.MicrosoftTeamsIntegrations, `${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`, diff --git a/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts new file mode 100644 index 000000000..b16432a34 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertChannels, TPkiAlertChannelsInsert } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertChannelDALFactory = ReturnType; + +export const pkiAlertChannelDALFactory = (db: TDbClient) => { + const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels); + + const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise => { + try { + if (!data.length) return []; + + const serializedData = data.map((item) => ({ + ...item, + config: item.config ? JSON.stringify(item.config) : null + })); + + const res = await (tx || db)(TableName.PkiAlertChannels).insert(serializedData).returning("*"); + + return res as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "InsertMany" }); + } + }; + + const findByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const channels = await (tx || db.replicaNode())(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .orderBy(`${TableName.PkiAlertChannels}.createdAt`, "asc"); + + return channels as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const deleteByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .del(); + + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "DeleteByAlertId" }); + } + }; + + return { + ...pkiAlertChannelOrm, + insertMany, + findByAlertId, + deleteByAlertId + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts new file mode 100644 index 000000000..ceb2820c4 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts @@ -0,0 +1,110 @@ +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertHistory } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertHistoryDALFactory = ReturnType; + +export const pkiAlertHistoryDALFactory = (db: TDbClient) => { + const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory); + + const createWithCertificates = async ( + alertId: string, + certificateIds: string[], + options?: { + hasNotificationSent?: boolean; + notificationError?: string; + } + ): Promise => { + try { + return await db.transaction(async (tx) => { + const historyRecords = await tx(TableName.PkiAlertHistory) + .insert({ + alertId, + hasNotificationSent: options?.hasNotificationSent || false, + notificationError: options?.notificationError + }) + .returning("*"); + + const historyRecord = historyRecords[0]; + + if (certificateIds.length > 0) { + const certificateAssociations = certificateIds.map((certificateId) => ({ + alertHistoryId: historyRecord.id, + certificateId + })); + + await tx(TableName.PkiAlertHistoryCertificate).insert(certificateAssociations); + } + + return historyRecord; + }); + } catch (error) { + throw new DatabaseError({ error, name: "CreateWithCertificates" }); + } + }; + + const findByAlertId = async ( + alertId: string, + options?: { + limit?: number; + offset?: number; + } + ): Promise => { + try { + let query = db + .replicaNode() + .select(selectAllTableCols(TableName.PkiAlertHistory)) + .from(TableName.PkiAlertHistory) + .where(`${TableName.PkiAlertHistory}.alertId`, alertId) + .orderBy(`${TableName.PkiAlertHistory}.triggeredAt`, "desc"); + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.offset(options.offset); + } + + const results = await query; + return results as TPkiAlertHistory[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const findRecentlyAlertedCertificates = async ( + alertId: string, + certificateIds: string[], + withinHours = 24 + ): Promise => { + try { + if (certificateIds.length === 0) return []; + + const cutoffDate = new Date(); + cutoffDate.setHours(cutoffDate.getHours() - withinHours); + + const results = (await db + .replicaNode() + .select("cert.certificateId") + .from(`${TableName.PkiAlertHistory} as hist`) + .join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId") + .where("hist.alertId", alertId) + .where("hist.hasNotificationSent", true) + .where("hist.triggeredAt", ">=", cutoffDate) + .whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>; + + return results.map((row) => row.certificateId); + } catch (error) { + throw new DatabaseError({ error, name: "FindRecentlyAlertedCertificates" }); + } + }; + + return { + ...pkiAlertHistoryOrm, + createWithCertificates, + findByAlertId, + findRecentlyAlertedCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts new file mode 100644 index 000000000..d10c1689b --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts @@ -0,0 +1,556 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + applyCaFilters, + applyCertificateFilters, + requiresProfileJoin, + sanitizeLikeInput, + shouldIncludeCAs +} from "./pki-alert-v2-filter-utils"; +import { CertificateOrigin, TCertificatePreview, TPkiFilterRule } from "./pki-alert-v2-types"; + +export type TPkiAlertV2DALFactory = ReturnType; + +export const pkiAlertV2DALFactory = (db: TDbClient) => { + const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2); + + const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise => { + try { + const serializedData = { + ...data, + filters: data.filters ? JSON.stringify(data.filters) : null + }; + const [res] = await (tx || db)(TableName.PkiAlertsV2).insert(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Create" }); + } + }; + + const updateById = async (id: string, data: TPkiAlertsV2Update, tx?: Knex): Promise => { + try { + const serializedData: Record = { + ...data, + filters: data.filters !== undefined ? JSON.stringify(data.filters) : undefined + }; + Object.keys(serializedData).forEach((key) => { + if (serializedData[key] === undefined) { + delete serializedData[key]; + } + }); + + const [res] = await (tx || db)(TableName.PkiAlertsV2).where({ id }).update(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "UpdateById" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const [res] = await (tx || db.replicaNode())(TableName.PkiAlertsV2).where({ id }).select("*"); + + if (!res) return null; + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + type TChannelResult = { + id: string; + alertId: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }; + + type TAlertWithChannels = TPkiAlertsV2 & { + channels: TChannelResult[]; + }; + + const findByIdWithChannels = async (alertId: string, tx?: Knex): Promise => { + try { + const [alert] = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.id`, alertId)) as TPkiAlertsV2[]; + + if (!alert) return null; + + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId)) as TChannelResult[]; + + return { + ...alert, + channels: channels || [] + } as TAlertWithChannels; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdWithChannels" }); + } + }; + + const findByProjectIdWithCount = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise<{ alerts: TAlertWithChannels[]; total: number }> => { + try { + let countQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + countQuery = countQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + let alertQuery = (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + alertQuery = alertQuery.orderBy(`${TableName.PkiAlertsV2}.createdAt`, "desc"); + + if (filters?.limit) { + alertQuery = alertQuery.limit(filters.limit); + } + + if (filters?.offset) { + alertQuery = alertQuery.offset(filters.offset); + } + + const [countResult, alerts] = await Promise.all([countQuery, alertQuery]); + + const total = parseInt((countResult[0] as { count: string }).count, 10); + + const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id); + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .whereIn(`${TableName.PkiAlertChannels}.alertId`, alertIds)) as TChannelResult[]; + + const channelsByAlertId = channels.reduce( + (acc, channel) => { + if (!acc[channel.alertId]) { + acc[channel.alertId] = []; + } + acc[channel.alertId].push(channel); + return acc; + }, + {} as Record + ); + + const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({ + ...alert, + channels: channelsByAlertId[alert.id] || [] + })); + + return { alerts: alertsWithChannels, total }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByProjectIdWithCount" }); + } + }; + + const findByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise => { + const result = await findByProjectIdWithCount(projectId, filters, tx); + return result.alerts; + }; + + const countByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + query = query.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return parseInt((result[0] as { count: string }).count, 10); + } catch (error) { + throw new DatabaseError({ error, name: "CountByProjectId" }); + } + }; + + const getDistinctProjectIds = async ( + filters?: { + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()).distinct(`${TableName.PkiAlertsV2}.projectId`).from(TableName.PkiAlertsV2); + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return result.map((row: { projectId: string }) => row.projectId); + } catch (error) { + throw new DatabaseError({ error, name: "GetDistinctProjectIds" }); + } + }; + + const findMatchingCertificates = async ( + projectId: string, + filters: TPkiFilterRule[] = [], + options?: { + limit?: number; + offset?: number; + alertBefore?: string; + showFutureMatches?: boolean; + showCurrentMatches?: boolean; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + }, + tx?: Knex + ): Promise<{ certificates: TCertificatePreview[]; total: number }> => { + try { + const includeCAs = shouldIncludeCAs(filters); + const needsProfileJoin = requiresProfileJoin(filters); + const limit = options?.limit || 10; + const offset = options?.offset || 0; + + let caTotalCount = 0; + let certTotalCount = 0; + + if (includeCAs) { + let caCountQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caCountQuery = applyCaFilters(caCountQuery, filters, projectId) as typeof caCountQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else if (options.showCurrentMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } else { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + const caCountResult = await caCountQuery; + caTotalCount = parseInt((caCountResult[0] as { count: string }).count, 10); + } + + let certCountQuery = (tx || db.replicaNode()).count("* as count").from(TableName.Certificate); + certCountQuery = applyCertificateFilters(certCountQuery, filters, projectId) as typeof certCountQuery; + + if (options?.showPreview) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certCountQuery = certCountQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + const certCountResult = await certCountQuery; + certTotalCount = parseInt((certCountResult[0] as { count: string }).count, 10); + + const totalCount = caTotalCount + certTotalCount; + let results: TCertificatePreview[] = []; + + const fetchCertificates = async (certLimit: number, certOffset: number) => { + const selectColumns = [ + `${TableName.Certificate}.id`, + `${TableName.Certificate}.serialNumber`, + `${TableName.Certificate}.commonName`, + `${TableName.Certificate}.altNames as san`, + `${TableName.Certificate}.notBefore`, + `${TableName.Certificate}.notAfter`, + `${TableName.Certificate}.status`, + `${TableName.Certificate}.profileId`, + `${TableName.Certificate}.pkiSubscriberId` + ]; + + if (needsProfileJoin) { + selectColumns.push("profile.name as profileName"); + } + + let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate); + + certificateQuery = applyCertificateFilters(certificateQuery, filters, projectId) as typeof certificateQuery; + + if (options?.showPreview) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certificateQuery = certificateQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + certificateQuery = certificateQuery + .orderBy(`${TableName.Certificate}.notAfter`, "asc") + .limit(certLimit) + .offset(certOffset); + + const certificates = await certificateQuery; + const formattedCertificates: TCertificatePreview[] = ( + certificates as Array<{ + id: string; + serialNumber: string; + commonName: string; + san: string[] | null; + profileId: string | null; + pkiSubscriberId: string | null; + profileName?: string | null; + notBefore: Date; + notAfter: Date; + status: string; + }> + ).map((cert) => { + let enrollmentType = CertificateOrigin.UNKNOWN; + if (cert.profileId) { + enrollmentType = CertificateOrigin.PROFILE; + } else if (cert.pkiSubscriberId) { + enrollmentType = CertificateOrigin.IMPORT; + } + + return { + id: cert.id, + serialNumber: cert.serialNumber, + commonName: cert.commonName, + san: Array.isArray(cert.san) ? cert.san : [], + profileName: cert.profileName || null, + enrollmentType, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + status: cert.status + }; + }); + + results = [...results, ...formattedCertificates]; + }; + + if (offset < caTotalCount) { + const caLimit = Math.min(limit, caTotalCount - offset); + const caOffset = offset; + + let caQuery = (tx || db.replicaNode()) + .select( + `${TableName.CertificateAuthority}.id`, + `ica.serialNumber`, + `ica.commonName`, + `ica.notBefore`, + `ica.notAfter` + ) + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caQuery = applyCaFilters(caQuery, filters, projectId) as typeof caQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + caQuery = caQuery.orderBy(`ica.notAfter`, "asc").limit(caLimit).offset(caOffset); + + const cas = await caQuery; + const formattedCAs: TCertificatePreview[] = ( + cas as Array<{ + id: string; + serialNumber: string; + commonName: string; + notBefore: Date; + notAfter: Date; + }> + ).map((ca) => ({ + id: ca.id, + serialNumber: ca.serialNumber, + commonName: ca.commonName, + san: [], + profileName: null, + enrollmentType: CertificateOrigin.CA, + notBefore: ca.notBefore, + notAfter: ca.notAfter, + status: "active" + })); + + results = [...results, ...formattedCAs]; + + const remainingLimit = limit - caLimit; + if (remainingLimit > 0 && certTotalCount > 0) { + const certOffset = 0; + await fetchCertificates(remainingLimit, certOffset); + } + } else { + const certOffset = offset - caTotalCount; + await fetchCertificates(limit, certOffset); + } + + return { + certificates: results, + total: totalCount + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindMatchingCertificates" }); + } + }; + + return { + ...pkiAlertV2Orm, + create, + updateById, + findById, + findByIdWithChannels, + findByProjectId, + findByProjectIdWithCount, + countByProjectId, + getDistinctProjectIds, + findMatchingCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts new file mode 100644 index 000000000..cb7f9cf72 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts @@ -0,0 +1,358 @@ +import { Knex } from "knex"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { logger } from "@app/lib/logger"; + +import { PkiFilterField, PkiFilterOperator, TPkiFilterRule } from "./pki-alert-v2-types"; + +export const sanitizeLikeInput = (input: string): string => { + const allowedCharsRegex = new RE2("^[a-zA-Z0-9\\s\\-_\\.@\\*]+$"); + if (!allowedCharsRegex.test(input)) { + throw new Error( + "Invalid characters in input. Only alphanumeric characters, spaces, hyphens, underscores, dots, @ and * are allowed." + ); + } + + const backslashRegex = new RE2("\\\\", "g"); + const percentRegex = new RE2("%", "g"); + const underscoreRegex = new RE2("_", "g"); + const quoteRegex = new RE2("'", "g"); + + return input + .replace(backslashRegex, "\\\\\\\\") + .replace(percentRegex, "\\%") + .replace(underscoreRegex, "\\_") + .replace(quoteRegex, "''"); +}; + +export const parseTimeToPostgresInterval = (duration: string): string => { + if (duration.length > 32) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const durationRegex = new RE2("^(\\d+)([dwmy])$"); + const match = durationRegex.exec(duration); + + if (!match) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + throw new Error(`Duration value out of range: ${duration}. Must be between 1 and 9999.`); + } + + const unitMap = { + d: "days", + w: "weeks", + m: "months", + y: "years" + }; + + return `${amount} ${unitMap[unit as keyof typeof unitMap]}`; +}; + +export const parseTimeToDays = (timeStr: string): number => { + const alertBeforeRegex = new RE2("^(\\d+)([dwmy])$"); + const match = alertBeforeRegex.exec(timeStr); + if (!match) { + return 0; + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + return 0; + } + + switch (unit) { + case "d": + return amount; + case "w": + return amount * 7; + case "m": + return amount * 30; + case "y": + return amount * 365; + default: + return 0; + } +}; + +const applyProfileNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where("profile.slug", value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn("profile.slug", value); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike("profile.slug", `%${sanitizedValue}%`); + } else { + void builder.orWhereILike("profile.slug", `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike("profile.slug", `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for profile_name: ${String(filter.operator)}`); + return query; + } +}; + +const applyCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.commonName`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizedValue}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for common_name: ${String(filter.operator)}`); + return query; + } +}; + +const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.altNames`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.whereJsonSupersetOf(columnName, [value as string]); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = `%"${String(v)}"%`; + if (index === 0) { + void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } else { + void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + }); + }); + } + { + const sanitizedValue = `%"${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + + case PkiFilterOperator.CONTAINS: + return applySanFilter(query, { ...filter, operator: PkiFilterOperator.MATCHES }); + + case PkiFilterOperator.STARTS_WITH: { + const startsWithValue = `%"${String(value)}%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]); + } + + case PkiFilterOperator.ENDS_WITH: { + const endsWithValue = `%${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, endsWithValue]); + } + + default: + logger.warn(`Unsupported operator for SAN: ${String(filter.operator)}`); + return query; + } +}; + +export const shouldIncludeCAs = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.INCLUDE_CAS && filter.value === true); +}; + +const applyCaCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = "ica.commonName"; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for CA common_name: ${String(filter.operator)}`); + return query; + } +}; + +export const applyCaFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId).whereNotNull("ica.caId"); // Only include CAs that have internal CA data + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCaCommonNameFilter(filteredQuery, filter); + break; + + default: + break; + } + }); + + return filteredQuery; +}; + +export const validateFilterRules = (filters: TPkiFilterRule[]): void => { + for (const filter of filters) { + if (!Object.values(PkiFilterField).includes(filter.field)) { + throw new Error(`Invalid filter field: ${filter.field}`); + } + + if (!Object.values(PkiFilterOperator).includes(filter.operator)) { + throw new Error(`Invalid filter operator: ${filter.operator}`); + } + + switch (filter.field) { + case PkiFilterField.INCLUDE_CAS: + if (typeof filter.value !== "boolean") { + throw new Error("include_cas filter value must be boolean"); + } + break; + + case PkiFilterField.PROFILE_NAME: + case PkiFilterField.COMMON_NAME: + case PkiFilterField.SAN: + if (filter.operator === PkiFilterOperator.CONTAINS || filter.operator === PkiFilterOperator.MATCHES) { + if (!Array.isArray(filter.value) && typeof filter.value !== "string") { + throw new Error( + `${filter.field} filter value must be string or array of strings for ${filter.operator} operator` + ); + } + } else if (typeof filter.value !== "string") { + throw new Error(`${filter.field} filter value must be string for ${filter.operator} operator`); + } + break; + + default: + break; + } + } +}; + +export const requiresProfileJoin = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.PROFILE_NAME); +}; + +export const applyCertificateFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.Certificate}.projectId`, projectId); + + const needsProfileJoin = requiresProfileJoin(filters); + if (needsProfileJoin) { + filteredQuery = filteredQuery.leftJoin( + `${TableName.PkiCertificateProfile} as profile`, + `${TableName.Certificate}.profileId`, + "profile.id" + ); + } + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.PROFILE_NAME: + filteredQuery = applyProfileNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCommonNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.SAN: + filteredQuery = applySanFilter(filteredQuery, filter); + break; + + case PkiFilterField.INCLUDE_CAS: + break; + + default: + logger.warn(`Unknown filter field: ${String(filter.field)}`); + break; + } + }); + + return filteredQuery; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts new file mode 100644 index 000000000..81b4bc9ef --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts @@ -0,0 +1,220 @@ +/* eslint-disable no-await-in-loop */ + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { TPkiAlertV2ServiceFactory } from "./pki-alert-v2-service"; +import { CertificateOrigin, PkiAlertEventType, TPkiFilterRule } from "./pki-alert-v2-types"; + +type TPkiAlertV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiAlertV2Service: Pick; + pkiAlertV2DAL: Pick; + pkiAlertHistoryDAL: Pick; +}; + +export type TPkiAlertV2QueueServiceFactory = ReturnType; + +export const pkiAlertV2QueueServiceFactory = ({ + queueService, + pkiAlertV2Service, + pkiAlertV2DAL, + pkiAlertHistoryDAL +}: TPkiAlertV2QueueServiceFactoryDep) => { + const appCfg = getConfig(); + const calculateDeduplicationWindow = (alertBefore: string): number => { + const alertDays = parseTimeToDays(alertBefore); + + if (alertDays === 0) { + return 24; + } + + if (alertDays <= 1) { + return 8; + } + if (alertDays <= 7) { + return 24; + } + if (alertDays <= 30) { + return 48; + } + if (alertDays <= 90) { + return 168; + } + return 720; + }; + + const getAllProjectsWithAlerts = async (): Promise => { + try { + const projectIds = await pkiAlertV2DAL.getDistinctProjectIds({ enabled: true }); + + logger.info(`Found ${projectIds.length} projects with PKI alerts`); + return projectIds; + } catch (error) { + logger.error(error, "Failed to get projects with alerts"); + return []; + } + }; + + const evaluateAlert = async ( + alert: { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }, + projectId: string + ): Promise<{ shouldNotify: boolean; certificateIds: string[] }> => { + if (alert.eventType !== PkiAlertEventType.EXPIRATION) { + return { shouldNotify: false, certificateIds: [] }; + } + + try { + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, alert.filters, { + limit: 1000, + alertBefore: parseTimeToPostgresInterval(alert.alertBefore), + showCurrentMatches: true + }); + + if (result.certificates.length === 0) { + return { shouldNotify: false, certificateIds: [] }; + } + + const allCertificateIds = result.certificates + .filter((cert) => cert.enrollmentType !== CertificateOrigin.CA) + .map((cert) => cert.id); + + const deduplicationHours = calculateDeduplicationWindow(alert.alertBefore); + const recentlyAlertedIds = await pkiAlertHistoryDAL.findRecentlyAlertedCertificates( + alert.id, + allCertificateIds, + deduplicationHours + ); + + const certificateIds = allCertificateIds.filter((certId) => !recentlyAlertedIds.includes(certId)); + + if (certificateIds.length === 0) { + logger.debug( + `All ${allCertificateIds.length} matching certificates for alert ${alert.id} were already alerted within the last ${deduplicationHours} hours` + ); + return { shouldNotify: false, certificateIds: [] }; + } + + logger.debug( + `Alert ${alert.id}: Found ${allCertificateIds.length} expiring certificates, ${recentlyAlertedIds.length} already alerted recently, ${certificateIds.length} new to alert` + ); + + return { + shouldNotify: true, + certificateIds + }; + } catch (error) { + logger.error(error, `Failed to evaluate alert ${alert.id}`); + return { shouldNotify: false, certificateIds: [] }; + } + }; + + const processProjectAlerts = async ( + projectId: string + ): Promise<{ alertsProcessed: number; notificationsSent: number }> => { + logger.info(`Processing alerts for project: ${projectId}`); + + const alerts = await pkiAlertV2DAL.findByProjectId(projectId, { + enabled: true, + limit: 1000 + }); + + let alertsProcessed = 0; + let notificationsSent = 0; + + for (const alert of alerts) { + const typedAlert = alert as { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }; + try { + const { shouldNotify, certificateIds } = await evaluateAlert(typedAlert, projectId); + + if (shouldNotify && certificateIds.length > 0) { + await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds); + notificationsSent += 1; + logger.info( + `Sent notification for alert ${typedAlert.id} (${typedAlert.name}) with ${certificateIds.length} certificates` + ); + } + + alertsProcessed += 1; + } catch (error) { + logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`); + } + } + + logger.info( + `Completed processing ${alertsProcessed} alerts for project ${projectId}, sent ${notificationsSent} notifications` + ); + + return { alertsProcessed, notificationsSent }; + }; + + const processDailyAlerts = async () => { + logger.info("Starting daily PKI alert processing..."); + + const allProjects = await getAllProjectsWithAlerts(); + + let totalAlertsProcessed = 0; + let totalNotificationsSent = 0; + + for (const projectId of allProjects) { + try { + const { alertsProcessed, notificationsSent } = await processProjectAlerts(projectId); + totalAlertsProcessed += alertsProcessed; + totalNotificationsSent += notificationsSent; + } catch (error) { + logger.error(error, `Failed to process alerts for project ${projectId}`); + } + } + + logger.info( + `Daily PKI alert processing completed. Processed ${totalAlertsProcessed} alerts, sent ${totalNotificationsSent} notifications.` + ); + }; + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.startPg( + QueueJobs.DailyPkiAlertV2Processing, + async () => { + try { + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task started`); + await processDailyAlerts(); + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task completed successfully`); + } catch (error) { + logger.error(error, `${QueueJobs.DailyPkiAlertV2Processing}: queue task failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg(QueueJobs.DailyPkiAlertV2Processing, "0 0 * * *", undefined, { tz: "UTC" }); + }; + + return { + init + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts new file mode 100644 index 000000000..2deb18c62 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts @@ -0,0 +1,507 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TPkiAlertChannelDALFactory } from "./pki-alert-channel-dal"; +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { + CertificateOrigin, + PkiAlertChannelType, + PkiAlertEventType, + TAlertV2Response, + TChannelConfig, + TCreateAlertV2DTO, + TDeleteAlertV2DTO, + TEmailChannelConfig, + TGetAlertV2DTO, + TListAlertsV2DTO, + TListAlertsV2Response, + TListCurrentMatchingCertificatesDTO, + TListMatchingCertificatesDTO, + TListMatchingCertificatesResponse, + TPkiFilterRule, + TUpdateAlertV2DTO +} from "./pki-alert-v2-types"; + +type TPkiAlertV2ServiceFactoryDep = { + pkiAlertV2DAL: Pick< + TPkiAlertV2DALFactory, + | "create" + | "findById" + | "findByIdWithChannels" + | "updateById" + | "deleteById" + | "findByProjectId" + | "findByProjectIdWithCount" + | "countByProjectId" + | "findMatchingCertificates" + | "transaction" + >; + pkiAlertChannelDAL: Pick; + pkiAlertHistoryDAL: Pick; + permissionService: Pick; + smtpService: Pick; +}; + +export type TPkiAlertV2ServiceFactory = ReturnType; + +export const pkiAlertV2ServiceFactory = ({ + pkiAlertV2DAL, + pkiAlertChannelDAL, + pkiAlertHistoryDAL, + permissionService, + smtpService +}: TPkiAlertV2ServiceFactoryDep) => { + type TAlertWithChannels = { + id: string; + name: string; + description: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + enabled: boolean; + projectId: string; + createdAt: Date; + updatedAt: Date; + channels?: Array<{ + id: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + }; + + const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => { + return { + id: alert.id, + name: alert.name, + description: alert.description, + eventType: alert.eventType as PkiAlertEventType, + alertBefore: alert.alertBefore, + filters: alert.filters, + enabled: alert.enabled, + projectId: alert.projectId, + channels: (alert.channels || []).map((channel) => ({ + id: channel.id, + channelType: channel.channelType as PkiAlertChannelType, + config: channel.config as TChannelConfig, + enabled: channel.enabled, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt + })), + createdAt: alert.createdAt, + updatedAt: alert.updatedAt + }; + }; + + const createAlert = async ({ + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled = true, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateAlertV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + return pkiAlertV2DAL.transaction(async (tx) => { + const alert = await pkiAlertV2DAL.create( + { + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled + }, + tx + ); + + const channelInserts = channels.map((channel) => ({ + alertId: alert.id, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve created alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const getAlertById = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + return formatAlertResponse(alert as TAlertWithChannels); + }; + + const listAlerts = async ({ + projectId, + search, + eventType, + enabled, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListAlertsV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const filters = { search, eventType, enabled, limit, offset }; + + const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters); + + return { + alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)), + total + }; + }; + + const updateAlert = async ({ + alertId, + name, + description, + eventType, + alertBefore, + filters, + enabled, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAlertV2DTO): Promise => { + let alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); + + if (alertBefore) { + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + } + + const updateData: { + name?: string; + description?: string; + eventType?: PkiAlertEventType; + alertBefore?: string; + filters?: TPkiFilterRule[]; + enabled?: boolean; + } = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (eventType !== undefined) updateData.eventType = eventType; + if (alertBefore !== undefined) updateData.alertBefore = alertBefore; + if (filters !== undefined) updateData.filters = filters; + if (enabled !== undefined) updateData.enabled = enabled; + + return pkiAlertV2DAL.transaction(async (tx) => { + alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx); + + if (channels) { + await pkiAlertChannelDAL.deleteByAlertId(alertId, tx); + + const channelInserts = channels.map((channel) => ({ + alertId, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + } + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve updated alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const deleteAlert = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts); + + const formattedAlert = formatAlertResponse(alert as TAlertWithChannels); + await pkiAlertV2DAL.deleteById(alertId); + + return formattedAlert; + }; + + const listMatchingCertificates = async ({ + alertId, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListMatchingCertificatesDTO): Promise => { + const alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + } = { + limit, + offset, + showPreview: true, + excludeAlerted: (alert as { eventType: string }).eventType === PkiAlertEventType.EXPIRATION, + alertId + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + options + ); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const listCurrentMatchingCertificates = async ({ + projectId, + filters, + alertBefore, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListCurrentMatchingCertificatesDTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + alertBefore?: string; + } = { + limit, + offset, + showPreview: true, + alertBefore: parseTimeToPostgresInterval(alertBefore) + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, filters, options); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const sendAlertNotifications = async (alertId: string, certificateIds: string[]) => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert || !(alert as { enabled: boolean }).enabled) return; + + const channels = + (alert as { channels?: Array<{ enabled: boolean; channelType: string; config: unknown }> }).channels?.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => channel.enabled + ) || []; + if (channels.length === 0) return; + + const { certificates } = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + { + alertBefore: parseTimeToPostgresInterval((alert as { alertBefore: string }).alertBefore) + } + ); + + const matchingCertificates = certificates.filter( + (cert) => certificateIds.includes(cert.id) && cert.enrollmentType !== CertificateOrigin.CA + ); + + if (matchingCertificates.length === 0) return; + + let hasNotificationSent = false; + let notificationError: string | undefined; + + try { + const emailChannels = channels.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => + channel.channelType === PkiAlertChannelType.EMAIL + ); + + const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore); + const alertName = (alert as { name: string }).name; + + const emailPromises = emailChannels.map((channel) => { + const config = channel.config as TEmailChannelConfig; + + return smtpService.sendMail({ + recipients: config.recipients, + subjectLine: `Infisical Certificate Alert - ${alertName}`, + substitutions: { + alertName, + alertBeforeDays, + projectId: (alert as { projectId: string }).projectId, + items: matchingCertificates.map((cert) => ({ + type: "Certificate", + friendlyName: cert.commonName, + serialNumber: cert.serialNumber, + expiryDate: cert.notAfter.toLocaleDateString() + })) + }, + template: SmtpTemplates.PkiExpirationAlert + }); + }); + + await Promise.all(emailPromises); + + hasNotificationSent = true; + } catch (error) { + notificationError = error instanceof Error ? error.message : "Unknown error occurred"; + logger.error(error, `Failed to send notifications for alert ${alertId}`); + } + + await pkiAlertHistoryDAL.createWithCertificates(alertId, certificateIds, { + hasNotificationSent, + notificationError + }); + }; + + return { + createAlert, + getAlertById, + listAlerts, + updateAlert, + deleteAlert, + listMatchingCertificates, + listCurrentMatchingCertificates, + sendAlertNotifications + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts new file mode 100644 index 000000000..fe6055402 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts @@ -0,0 +1,205 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { TGenericPermission } from "@app/lib/types"; + +const createSecureNameValidator = () => { + // Validates name format: lowercase alphanumeric characters with optional hyphens + // Pattern: starts and ends with alphanumeric, allows hyphens between segments + // Examples: "my-alert", "alert1", "test-alert-2" + const nameRegex = new RE2("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + return (value: string) => nameRegex.test(value); +}; + +export const createSecureAlertBeforeValidator = () => { + // Validates alertBefore duration format: number followed by time unit + // Pattern: one or more digits followed by d(days), w(weeks), m(months), or y(years) + // Examples: "30d", "2w", "6m", "1y" + const alertBeforeRegex = new RE2("^\\d+[dwmy]$"); + return (value: string) => { + if (value.length > 32) return false; + return alertBeforeRegex.test(value); + }; +}; + +export enum PkiAlertEventType { + EXPIRATION = "expiration", + RENEWAL = "renewal", + ISSUANCE = "issuance", + REVOCATION = "revocation" +} + +export enum PkiAlertChannelType { + EMAIL = "email", + WEBHOOK = "webhook", + SLACK = "slack" +} + +export enum PkiFilterOperator { + EQUALS = "equals", + MATCHES = "matches", + CONTAINS = "contains", + STARTS_WITH = "starts_with", + ENDS_WITH = "ends_with" +} + +export enum PkiFilterField { + PROFILE_NAME = "profile_name", + COMMON_NAME = "common_name", + SAN = "san", + INCLUDE_CAS = "include_cas" +} + +export enum CertificateOrigin { + UNKNOWN = "unknown", + PROFILE = "profile", + IMPORT = "import", + CA = "ca" +} + +export const PkiFilterRuleSchema = z.object({ + field: z.nativeEnum(PkiFilterField), + operator: z.nativeEnum(PkiFilterOperator), + value: z.union([z.string(), z.array(z.string()), z.boolean()]) +}); + +export type TPkiFilterRule = z.infer; + +export const PkiFiltersSchema = z.array(PkiFilterRuleSchema); +export type TPkiFilters = z.infer; + +export const EmailChannelConfigSchema = z.object({ + recipients: z.array(z.string().email()).min(1).max(10) +}); + +export const WebhookChannelConfigSchema = z.object({ + url: z.string().url(), + method: z.enum(["POST", "PUT"]).default("POST"), + headers: z.record(z.string()).optional() +}); + +export const SlackChannelConfigSchema = z.object({ + webhookUrl: z.string().url(), + channel: z.string().optional(), + mentionUsers: z.array(z.string()).optional() +}); + +export const ChannelConfigSchema = z.union([ + EmailChannelConfigSchema, + WebhookChannelConfigSchema, + SlackChannelConfigSchema +]); + +export type TEmailChannelConfig = z.infer; +export type TWebhookChannelConfig = z.infer; +export type TSlackChannelConfig = z.infer; +export type TChannelConfig = z.infer; + +export const CreateChannelSchema = z.object({ + channelType: z.nativeEnum(PkiAlertChannelType), + config: ChannelConfigSchema, + enabled: z.boolean().default(true) +}); + +export type TCreateChannel = z.infer; + +export const CreatePkiAlertV2Schema = z.object({ + name: z + .string() + .min(1) + .max(255) + .refine(createSecureNameValidator(), "Must be a valid name (lowercase, numbers, hyphens only)"), + description: z.string().max(1000).optional(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string().refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'"), + filters: PkiFiltersSchema, + enabled: z.boolean().default(true), + channels: z.array(CreateChannelSchema).min(1, "At least one channel is required") +}); + +export type TCreatePkiAlertV2 = z.infer; + +export const UpdatePkiAlertV2Schema = CreatePkiAlertV2Schema.partial(); +export type TUpdatePkiAlertV2 = z.infer; + +export type TCreateAlertV2DTO = TGenericPermission & { + projectId: string; +} & TCreatePkiAlertV2; + +export type TUpdateAlertV2DTO = TGenericPermission & { + alertId: string; +} & TUpdatePkiAlertV2; + +export type TGetAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TDeleteAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TListAlertsV2DTO = TGenericPermission & { + projectId: string; + search?: string; + eventType?: PkiAlertEventType; + enabled?: boolean; + limit?: number; + offset?: number; +}; + +export type TListMatchingCertificatesDTO = TGenericPermission & { + alertId: string; + limit?: number; + offset?: number; +}; + +export type TListCurrentMatchingCertificatesDTO = TGenericPermission & { + projectId: string; + filters: TPkiFilters; + alertBefore: string; + limit?: number; + offset?: number; +}; + +export type TCertificatePreview = { + id: string; + serialNumber: string; + commonName: string; + san: string[]; + profileName: string | null; + enrollmentType: CertificateOrigin | null; + notBefore: Date; + notAfter: Date; + status: string; +}; + +export type TAlertV2Response = { + id: string; + name: string; + description: string | null; + eventType: PkiAlertEventType; + alertBefore: string; + filters: TPkiFilters; + enabled: boolean; + projectId: string; + channels: Array<{ + id: string; + channelType: PkiAlertChannelType; + config: TChannelConfig; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + createdAt: Date; + updatedAt: Date; +}; + +export type TListAlertsV2Response = { + alerts: TAlertV2Response[]; + total: number; +}; + +export type TListMatchingCertificatesResponse = { + certificates: TCertificatePreview[]; + total: number; +}; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts index 3e07420b5..af210c0c5 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop */ -import * as AWS from "aws-sdk"; +import AWS from "aws-sdk"; import RE2 from "re2"; import { z } from "zod"; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts index 8b2b8b87e..e86ecaab9 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -1,4 +1,4 @@ -import * as AWS from "aws-sdk"; +import AWS from "aws-sdk"; import { z } from "zod"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 25052ba9a..188c985fb 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -252,7 +252,8 @@ export const projectServiceFactory = ({ tx: trx, createDefaultEnvs = true, template = InfisicalProjectTemplate.Default, - type = ProjectType.SecretManager + type = ProjectType.SecretManager, + hasDeleteProtection }: TCreateProjectDTO) => { const organization = await orgDAL.findOne({ id: actorOrgId }); const { permission } = await permissionService.getOrgPermission({ @@ -325,7 +326,8 @@ export const projectServiceFactory = ({ slug, kmsSecretManagerKeyId: kmsKeyId, version: ProjectVersion.V3, - pitVersionLimit: 10 + pitVersionLimit: 10, + hasDeleteProtection }, tx ); @@ -587,6 +589,7 @@ export const projectServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); + return project; }; @@ -1605,8 +1608,14 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels - }: TUpdateProjectWorkflowIntegration) => { + secretRequestChannels, + secretSyncErrorChannels, + isSecretSyncErrorNotificationEnabled + }: TUpdateProjectWorkflowIntegration & { + // workaround intersection type while we don't have the microsoft teams integration for failed secret syncs + isSecretSyncErrorNotificationEnabled?: boolean; + secretSyncErrorChannels?: string; + }) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1628,6 +1637,7 @@ export const projectServiceFactory = ({ const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); + const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels); const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); @@ -1665,7 +1675,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1678,7 +1690,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1688,6 +1702,7 @@ export const projectServiceFactory = ({ ...updatedWorkflowIntegration, accessRequestChannels: sanitizedAccessRequestChannels, secretRequestChannels: sanitizedSecretRequestChannels, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels, integrationId: slackIntegration.id, integration: WorkflowIntegration.SLACK } as const; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 2b75b1bc7..2d27c7c44 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -51,6 +51,7 @@ export type TCreateProjectDTO = { pitVersionLimit?: number; tx?: Knex; type?: ProjectType; + hasDeleteProtection?: boolean; }; export type TDeleteProjectBySlugDTO = { @@ -185,8 +186,10 @@ export type TUpdateProjectWorkflowIntegration = ( integration: WorkflowIntegration.SLACK; isAccessRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean; + isSecretSyncErrorNotificationEnabled: boolean; accessRequestChannels?: string; secretRequestChannels?: string; + secretSyncErrorChannels?: string; } | { integrationId: string; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index e83a0033f..f6e23dded 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -10,6 +10,8 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { SecretNameSchema } from "@app/server/lib/schemas"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; @@ -62,8 +64,11 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; import { TNotificationServiceFactory } from "../notification/notification-service"; import { NotificationType } from "../notification/notification-types"; +import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; export type TSecretSyncQueueFactory = ReturnType; @@ -104,6 +109,9 @@ type TSecretSyncQueueFactoryDep = { gatewayService: Pick; gatewayV2Service: Pick; notificationService: Pick; + projectSlackConfigDAL: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + microsoftTeamsService: Pick; }; type SecretSyncActionJob = Job< @@ -147,7 +155,10 @@ export const secretSyncQueueFactory = ({ licenseService, gatewayService, gatewayV2Service, - notificationService + notificationService, + projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -921,34 +932,65 @@ export const secretSyncQueueFactory = ({ break; } - const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`; + const baseProjectPath = `/projects/secret-management/${projectId}`; + const overviewPath = `${baseProjectPath}/overview`; + const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`; - await notificationService.createUserNotifications( - projectAdmins.map((admin) => ({ - userId: admin.userId, - orgId: project.orgId, - type: NotificationType.SECRET_SYNC_FAILED, - title: `Secret Sync Failed to ${actionLabel} Secrets`, - body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`, - link: syncPath - })) - ); + const notifications = [ + triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.SECRET_SYNC_ERROR, + payload: { + syncName: name, + syncDestination, + failureMessage: failureMessage || "An unknown error occurred", + syncUrl: `${appCfg.SITE_URL}${syncPath}`, + syncActionLabel: actionLabel, + environment: environment?.name || "-", + secretPath: folder?.path || "-", + projectName: project.name, + projectPath: overviewPath + } + }, + projectId + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL + } + }), + notificationService.createUserNotifications( + projectAdmins.map((admin) => ({ + userId: admin.userId, + orgId: project.orgId, + type: NotificationType.SECRET_SYNC_FAILED, + title: `Secret Sync Failed to ${actionLabel} Secrets`, + body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`, + link: syncPath + })) + ), + smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.SecretSyncFailed, + subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`, + substitutions: { + syncName: name, + syncDestination, + content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, + failureMessage, + secretPath: folder?.path, + environment: environment?.name, + projectName: project.name, + syncUrl: `${appCfg.SITE_URL}${syncPath}` + } + }) + ]; - await smtpService.sendMail({ - recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), - template: SmtpTemplates.SecretSyncFailed, - subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`, - substitutions: { - syncName: name, - syncDestination, - content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, - failureMessage, - secretPath: folder?.path, - environment: environment?.name, - projectName: project.name, - syncUrl: `${appCfg.SITE_URL}${syncPath}` - } - }); + await Promise.allSettled(notifications); }; const queueSecretSyncsSyncSecretsByPath = async ({ diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 6b284ddee..01a7f6210 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -793,6 +793,7 @@ export const reshapeBridgeSecret = ( userActorId?: string | null; identityActorId?: string | null; membershipId?: string | null; + groupId?: string | null; actorType?: string | null; tags?: { id: string; @@ -823,7 +824,8 @@ export const reshapeBridgeSecret = ( actorType: secret.actorType, actorId: secret.userActorId || secret.identityActorId, name: secret.identityActorName || secret.userActorName, - membershipId: secret.membershipId + membershipId: secret.membershipId, + groupId: secret.groupId } : undefined, tags: secret.tags, diff --git a/backend/src/services/secret-v2-bridge/secret-version-dal.ts b/backend/src/services/secret-v2-bridge/secret-version-dal.ts index 7d25dac86..a7f0eb565 100644 --- a/backend/src/services/secret-v2-bridge/secret-version-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-version-dal.ts @@ -182,7 +182,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { const findVersionsBySecretIdWithActors = async ({ secretId, - projectId, secretVersions, findOpt = {}, tx @@ -196,13 +195,22 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { try { const { offset, limit, sort = [["createdAt", "desc"]] } = findOpt; const query = (tx || db.replicaNode())(TableName.SecretVersionV2) + .leftJoin(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersionV2}.folderId`) + .leftJoin(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.SecretVersionV2}.userActorId`) + .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) + .leftJoin(TableName.UserGroupMembership, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.Membership, (qb) => { void qb - .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) - .andOn(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])); + .on(`${TableName.Membership}.scope`, db.raw("?", [AccessScope.Project])) + .andOn(`${TableName.Membership}.scopeProjectId`, `${TableName.Environment}.projectId`) + .andOn((sqb) => { + void sqb + .on(`${TableName.Membership}.actorUserId`, `${TableName.SecretVersionV2}.userActorId`) + .orOn(`${TableName.Membership}.actorIdentityId`, `${TableName.SecretVersionV2}.identityActorId`) + .orOn(`${TableName.Membership}.actorGroupId`, `${TableName.UserGroupMembership}.groupId`); + }); }) - .leftJoin(TableName.Identity, `${TableName.Identity}.id`, `${TableName.SecretVersionV2}.identityActorId`) .leftJoin(TableName.SecretV2, `${TableName.SecretVersionV2}.secretId`, `${TableName.SecretV2}.id`) .leftJoin( TableName.SecretVersionV2Tag, @@ -216,12 +224,6 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { ) .where((qb) => { void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); - void qb.where(`${TableName.Membership}.scopeProjectId`, projectId); - if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); - }) - .orWhere((qb) => { - void qb.where(`${TableName.SecretVersionV2}.secretId`, secretId); - void qb.whereNull(`${TableName.Membership}.scopeProjectId`); if (secretVersions?.length) void qb.whereIn(`${TableName.SecretVersionV2}.version`, secretVersions); }) .select( @@ -229,6 +231,7 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { db.ref("username").withSchema(TableName.Users).as("userActorName"), db.ref("name").withSchema(TableName.Identity).as("identityActorName"), db.ref("id").withSchema(TableName.Membership).as("membershipId"), + db.ref("actorGroupId").withSchema(TableName.Membership).as("groupId"), db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") @@ -256,7 +259,8 @@ export const secretVersionV2BridgeDALFactory = (db: TDbClient) => { ...SecretVersionsV2Schema.parse(el), userActorName: el.userActorName, identityActorName: el.identityActorName, - membershipId: el.membershipId + membershipId: el.membershipId, + groupId: el.groupId }), childrenMapper: [ { diff --git a/backend/src/services/slack/project-slack-config-dal.ts b/backend/src/services/slack/project-slack-config-dal.ts index 276442b1b..4b5b2146e 100644 --- a/backend/src/services/slack/project-slack-config-dal.ts +++ b/backend/src/services/slack/project-slack-config-dal.ts @@ -1,16 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TProjectSlackConfigs, TSlackIntegrations } from "@app/db/schemas"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectSlackConfigDALFactory = ReturnType; +export type TProjectSlackConfigWithIntegrations = TProjectSlackConfigs & TSlackIntegrations; export const projectSlackConfigDALFactory = (db: TDbClient) => { const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs); const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { - return (tx || db.replicaNode())(TableName.ProjectSlackConfigs) + return (tx || db.replicaNode())(TableName.ProjectSlackConfigs) .join( TableName.SlackIntegrations, `${TableName.ProjectSlackConfigs}.slackIntegrationId`, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 88db1a84d..aaeb28916 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -8,6 +8,9 @@ import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/ty import { KmsDataKey } from "../kms/kms-types"; import { TSendSlackNotificationDTO } from "./slack-types"; +const COMPANY_BRAND_COLOR = "#e0ed34"; +const ERROR_COLOR = "#e74c3c"; + export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { name: string; @@ -48,13 +51,9 @@ const buildSlackPayload = (notification: TNotification) => { const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} *Secret path*: ${payload.secretPath || "/"} -*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")} +*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}`; -View the complete details <${appCfg.SITE_URL}/projects/secret-management/${payload.projectId}/approval?requestId=${ - payload.requestId - }|here>.`; - - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -62,37 +61,52 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo text: "Secret approval request", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", text: messageBody } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] } ]; return { + headerBlocks, payloadMessage: messageBody, - payloadBlocks + payloadBlocks, + color: COMPANY_BRAND_COLOR }; } case TriggerFeature.ACCESS_REQUEST: { const { payload } = notification; - const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ - payload.isTemporary ? "temporary" : "permanent" - } access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - -The following permissions are requested: ${payload.permissions.join(", ")} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`; + const accessType = payload.isTemporary ? "temporary" : "permanent"; + const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", "); -View the request and approve or deny it <${payload.approvalUrl}|here>.${ - payload.note - ? ` -User Note: ${payload.note}` - : "" + const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${accessType} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${ + payload.note ? `\n\nUser note: ${payload.note}` : "" }`; - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -100,37 +114,54 @@ User Note: ${payload.note}` text: "New access approval request pending for review", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: messageBody + text: `*${payload.requesterFullName}* (${payload.requesterEmail}) has requested *${accessType}* access to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${ + payload.note ? `\n\n*User note:* ${payload.note}` : "" + }` } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] } ]; return { + headerBlocks, payloadMessage: messageBody, - payloadBlocks + payloadBlocks, + color: COMPANY_BRAND_COLOR }; } case TriggerFeature.ACCESS_REQUEST_UPDATED: { const { payload } = notification; - const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ - payload.isTemporary ? "temporary" : "permanent" - } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - -The following permissions are requested: ${payload.permissions.join(", ")} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`; + const accessType = payload.isTemporary ? "temporary" : "permanent"; + const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", "); -View the request and approve or deny it <${payload.approvalUrl}|here>.${ - payload.editNote - ? ` -Editor Note: ${payload.editNote}` - : "" + const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${accessType} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${ + payload.editNote ? `\n\nEditor Note: ${payload.editNote}` : "" }`; - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -138,19 +169,89 @@ Editor Note: ${payload.editNote}` text: "Updated access approval request pending for review", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: messageBody + text: `*${payload.editorFullName}* (${payload.editorEmail}) has updated the *${accessType}* access request from *${payload.requesterFullName}* (${payload.requesterEmail}) to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${ + payload.editNote ? `\n\n*Editor Note:* ${payload.editNote}` : "" + }` } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] + } + ]; + + return { + headerBlocks, + payloadMessage: messageBody, + payloadBlocks, + color: COMPANY_BRAND_COLOR + }; + } + case TriggerFeature.SECRET_SYNC_ERROR: { + const { payload } = notification; + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`; + const messageBody = `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}\n\n\nEnvironment: ${payload.environment}\n\n\nSecret Path: ${payload.secretPath}\n\n\nProject: ${payload.projectName} (${projectUrl})\n\n\nReason:\n${payload.failureMessage}`; + + const headerBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`, + emoji: true + } + } + ]; + + const payloadBlocks = [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Environment:* ${payload.environment}\n\n*Secret Path:* ${payload.secretPath}\n\n*Project:* <${projectUrl}|${payload.projectName}>\n\n*Reason:* ${payload.failureMessage}` + } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Open secret sync", + emoji: true + }, + style: "primary", + url: payload.syncUrl + } + ] } ]; return { payloadMessage: messageBody, - payloadBlocks + headerBlocks, + payloadBlocks, + color: ERROR_COLOR }; } default: { @@ -177,15 +278,22 @@ export const sendSlackNotification = async ({ }).toString("utf8"); const slackWebClient = new WebClient(botKey); - const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); + const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification); for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients + await slackWebClient.chat .postMessage({ channel: conversationId, text: payloadMessage, - blocks: payloadBlocks + blocks: headerBlocks, + attachments: [ + { + color, + blocks: payloadBlocks + } + ] }) .catch((err) => logger.error(err)); } diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx index a09a125a2..f7a89f4e1 100644 --- a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -1,11 +1,12 @@ -import { Heading, Hr, Section, Text } from "@react-email/components"; -import React, { Fragment } from "react"; +import { Heading, Section, Text } from "@react-email/components"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface PkiExpirationAlertTemplateProps extends Omit { alertName: string; alertBeforeDays: number; + projectId: string; items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[]; } @@ -13,44 +14,56 @@ export const PkiExpirationAlertTemplate = ({ alertName, siteUrl, alertBeforeDays, + projectId, items }: PkiExpirationAlertTemplateProps) => { + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + } catch { + return dateStr; + } + }; + + const certificateText = items.length === 1 ? "certificate" : "certificates"; + const daysText = alertBeforeDays === 1 ? "1 day" : `${alertBeforeDays} days`; + + const message = `Alert ${alertName}: You have ${items.length === 1 ? "one" : items.length} ${certificateText} that will expire in ${daysText}.`; + return ( - + - CA/Certificate Expiration Notice + Certificate Expiration Notice -
- Hello, - - This is an automated alert for {alertName} triggered for CAs/Certificates expiring in{" "} - {alertBeforeDays} days. - - - Expiring Items: + +
+ + Alert {alertName}: You have{" "} + {items.length === 1 ? "one" : items.length} {certificateText} that will expire in {daysText}. +
+
+ Expiring certificates: {items.map((item) => ( - -
- {item.type}: - {item.friendlyName} - Serial Number: - {item.serialNumber} - Expires On: - {item.expiryDate} -
+
+ {item.friendlyName} + Serial: {item.serialNumber} + Expires: {formatDate(item.expiryDate)} +
))} -
- - Please take the necessary actions to renew these items before they expire. - - - For more details, please log in to your Infisical account and check your PKI management section. - +
+ +
+ + View Certificate Alerts +
); @@ -59,11 +72,22 @@ export const PkiExpirationAlertTemplate = ({ export default PkiExpirationAlertTemplate; PkiExpirationAlertTemplate.PreviewProps = { - alertBeforeDays: 5, + alertBeforeDays: 7, items: [ - { type: "CA", friendlyName: "Example CA", serialNumber: "1234567890", expiryDate: "2032-01-01" }, - { type: "Certificate", friendlyName: "Example Certificate", serialNumber: "2345678901", expiryDate: "2032-01-01" } + { + type: "Certificate", + friendlyName: "api.production.company.com", + serialNumber: "4B:3E:2F:A1:D6:7C:89:45:B2:E8:7F:1A:3D:9C:5E:8B", + expiryDate: "2025-11-12" + }, + { + type: "Certificate", + friendlyName: "web.company.com", + serialNumber: "8A:7F:1C:E4:92:B5:D3:68:F1:A2:7E:9B:4C:6D:5A:3F", + expiryDate: "2025-11-10" + } ], - alertName: "My PKI Alert", + alertName: "Production SSL Certificate Expiration Alert", + projectId: "c3b0ef29-915b-4cb1-8684-65b91b7fe02d", siteUrl: "https://infisical.com" } as PkiExpirationAlertTemplateProps; diff --git a/docker-compose.bdd.yml b/docker-compose.bdd.yml new file mode 100644 index 000000000..b73683867 --- /dev/null +++ b/docker-compose.bdd.yml @@ -0,0 +1,103 @@ +version: "3.9" + +services: + nginx: + container_name: infisical-bdd-nginx + image: nginx + restart: "always" + ports: + - 8080:80 + - 8443:443 + volumes: + - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + + db: + image: postgres:14-alpine + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: infisical + POSTGRES_USER: infisical + POSTGRES_DB: infisical + + redis: + image: redis + container_name: infisical-bdd-redis + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - 6379:6379 + volumes: + - redis_data:/data + + + backend: + container_name: infisical-bdd-api + build: + context: ./backend + dockerfile: Dockerfile.dev + depends_on: + db: + condition: service_started + redis: + condition: service_started + env_file: + - .env + ports: + - 4000:4000 + - 9464:9464 # for OTEL collection of Prometheus metrics + environment: + - NODE_ENV=development + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - TELEMETRY_ENABLED=false + # This is needed to trust the Pebble CA certificate, which is used for the BDD tests + - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/pebble.minica.crt + volumes: + - ./backend/src:/app/src + # This is needed to trust the Pebble CA certificate, which is used for the BDD tests + - ./backend/bdd/pebble/pebble.minica.pem:/usr/local/share/ca-certificates/pebble.minica.crt:ro + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts + extra_hosts: + - "host.docker.internal:host-gateway" + + # TODO: not really needed, but it seems like nginx needs it to be present + frontend: + container_name: infisical-bdd-frontend + restart: unless-stopped + depends_on: + - backend + build: + context: ./frontend + dockerfile: Dockerfile.dev + volumes: + - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files + - ./frontend/public:/app/public + env_file: .env + + # ACME server for BDD tests + pebble: + image: ghcr.io/letsencrypt/pebble:2.8.0 + command: -config /var/data/pebble/pebble-config.json + ports: + - 14000:14000 # ACME port + - 15000:15000 # Management port + environment: + # Do not perform validation sleep to make the BDD tests faster + - PEBBLE_VA_NOSLEEP=1 + # Skip validation for now to make the BDD tests easier to write + - PEBBLE_VA_ALWAYS_VALID=1 + volumes: + - ./backend/bdd/pebble/:/var/data/pebble:ro + +volumes: + postgres-data: + driver: local + redis_data: + driver: local + softhsm_tokens: + driver: local \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f12d446ef..5c3661cb7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -26,8 +26,6 @@ services: restart: always environment: - ALLOW_EMPTY_PASSWORD=yes - ports: - - 6379:6379 networks: - infisical volumes: diff --git a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx deleted file mode 100644 index 045cada58..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue certificate" -openapi: "POST /api/v1/pki/ca/{caId}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx deleted file mode 100644 index 95c8d8c65..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign certificate" -openapi: "POST /api/v1/pki/ca/{caId}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx b/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx new file mode 100644 index 000000000..aa033418d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Latest Active Certificate Bundle" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates/latest-active-bundle" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/issue-certificate.mdx b/docs/api-reference/endpoints/certificates/issue-certificate.mdx index 90a79a4af..13a464b67 100644 --- a/docs/api-reference/endpoints/certificates/issue-certificate.mdx +++ b/docs/api-reference/endpoints/certificates/issue-certificate.mdx @@ -1,4 +1,4 @@ --- title: "Issue Certificate" -openapi: "POST /api/v1/pki/certificates/issue-certificate" +openapi: "POST /api/v3/pki/certificates/issue-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/renew.mdx b/docs/api-reference/endpoints/certificates/renew.mdx new file mode 100644 index 000000000..b44424369 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/renew.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew Certificate" +openapi: "POST /api/v3/pki/certificates/{certificateId}/renew" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/sign-certificate.mdx b/docs/api-reference/endpoints/certificates/sign-certificate.mdx index 3132d5846..7291025fc 100644 --- a/docs/api-reference/endpoints/certificates/sign-certificate.mdx +++ b/docs/api-reference/endpoints/certificates/sign-certificate.mdx @@ -1,4 +1,4 @@ --- title: "Sign Certificate" -openapi: "POST /api/v1/pki/certificates/sign-certificate" +openapi: "POST /api/v3/pki/certificates/sign-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/update-config.mdx b/docs/api-reference/endpoints/certificates/update-config.mdx new file mode 100644 index 000000000..70520bf68 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/update-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Certificate Config" +openapi: "PATCH /api/v3/pki/certificates/{certificateId}/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/add-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership.mdx diff --git a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership.mdx diff --git a/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx new file mode 100644 index 000000000..801b0d422 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v1/projects/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships.mdx diff --git a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/update-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership.mdx diff --git a/docs/api-reference/endpoints/pki-alerts/create.mdx b/docs/api-reference/endpoints/pki-alerts/create.mdx index 458f0cd48..d4be026a7 100644 --- a/docs/api-reference/endpoints/pki-alerts/create.mdx +++ b/docs/api-reference/endpoints/pki-alerts/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/pki/alerts" +openapi: "POST /api/v2/pki/alerts" --- diff --git a/docs/api-reference/endpoints/pki-alerts/delete.mdx b/docs/api-reference/endpoints/pki-alerts/delete.mdx index c0918d1fe..67429049c 100644 --- a/docs/api-reference/endpoints/pki-alerts/delete.mdx +++ b/docs/api-reference/endpoints/pki-alerts/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/pki/alerts/{alertId}" +openapi: "DELETE /api/v2/pki/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki-alerts/read.mdx b/docs/api-reference/endpoints/pki-alerts/read.mdx index 928afdbc5..0e0547288 100644 --- a/docs/api-reference/endpoints/pki-alerts/read.mdx +++ b/docs/api-reference/endpoints/pki-alerts/read.mdx @@ -1,4 +1,4 @@ --- title: "Retrieve" -openapi: "GET /api/v1/pki/alerts/{alertId}" +openapi: "GET /api/v2/pki/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki-alerts/update.mdx b/docs/api-reference/endpoints/pki-alerts/update.mdx index 829f8c57b..45f1f1f1f 100644 --- a/docs/api-reference/endpoints/pki-alerts/update.mdx +++ b/docs/api-reference/endpoints/pki-alerts/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/pki/alerts/{alertId}" +openapi: "PATCH /api/v2/pki/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx new file mode 100644 index 000000000..c7b21996e --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Certificates to Sync" +openapi: "POST /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx new file mode 100644 index 000000000..eaece0a2d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Sync Certificates" +openapi: "GET /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx new file mode 100644 index 000000000..99c8bfe28 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from Sync" +openapi: "DELETE /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx new file mode 100644 index 000000000..004588713 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity Membership" +openapi: "POST /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx new file mode 100644 index 000000000..c170dfecf --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity Membership" +openapi: "DELETE /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx b/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx new file mode 100644 index 000000000..69e0317d1 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx new file mode 100644 index 000000000..647f9f032 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identity Memberships" +openapi: "GET /api/v1/projects/{projectId}/memberships/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx new file mode 100644 index 000000000..3e8ddbf37 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity Membership" +openapi: "PATCH /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/add-identity.mdx b/docs/api-reference/endpoints/project-identities/add-identity.mdx new file mode 100644 index 000000000..a00d12c97 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/add-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity" +openapi: "POST /api/v1/projects/{projectId}/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities/delete-identity.mdx b/docs/api-reference/endpoints/project-identities/delete-identity.mdx new file mode 100644 index 000000000..3c4ebfa04 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/delete-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity" +openapi: "DELETE /api/v1/projects/{projectId}/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/project-identities/get-by-id.mdx index 801b0d422..bd9d0dd6f 100644 --- a/docs/api-reference/endpoints/project-identities/get-by-id.mdx +++ b/docs/api-reference/endpoints/project-identities/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get Identity by ID" -openapi: "GET /api/v1/projects/{projectId}/identity-memberships/{identityId}" +openapi: "GET /api/v1/projects/{projectId}/identities/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/list-identity.mdx b/docs/api-reference/endpoints/project-identities/list-identity.mdx new file mode 100644 index 000000000..3070b39e7 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/list-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identities" +openapi: "GET /api/v1/projects/{projectId}/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities/update-identity.mdx b/docs/api-reference/endpoints/project-identities/update-identity.mdx new file mode 100644 index 000000000..683642a12 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/update-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity" +openapi: "PATCH /api/v1/projects/{projectId}/identities/{identityId}" +--- diff --git a/docs/api-reference/overview/usage.mdx b/docs/api-reference/overview/usage.mdx deleted file mode 100644 index 9f23080c7..000000000 --- a/docs/api-reference/overview/usage.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Usage" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) or your self-hosted instance. -- Obtain an API Key in your user settings to be included in requests to the Infisical API. - -Using Infisical's API to manage secrets requires a basic understanding of the system and its underlying cryptography detailed [here](/security/overview). - -## Concepts - -- Each user has a public/private key pair that is stored with the platform; private keys are encrypted locally by the user's password before being sent off to the server during the account signup process. -- Each (encrypted) secret belongs to a project and environment. -- Each project has an (encrypted) project key used to encrypt the secrets within that project; Infisical stores copies of the project key, for each member of that project, encrypted under each member's public key. -- Secrets are encrypted symmetrically by your copy of the project key belonging to the project containing. -- Infisical uses AES256-GCM and [TweetNaCl.js](https://tweetnacl.js.org/#/) for symmetric and asymmetric encryption/decryption operations. diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 59202e46e..32612938a 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -6,12 +6,12 @@ description: "Run the Infisical gateway or manage its systemd service" ```bash - infisical gateway start --name= --relay= --auth-method= + sudo infisical gateway start --name= --auth-method= ``` ```bash - sudo infisical gateway systemd install --token= --domain= --name= --relay= + sudo infisical gateway systemd install --token= --domain= --name= ``` @@ -33,22 +33,27 @@ If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. G - Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources within your network. + Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to a relay server and provides secure access to private resources within your network. ```bash -infisical gateway start --relay= --name= --auth-method= +sudo infisical gateway start --name= --auth-method= ``` + + By default, the gateway automatically connects to the relay with the lowest latency. To target a specific relay, use the `--relay=` flag. + + Once started, the gateway component will: -- Establish outbound SSH reverse tunnels to relay servers (no inbound firewall rules needed) +- Automatically connect to a healthy relay with the lowest latency (unless the `--relay` flag is specified) +- Establish outbound SSH reverse tunnel to relay server (no inbound firewall rules needed) - Authenticate using SSH certificates issued by Infisical - Automatically reconnect if the connection is lost - Provide access to private resources within your network ### Authentication -The Relay supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Gateway supports multiple authentication methods. Below are the available authentication methods, with their respective flags. @@ -69,7 +74,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=universal-auth --client-id= --client-secret= --relay= --name= + sudo infisical gateway start --auth-method=universal-auth --client-id= --client-secret= --name= ``` @@ -93,7 +98,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=kubernetes --machine-identity-id= --relay= --name= + sudo infisical gateway start --auth-method=kubernetes --machine-identity-id= --name= ``` @@ -114,7 +119,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=azure --machine-identity-id= --relay= --name= + sudo infisical gateway start --auth-method=azure --machine-identity-id= --name= ``` @@ -135,7 +140,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=gcp-id-token --machine-identity-id= --relay= --name= + sudo infisical gateway start --auth-method=gcp-id-token --machine-identity-id= --name= ``` @@ -157,7 +162,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --relay= --name= + sudo infisical gateway start --auth-method=gcp-iam --machine-identity-id= --service-account-key-file-path= --name= ``` @@ -176,7 +181,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=aws-iam --machine-identity-id= --relay= --name= + sudo infisical gateway start --auth-method=aws-iam --machine-identity-id= --name= ``` @@ -198,7 +203,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=oidc-auth --machine-identity-id= --jwt= --relay= --name= + sudo infisical gateway start --auth-method=oidc-auth --machine-identity-id= --jwt= --name= ``` @@ -222,7 +227,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --auth-method=jwt-auth --jwt= --machine-identity-id= --relay= --name= + sudo infisical gateway start --auth-method=jwt-auth --jwt= --machine-identity-id= --name= ``` @@ -238,7 +243,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash - infisical gateway start --token= --relay= --name= + sudo infisical gateway start --token= --name= ``` @@ -250,12 +255,14 @@ The Relay supports multiple authentication methods. Below are the available auth The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. + If this flag is omitted, the gateway will automatically connect to a healthy relay with the lowest latency. + ```bash # Example - infisical gateway start --relay=my-relay --name=my-gateway --token= + sudo infisical gateway start --relay=my-relay --name=my-gateway --token= ``` - **Note:** If using organization relays or self-hosted instance relays, you must first start a relay server using `infisical relay start` before connecting gateways to it. For Infisical Cloud users using instance relays, the relay infrastructure is already running and managed by Infisical. + **Note:** For Infisical Cloud users using instance relays, the relay infrastructure is already running and managed by Infisical. If using organization relays or self-hosted instance relays, you must first start a relay server. For more information on deploying relays, refer to the [Relay Deployment Guide](/documentation/platform/gateways/relay-deployment). @@ -264,7 +271,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash # Example - infisical gateway start --name=my-gateway --relay=my-relay --token= + sudo infisical gateway start --name=my-gateway --token= ``` @@ -274,7 +281,7 @@ The Relay supports multiple authentication methods. Below are the available auth ```bash # Example - infisical gateway start --domain=https://app.your-domain.com --relay= --name= + sudo infisical gateway start --domain=https://app.your-domain.com --name= ``` @@ -285,7 +292,7 @@ The Relay supports multiple authentication methods. Below are the available auth Install and enable the gateway as a systemd service. This command must be run with sudo on Linux. ```bash -sudo infisical gateway systemd install --token= --domain= --name= --relay= +sudo infisical gateway systemd install --token= --domain= --name= ``` ### Requirements @@ -302,7 +309,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ```bash # Example - sudo infisical gateway systemd install --token= --name= --relay= + sudo infisical gateway systemd install --token= --name= ``` You may also expose the token to the CLI by setting the environment variable `INFISICAL_TOKEN` before executing the install command. @@ -314,7 +321,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ```bash # Example - sudo infisical gateway systemd install --domain=https://app.your-domain.com --name= --relay= + sudo infisical gateway systemd install --domain=https://app.your-domain.com --name= ``` @@ -324,19 +331,23 @@ sudo infisical gateway systemd install --token= --domain= --name= ```bash # Example - sudo infisical gateway systemd install --name=my-gateway --token= --relay= + sudo infisical gateway systemd install --name=my-gateway --token= ``` - The name of the relay that this gateway should connect to. + The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. + + If this flag is omitted, the gateway will automatically connect to a healthy relay with the lowest latency. ```bash # Example sudo infisical gateway systemd install --relay=my-relay --token= --name= ``` + **Note:** For Infisical Cloud users using instance relays, the relay infrastructure is already running and managed by Infisical. If using organization relays or self-hosted instance relays, you must first start a relay server. For more information on deploying relays, refer to the [Relay Deployment Guide](/documentation/platform/gateways/relay-deployment). + @@ -671,3 +682,14 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot + +## Frequently Asked Questions + + + + If the `--relay` flag is omitted, the gateway automatically selects the optimal relay. It first checks for healthy organization relays and connects to the one with the lowest latency. If no organization relays are available, it then performs the same latency-based selection among the available managed relays. + + + No. The first time the gateway starts, it selects the optimal relay (based on latency) and caches that selection. On subsequent restarts, it will prioritize connecting to the cached relay. If it's unable to connect, it will then re-evaluate and connect to the next most optimal relay available. + + diff --git a/docs/contributing/getting-started/faq.mdx b/docs/contributing/getting-started/faq.mdx deleted file mode 100644 index f34382c64..000000000 --- a/docs/contributing/getting-started/faq.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: "FAQ" -description: "Frequently Asked Questions about contributing to Infisical" ---- - -Frequently asked questions about contributing to Infisical can be found on this page. -If you can't find the answer you are looking for, please create an issue on our GitHub repository or join our Slack channel for additional support. - - -The Alpine Linux CDN may be unavailable/down in your region infrequently (eg. there is an unplanned outage). One possible fix is to add a retry mechanism and a fallback mirrors array to the Dockerfile. You can also use this as an opportunity to pin the Alpine Linux version for Docker to use in case there are issues with the latest version. Ensure to use https for the mirrors. - -#### Make the following changes to the backend Dockerfile -```bash -# Pin Alpine version from list: https://dl-cdn.alpinelinux.org/alpine/ -ARG ALPINE_VERSION=3.17 -ARG ALPINE_APPEND=v3.17/main - -# Specify number of retries for each mirror -ARG MAX_RETRIES=3 - -# Define base Alpine mirror URLs in attempt order from list: https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt -ARG BASE_ALPINE_MIRRORS="https://dl-cdn.alpinelinux.org/alpine https://ftp.halifax.rwth-aachen.de/alpine https://uk.alpinelinux.org/alpine" - -# Build stage -# Add the Alpine version arg -FROM node:16-alpine$ALPINE_VERSION AS build - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --only-production - -COPY . . -RUN npm run build - -# Production stage -# Add the Alpine version arg -FROM node:16-alpine$ALPINE_VERSION - -WORKDIR /app - -ENV npm_config_cache /home/node/.npm - -COPY package*.json ./ -RUN npm ci --only-production - -COPY --from=build /app . - -# Add retry mechanism and loop through the specified mirrors -RUN retries_left=$MAX_RETRIES; \ - for mirror in $ALPINE_MIRRORS; do \ - full_mirror="$mirror/$ALPINE_APPEND"; \ - echo "Trying mirror: $full_mirror"; \ - echo >>/etc/apk/repositories "$full_mirror"; \ - for i in $(seq $retries_left); do \ - echo "Retrying... Attempt $i (Retries Left: $((retries_left - i)))"; \ - if apk add --no-cache bash curl git && \ - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash && \ - apk add --no-cache infisical=0.8.1; then \ - break; \ - fi; \ - sleep 10; \ - done; \ - if [ $? -eq 0 ]; then \ - break; \ - fi; \ - done - -HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ - CMD node healthcheck.js - -EXPOSE 4000 - -CMD ["npm", "run", "start"] - ``` - - - [Alpine Linux (mirrors) - official site](https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) - - - - [Alpine Linux (mirrors) - archived site](https://web.archive.org/web/20230914123159/https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) - - - - [Alpine Linux (versions) - official site](https://dl-cdn.alpinelinux.org/alpine/) - - - - [Alpine Linux (versions) - archived site](https://web.archive.org/web/20230914123455/https://dl-cdn.alpinelinux.org/alpine/) - - - diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 1784b77e8..e34f715b0 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -20,7 +20,6 @@ Infisical has two major code-bases. One for the platform code, and one for SDKs. - [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk) - [PHP SDK](https://github.com/Infisical/php-sdk) - [Rust SDK](https://github.com/Infisical/rust-sdk) - - [Ruby SDK](https://github.com/infisical/sdk) ## Community diff --git a/docs/contributing/getting-started/pull-requests.mdx b/docs/contributing/getting-started/pull-requests.mdx index 2f3163478..dfdf05c18 100644 --- a/docs/contributing/getting-started/pull-requests.mdx +++ b/docs/contributing/getting-started/pull-requests.mdx @@ -29,13 +29,7 @@ Feel free to add a short video or screenshots of what your PR achieves. ## Getting your PR reviewed -Once your PR is reviewed, one or two relevant members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. - -- Vlad: Frontend, Web UI -- Tony: Backend, SDKs, Security -- Maidul: Backend, CI/CD, CLI, Kubernetes Operator -- Daniel: Frontend, UI/UX, Backend, SDKs - +One or two relevant members of the Infisical team should review and approve the PR before it is merged. You can ping someone from the team in our [Slack](https://infisical.com/slack) to review your PR. The team member(s) will start by enabling baseline checks to ensure that there are no leaked secrets, new dependencies are clear, and the frontend/backend services start up. Afterward, they will review your PR thoroughly by testing the code and leave any feedback or work in with you to revise the PR up to standard. diff --git a/docs/contributing/platform/backend/folder-structure.mdx b/docs/contributing/platform/backend/folder-structure.mdx index abfe0f69d..ec0bd72e4 100644 --- a/docs/contributing/platform/backend/folder-structure.mdx +++ b/docs/contributing/platform/backend/folder-structure.mdx @@ -5,28 +5,47 @@ title: 'Backend folder structure' ``` β”œβ”€β”€ scripts β”œβ”€β”€ e2e-test +β”œβ”€β”€ bdd └── src/ β”œβ”€β”€ @types/ β”‚ β”œβ”€β”€ knex.d.ts - β”‚ └── fastify.d.ts + β”‚ β”œβ”€β”€ fastify.d.ts + β”‚ β”œβ”€β”€ ... β”œβ”€β”€ db/ β”‚ β”œβ”€β”€ migrations β”‚ β”œβ”€β”€ schemas - β”‚ └── seed + β”‚ └── seeds + β”œβ”€β”€ keystore/ β”œβ”€β”€ lib/ + β”‚ β”œβ”€β”€ api-docs + β”‚ β”œβ”€β”€ aws + β”‚ β”œβ”€β”€ axios + β”‚ β”œβ”€β”€ base64 + β”‚ β”œβ”€β”€ casl + β”‚ β”œβ”€β”€ certificates + β”‚ β”œβ”€β”€ config + β”‚ β”œβ”€β”€ crypto + β”‚ β”œβ”€β”€ dates + β”‚ β”œβ”€β”€ delay + β”‚ β”œβ”€β”€ error-codes + β”‚ β”œβ”€β”€ errors + β”‚ β”œβ”€β”€ files β”‚ β”œβ”€β”€ fn - β”‚ β”œβ”€β”€ date - β”‚ └── config + β”‚ β”œβ”€β”€ ... β”œβ”€β”€ queue β”œβ”€β”€ server/ β”‚ β”œβ”€β”€ routes/ β”‚ β”‚ β”œβ”€β”€ v1 - β”‚ β”‚ └── v2 + β”‚ β”‚ β”œβ”€β”€ v2 + β”‚ β”‚ β”œβ”€β”€ v3 + β”‚ β”‚ └── v4 β”‚ β”œβ”€β”€ plugins - β”‚ └── config + β”‚ β”œβ”€β”€ config + β”‚ └── lib β”œβ”€β”€ services/ β”‚ β”œβ”€β”€ auth β”‚ β”œβ”€β”€ org + β”‚ β”œβ”€β”€ ... β”‚ └── project/ β”‚ β”œβ”€β”€ project-service.ts β”‚ β”œβ”€β”€ project-types.ts @@ -42,19 +61,23 @@ Contains reusable scripts for backend automation, like running migrations and ge ### `backend/e2e-test` Integration tests for the APIs. +### `backend/bdd` +Behavior-Driven Development (BDD) tests using Python and Gherkin feature files. + ### `backend/src` The source code of the backend. -- `@types`: Type definitions for libraries like Fastify and Knex. -- `db`: Knex.js configuration for the database, including migration, seed files, and SQL type schemas. -- `lib`: Stateless, reusable functions used across the codebase. +- `@types`: Type definitions for libraries like Fastify, Knex, and other third-party dependencies. +- `db`: Knex.js configuration for the database, including migrations, seed files, and SQL type schemas. +- `keystore`: Key-value store abstraction layer supporting Redis and PostgreSQL for application caching, distributed locking, and coordination. +- `lib`: Stateless, reusable functions used across the codebase, organized by functionality (crypto, config, dates, etc.). - `queue`: Infisical's queue system based on BullMQ. ### `src/server` - Scope anything related to Fastify/service here. -- Includes routes, Fastify plugins, and server configurations. -- The routes folder contains various versions of routes separated into v1, v2, etc. +- Includes routes, Fastify plugins, server configurations, and server-specific utilities. +- The routes folder contains various versions of routes separated into v1, v2, v3, v4, etc. ### `src/services` diff --git a/docs/contributing/platform/backend/how-to-create-a-feature.mdx b/docs/contributing/platform/backend/how-to-create-a-feature.mdx index f02040cfa..52fc5aad0 100644 --- a/docs/contributing/platform/backend/how-to-create-a-feature.mdx +++ b/docs/contributing/platform/backend/how-to-create-a-feature.mdx @@ -8,7 +8,7 @@ Suppose you're interested in implementing a new feature in Infisical's backend, If your feature involves a change in the database, you need to first address this by generating the necessary database schemas. 1. If you're adding a new table, update the `TableName` enum in `/src/db/schemas/models.ts` to include the new table name. -2. Create a new migration file by running `npm run migration:new` and give it a relevant name, such as `feature-x`. +2. Create a new migration file by going to the `/backend` folder and running `npm run migration:new` and give it a relevant name, such as `feature-x`. 3. Navigate to `/src/db/migrations/_.ts`. 4. Modify both the `up` and `down` functions to create or alter Postgres fields on migration up and to revert these changes on migration down, ensuring idempotency as outlined [here](https://github.com/graphile/migrate/blob/main/docs/idempotent-examples.md). @@ -16,10 +16,11 @@ If your feature involves a change in the database, you need to first address thi While typically you would need to manually write TS types for Knex type-sense, we have automated this process: -1. Start the server. -2. Run `npm run migration:latest` to apply all database changes. -3. Execute `npm run generate:schema` to automatically generate types and schemas using [zod](https://github.com/colinhacks/zod) in the `/src/db/schemas` folder. -4. Update the barrel export in `schema/index` and include the new tables in `/src/@types/knex.d.ts` to enable type-sensing in Knex.js. +1. If you haven't done it yet, create a new `.env.migration` file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.migration.example) +2. Start the server. +3. Go to the `/backend` folder and run `npm run migration:latest-dev` to apply all database changes. +4. Execute `npm run generate:schema` to automatically generate types and schemas using [zod](https://github.com/colinhacks/zod) in the `/src/db/schemas` folder. +5. Update the barrel export in `schema/index` and include the new tables in `/src/@types/knex.d.ts` to enable type-sensing in Knex.js. ## Business Logic @@ -38,10 +39,10 @@ Use the custom Infisical function `ormify` in `src/lib/knex` for simple database ## Connecting the Service Layer to the Server Layer -Server-related logic is handled in `/src/server`. To connect the service layer to the server layer, we use Fastify plugins for dependency injection: +Server-related logic is handled in `/src/server`. To connect the service layer to the server layer, we use Fastify's dependency injection pattern: -1. Add the service type in the `fastify.d.ts` file under the `service` namespace of a FastifyServerInstance type. -2. In `/src/server/routes/index.ts`, instantiate the required dependencies for `feature-x`, such as the DAL and service layers, and then pass them to `fastify.register("service,{...dependencies})`. +1. Add the service type in `/src/@types/fastify.d.ts` under the `services` namespace of the `FastifyInstance` interface. +2. In `/src/server/routes/index.ts`, instantiate the required dependencies for `feature-x` (such as the DAL and service layers), and then add the service instance to the `server.decorate()` call, where all services are registered for dependency injection. 3. This makes the service layer accessible within all routes under the Fastify service instance, accessed via `server.services..`. ## Writing API Routes diff --git a/docs/contributing/platform/developing.mdx b/docs/contributing/platform/developing.mdx index 69710baf3..5a1af52c4 100644 --- a/docs/contributing/platform/developing.mdx +++ b/docs/contributing/platform/developing.mdx @@ -15,7 +15,7 @@ git checkout -b MY_BRANCH_NAME ## Set up environment variables -Start by creating a .env file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.example). View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. +Start by creating a `.env` file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.example). View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. ## Starting Infisical for development diff --git a/docs/docs.json b/docs/docs.json index 3fb6914af..aea022fd4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -377,8 +377,7 @@ "pages": [ "contributing/getting-started/overview", "contributing/getting-started/code-of-conduct", - "contributing/getting-started/pull-requests", - "contributing/getting-started/faq" + "contributing/getting-started/pull-requests" ] }, { @@ -703,40 +702,77 @@ ] }, { - "item": "Infisical PKI", + "item": "Certificate Management", "groups": [ { - "group": "Infisical PKI", + "group": "Certificate Management", "pages": [ "documentation/platform/pki/overview", - "documentation/platform/pki/private-ca", - "documentation/platform/pki/external-ca", - "documentation/platform/pki/subscribers", - "documentation/platform/pki/certificates", - "documentation/platform/pki/acme-ca", - "documentation/platform/pki/azure-adcs", - "documentation/platform/pki/est", - "documentation/platform/pki/alerting", { - "group": "Integrations", + "group": "Concepts", "pages": [ - "documentation/platform/pki/pki-issuer", - "documentation/platform/pki/integration-guides/gloo-mesh" + "documentation/platform/pki/concepts/certificate-mgmt", + "documentation/platform/pki/concepts/certificate-lifecycle" + ] + } + ] + }, + { + "group": "Product Reference", + "pages": [ + { + "group": "Certificate Authorities", + "pages": [ + "documentation/platform/pki/ca/overview", + "documentation/platform/pki/ca/private-ca", + "documentation/platform/pki/ca/external-ca" ] }, { - "group": "Certificate Syncs", + "group": "Certificates", "pages": [ - "documentation/platform/pki/certificate-syncs/overview", - { - "group": "Syncs", - "pages": [ - "documentation/platform/pki/certificate-syncs/aws-certificate-manager", - "documentation/platform/pki/certificate-syncs/azure-key-vault" - ] - } + "documentation/platform/pki/certificates/overview", + "documentation/platform/pki/certificates/profiles", + "documentation/platform/pki/certificates/templates", + "documentation/platform/pki/certificates/certificates" ] - } + }, + { + "group": "Enrollment Methods", + "pages": [ + "documentation/platform/pki/enrollment-methods/overview", + "documentation/platform/pki/enrollment-methods/api", + "documentation/platform/pki/enrollment-methods/est" + ] + }, + "documentation/platform/pki/alerting" + ] + }, + { + "group": "Infrastructure Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh", + "documentation/platform/pki/integration-guides/windows-server-acme", + "documentation/platform/pki/integration-guides/nginx-certbot", + "documentation/platform/pki/integration-guides/apache-certbot", + "documentation/platform/pki/integration-guides/tomcat-certbot", + "documentation/platform/pki/integration-guides/jboss-certbot" + ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/overview", + "documentation/platform/pki/certificate-syncs/aws-certificate-manager", + "documentation/platform/pki/certificate-syncs/azure-key-vault" + ] + }, + { + "group": "External CA Integrations", + "pages": [ + "documentation/platform/pki/ca/acme-ca", + "documentation/platform/pki/ca/azure-adcs" ] } ] @@ -862,7 +898,7 @@ "pages": ["api-reference/endpoints/events/project-events"] }, { - "group": "Identities", + "group": "Organization Identities", "pages": [ "api-reference/endpoints/identities/create", "api-reference/endpoints/identities/update", @@ -1118,21 +1154,31 @@ ] }, { - "group": "Project Identities", + "group": "Project Managed Identities", "pages": [ - "api-reference/endpoints/project-identities/add-identity-membership", - "api-reference/endpoints/project-identities/list-identity-memberships", + "api-reference/endpoints/project-identities/add-identity", + "api-reference/endpoints/project-identities/list-identity", "api-reference/endpoints/project-identities/get-by-id", - "api-reference/endpoints/project-identities/update-identity-membership", - "api-reference/endpoints/project-identities/delete-identity-membership", + "api-reference/endpoints/project-identities/update-identity", + "api-reference/endpoints/project-identities/delete-identity" + ] + }, + { + "group": "Project Identity Memberships", + "pages": [ + "api-reference/endpoints/project-identities-membership/add-identity-membership", + "api-reference/endpoints/project-identities-membership/list-identity-memberships", + "api-reference/endpoints/project-identities-membership/get-by-id", + "api-reference/endpoints/project-identities-membership/update-identity-membership", + "api-reference/endpoints/project-identities-membership/delete-identity-membership", { "group": "Legacy", "pages": [ - "api-reference/endpoints/deprecated/project-identities/add-identity-membership", - "api-reference/endpoints/deprecated/project-identities/list-identity-memberships", - "api-reference/endpoints/deprecated/project-identities/get-by-id", - "api-reference/endpoints/deprecated/project-identities/update-identity-membership", - "api-reference/endpoints/deprecated/project-identities/delete-identity-membership" + "api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership", + "api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships", + "api-reference/endpoints/deprecated/project-identities-v2/get-by-id", + "api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership", + "api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership" ] } ] @@ -2553,8 +2599,6 @@ "api-reference/endpoints/certificate-authorities/cert", "api-reference/endpoints/certificate-authorities/sign-intermediate", "api-reference/endpoints/certificate-authorities/import-cert", - "api-reference/endpoints/certificate-authorities/issue-cert", - "api-reference/endpoints/certificate-authorities/sign-cert", "api-reference/endpoints/certificate-authorities/crl" ] }, @@ -2563,13 +2607,15 @@ "pages": [ "api-reference/endpoints/certificates/list", "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/issue-certificate", + "api-reference/endpoints/certificates/sign-certificate", + "api-reference/endpoints/certificates/renew", + "api-reference/endpoints/certificates/update-config", "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", "api-reference/endpoints/certificates/cert-body", "api-reference/endpoints/certificates/bundle", - "api-reference/endpoints/certificates/private-key", - "api-reference/endpoints/certificates/issue-certificate", - "api-reference/endpoints/certificates/sign-certificate" + "api-reference/endpoints/certificates/private-key" ] }, { @@ -2594,22 +2640,17 @@ "api-reference/endpoints/pki-collections/delete-item" ] }, - { - "group": "PKI Alerting", - "pages": [ - "api-reference/endpoints/pki-alerts/create", - "api-reference/endpoints/pki-alerts/read", - "api-reference/endpoints/pki-alerts/update", - "api-reference/endpoints/pki-alerts/delete" - ] - }, { "group": "Certificate Profiles", "pages": [ + "api-reference/endpoints/certificate-profiles/list", "api-reference/endpoints/certificate-profiles/create", "api-reference/endpoints/certificate-profiles/update", "api-reference/endpoints/certificate-profiles/get-by-id", - "api-reference/endpoints/certificate-profiles/delete" + "api-reference/endpoints/certificate-profiles/get-by-slug", + "api-reference/endpoints/certificate-profiles/delete", + "api-reference/endpoints/certificate-profiles/list-certificates", + "api-reference/endpoints/certificate-profiles/get-latest-active-bundle" ] }, { @@ -2618,6 +2659,9 @@ "api-reference/endpoints/pki/syncs/list", "api-reference/endpoints/pki/syncs/get-by-id", "api-reference/endpoints/pki/syncs/options", + "api-reference/endpoints/pki/syncs/list-certificates", + "api-reference/endpoints/pki/syncs/add-certificates", + "api-reference/endpoints/pki/syncs/remove-certificates", { "group": "AWS Certificate Manager", "pages": [ @@ -2989,6 +3033,30 @@ { "source": "/sdks/languages/csharp", "destination": "/sdks/languages/dotnet" + }, + { + "source": "/documentation/platform/pki/private-ca", + "destination": "/documentation/platform/pki/ca/private-ca" + }, + { + "source": "/documentation/platform/pki/external-ca", + "destination": "/documentation/platform/pki/ca/external-ca" + }, + { + "source": "/documentation/platform/pki/acme-ca", + "destination": "/documentation/platform/pki/ca/acme-ca" + }, + { + "source": "/documentation/platform/pki/azure-adcs", + "destination": "/documentation/platform/pki/ca/azure-adcs" + }, + { + "source": "/documentation/platform/pki/certificates", + "destination": "/documentation/platform/pki/certificates/certificates" + }, + { + "source": "/documentation/platform/pki/est", + "destination": "/documentation/platform/pki/enrollment-methods/est" } ] } diff --git a/docs/documentation/getting-started/api.mdx b/docs/documentation/getting-started/api.mdx deleted file mode 100644 index c638c4d70..000000000 --- a/docs/documentation/getting-started/api.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: "REST API" ---- - -Infisical's REST API is the most flexible way to read/write secrets for your application. - -In this brief, we'll explore how to fetch a secret back from a project on [Infisical Cloud](https://app.infisical.com) via the REST API. - - - - To create a project, head to your Organization Overview and press **Add New Project**; we'll call the project **Demo App**. - ![create project](../../images/getting-started/api/org-create-project-1.png) - - ![create project](../../images/getting-started/api/org-create-project-2.png) - - Next, let's head to the **Development** environment of the project and add a secret `FOO=BAR` to it. - - ![explore project env](../../images/getting-started/api/project-explore-env.png) - - ![create secret](../../images/getting-started/api/project-create-secret.png) - - ![project dashboard](../../images/getting-started/api/project-dashboard.png) - - - For this brief, you'll need to disable end-to-end encryption in your Project Settings - - - - Next, we need to create an identity to represent your application. To create one, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. - - ![identities organization](../../images/platform/identities/identities-org.png) - - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - - ![identities organization create](../../images/platform/identities/identities-org-create.png) - - Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - - ![identities organization create auth method](../../images/platform/identities/identities-org-create-auth-method.png) - - - - In order to use the identity, you'll need the non-sensitive **Client ID** - of the identity and a **Client Secret** for it; you can think of these credentials akin to a username - and password used to authenticate with the Infisical API. With that, press on the key icon on the identity to generate a **Client Secret** - for it. - - ![identities client secret create](../../images/platform/identities/identities-org-client-secret.png) - ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-1.png) - ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-2.png) - - - To enable the identity to access your project, we need to add it to the project. To do this, head over to the **Demo App** Project Settings > Access Control > Machine Identities and press **Add identity**. - - Next, select the identity you want to add to the project and the role you want to assign it. - - ![identities project](../../images/platform/identities/identities-project.png) - - ![identities project create](../../images/platform/identities/identities-project-create.png) - - - To access the Infisical API as the identity, you should first perform a login operation - that is to exchange the **Client ID** and **Client Secret** of the identity for an access token - by making a request to the `/api/v1/auth/universal-auth/login` endpoint. - - #### Sample request - - ``` - curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'clientSecret=' \ - --data-urlencode 'clientId=' - ``` - - #### Sample response - - ``` - { - "accessToken": "...", - "expiresIn": 7200, - "tokenType": "Bearer" - } - ``` - - Next, we can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) to read/write secrets - - - Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; - the default TTL is `7200` seconds which can be adjusted. - - If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, - a new access token should be obtained from the aforementioned login operation. - - - - Finally, you can fetch the secret `FOO=BAR` back from **Step 1** by including the access token in the previous step in another request to the `/api/v3/secrets/raw/{secretName}` endpoint. - - ### Sample request - - ``` - curl --location --request GET 'http://localhost:8080/api/v3/secrets/raw/FOO?workspaceId=657830d579cfc8415d06ce5b&environment=dev' \ - --header 'Authorization: Bearer ' - ``` - - ### Sample response - - ``` - { - "secret": { - "_id": "6564234b934d634e1fcd6cdf", - "version": 1, - "workspace": "6564173e934d634e1fcd6950", - "type": "shared", - "environment": "dev", - "secretKey": "FOO2", - "secretValue": "BAR2", - "secretComment": "" - } - } - ``` - - Note that you can fetch a list of secrets back by making a request to the `/api/v3/secrets/raw` endpoint. - - - -See also: - -- [API Reference](/api-reference/overview/introduction) diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx index f51136278..e0b0788ca 100644 --- a/docs/documentation/getting-started/overview.mdx +++ b/docs/documentation/getting-started/overview.mdx @@ -29,10 +29,10 @@ description: "The open source platform for managing secrets, certificates, and s Automatically detect and alert on hardcoded secrets in source code, CI pipelines, and infrastructure. - Automate the issuance and management of X.509 certificates across your infrastructure using modern protocols like EST. + Automate CA and X.509 certificate lifecycle management across your infrastructure. Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. - - - - Encrypt and decrypt sensitive data using a centralized key management system. + Encrypt and decrypt sensitive data using a centralized key management system. ## Resources - + Explore Infisical’s command-line interface for managing secrets, certificates, and system operations via terminal. - + Browse Infisical’s API documentation to programmatically interact with secrets, access controls, and certificate workflows. - - Learn how to deploy and operate Infisical on your own infrastructure with full - control and data ownership. - + + Learn how to deploy and operate Infisical on your own infrastructure with + full control and data ownership. + diff --git a/docs/documentation/getting-started/platform.mdx b/docs/documentation/getting-started/platform.mdx deleted file mode 100644 index 7ce96a0ed..000000000 --- a/docs/documentation/getting-started/platform.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Platform" ---- - -This quickstart provides an overview of functionalities offered by Infisical. - -## Managing your Organization - -When you first make an account with Infisical, you also create a new **organization** where you are assigned the `admin` role by default. -From there, you can invite external members to the organization and start creating **projects** to house secrets. - -### Projects - -The **Projects** page shows you all the projects that you have access to within your organization. -Here, you can also create a new project. - -![organization overview](../../images/organization-overview.png) - -### Members - -The **Members** page lets you add or remove external members to your organization. -Note that you can configure your organization in Infisical to have members authenticate with the platform via protocols like SAML 2.0 and OpenID Connect. - -![organization members](../../images/organization/platform/organization-members.png) - -## Managing your Projects - -As mentioned before, projects house secrets which are further organized into environments such as development, testing and production. -A project can be anything from a single application to a collection of micro-services that you wish to manage secrets for. - -### Secrets Overview - -The **Secrets Overview** screen provides a bird's-eye view of all the secrets in a project and is useful for comparing secrets and identifying missing ones across environments. - -![dashboard secrets overview](../../images/dashboard-secrets-overview.png) - -In the above image, you can already see that: - -- `STRIPE_API_KEY` is missing from the **Staging** environment. -- `JWT_SECRET` is missing from the **Production** environment. -- `BAR` is `EMPTY` in the **Production** environment. - -### Dashboard - -The secrets dashboard lets you manage secrets for a specific environment in a project. -Here, developers can override secrets, version secrets, rollback projects to any point in time and much more. - -![dashboard](../../images/dashboard.png) - -### Integrations - -The integrations page provides native integrations to sync secrets from a project environment to a [host of ever-expanding integrations](/integrations/overview). - -![integrations](../../images/integrations.png) - -### Members - -The members page lets you add/remove members to/from a project and provision them access to environments via roles. By default, Infisical provides the `admin`, `developer`, and `viewer` roles -which you can assign to members. - -![project members](../../images/project-members.png) - -That's it for the platform quickstart! β€” We encourage you to continue exploring the documentation to gain a deeper understanding of the extensive features and functionalities that Infisical has to offer. - -Next, head back to [Getting Started > Introduction](/documentation/getting-started/overview) to explore ways to fetch secrets from Infisical to your apps and infrastructure. diff --git a/docs/documentation/guides/nextjs-vercel.mdx b/docs/documentation/guides/nextjs-vercel.mdx index f4dd9ceca..ecefb0f2e 100644 --- a/docs/documentation/guides/nextjs-vercel.mdx +++ b/docs/documentation/guides/nextjs-vercel.mdx @@ -199,7 +199,7 @@ Next, navigate to your project's integrations tab in Infisical and press on the Opting in for the Infisical-Vercel integration will break end-to-end encryption since Infisical will be able to read your secrets. This is, however, necessary for Infisical to sync the secrets to Vercel. - Your secrets remain encrypted at rest following our [security guide mechanics](/security/mechanics). + Your secrets remain encrypted at rest following our [security guide mechanics](/internals/security). Now select **Production** for (the source) **Environment** and sync it to the **Production Environment** of the (target) application in Vercel. @@ -238,7 +238,7 @@ At this stage, you know how to use the Infisical-Vercel integration to sync prod Yes. Your secrets are still encrypted at rest. To note, most secret managers actually don't support end-to-end encryption. - Check out the [security guide](/security/overview). + Check out the [security guide](/internals/security). diff --git a/docs/documentation/guides/organization-structure.mdx b/docs/documentation/guides/organization-structure.mdx index 752c06ccc..8693c8c1c 100644 --- a/docs/documentation/guides/organization-structure.mdx +++ b/docs/documentation/guides/organization-structure.mdx @@ -75,7 +75,7 @@ Infisical’s access control framework is unified for both human users and machi ### 7.3 Attribute-Based Access Control (ABAC) -[Attribute-based Access Controls](/documentation/platform/access-controls/attribute-based-access-controls) allow restrictions based on tags or attributes linked to secrets. These can be integrated with SAML assertions and other security frameworks for dynamic access management. +[Attribute-based Access Controls](/documentation/platform/access-controls/abac/overview) allow restrictions based on tags or attributes linked to secrets. These can be integrated with SAML assertions and other security frameworks for dynamic access management. ### 7.4 User Groups diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx index 9a5b7d816..c7b8763c2 100644 --- a/docs/documentation/platform/gateways/gateway-deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -122,11 +122,13 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order For production deployments on Linux servers, install the Gateway as a systemd service so that it runs securely in the background and automatically restarts on failure or system reboot: ```bash - sudo infisical gateway systemd install --token --domain --name --relay + sudo infisical gateway systemd install --token --domain --name sudo systemctl start infisical-gateway ``` - + + By default, the gateway connects to the most optimal relay. Use the `--relay` flag to manually specify a different relay server. + The systemd install command requires a Linux operating system with root/sudo @@ -153,10 +155,13 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_RELAY_NAME= \ --from-literal=INFISICAL_GATEWAY_NAME= ``` + + By default, the gateway connects to the most optimal relay. Use the `--from-literal=INFISICAL_RELAY_NAME=` flag to manually specify a different relay server. + + #### Install the Gateway ```bash @@ -168,8 +173,12 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order For development or testing environments: ```bash - infisical gateway start --token --relay= --name= + sudo infisical gateway start --token --name= ``` + + + By default, the gateway connects to the most optimal relay. Use the `--relay` flag to manually specify a different relay server. + diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx index 18bfe6fa5..df4f11436 100644 --- a/docs/documentation/platform/groups.mdx +++ b/docs/documentation/platform/groups.mdx @@ -31,7 +31,7 @@ In the following steps, we explore how to create and use user groups to provisio ![groups org](/images/platform/groups/groups-org.png) - When creating a group, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![groups org create](/images/platform/groups/groups-org-create.png) diff --git a/docs/documentation/platform/identities/alicloud-auth.mdx b/docs/documentation/platform/identities/alicloud-auth.mdx index 2f54ef71b..059adc32d 100644 --- a/docs/documentation/platform/identities/alicloud-auth.mdx +++ b/docs/documentation/platform/identities/alicloud-auth.mdx @@ -88,7 +88,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index ab2b5cd3a..1d277a0c0 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -66,7 +66,7 @@ access the Infisical API using the AWS Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx index 7a7c112ef..dd73f1783 100644 --- a/docs/documentation/platform/identities/azure-auth.mdx +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -66,7 +66,7 @@ access the Infisical API using the Azure Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx index 8d6a1f177..cdc4a86a1 100644 --- a/docs/documentation/platform/identities/gcp-auth.mdx +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -72,7 +72,7 @@ access the Infisical API using the GCP ID Token authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -241,7 +241,7 @@ access the Infisical API using the GCP IAM authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/jwt-auth.mdx b/docs/documentation/platform/identities/jwt-auth.mdx index 339138881..29a37a5d4 100644 --- a/docs/documentation/platform/identities/jwt-auth.mdx +++ b/docs/documentation/platform/identities/jwt-auth.mdx @@ -61,7 +61,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index e357ba75e..f9412b92b 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -218,7 +218,7 @@ In the following steps, we explore how to create and use identities for your app ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oci-auth.mdx b/docs/documentation/platform/identities/oci-auth.mdx index ef5fafa4c..e07917c72 100644 --- a/docs/documentation/platform/identities/oci-auth.mdx +++ b/docs/documentation/platform/identities/oci-auth.mdx @@ -102,7 +102,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/azure.mdx b/docs/documentation/platform/identities/oidc-auth/azure.mdx index a9f244794..c4dd44152 100644 --- a/docs/documentation/platform/identities/oidc-auth/azure.mdx +++ b/docs/documentation/platform/identities/oidc-auth/azure.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/circleci.mdx b/docs/documentation/platform/identities/oidc-auth/circleci.mdx index bb5999f55..09616a5b4 100644 --- a/docs/documentation/platform/identities/oidc-auth/circleci.mdx +++ b/docs/documentation/platform/identities/oidc-auth/circleci.mdx @@ -56,7 +56,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/general.mdx b/docs/documentation/platform/identities/oidc-auth/general.mdx index f847f51fe..26f291734 100644 --- a/docs/documentation/platform/identities/oidc-auth/general.mdx +++ b/docs/documentation/platform/identities/oidc-auth/general.mdx @@ -60,7 +60,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/github.mdx b/docs/documentation/platform/identities/oidc-auth/github.mdx index 567f38d05..d7c2da280 100644 --- a/docs/documentation/platform/identities/oidc-auth/github.mdx +++ b/docs/documentation/platform/identities/oidc-auth/github.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx index b52d2f894..c3aeb9dac 100644 --- a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx +++ b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/spire.mdx b/docs/documentation/platform/identities/oidc-auth/spire.mdx index b402a1d10..6fb387391 100644 --- a/docs/documentation/platform/identities/oidc-auth/spire.mdx +++ b/docs/documentation/platform/identities/oidc-auth/spire.mdx @@ -94,7 +94,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx index 0ecb60b99..f52ad3c51 100644 --- a/docs/documentation/platform/identities/tls-cert-auth.mdx +++ b/docs/documentation/platform/identities/tls-cert-auth.mdx @@ -68,7 +68,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/token-auth.mdx b/docs/documentation/platform/identities/token-auth.mdx index f31e86517..c9a06d110 100644 --- a/docs/documentation/platform/identities/token-auth.mdx +++ b/docs/documentation/platform/identities/token-auth.mdx @@ -42,7 +42,7 @@ using the Token Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 3a87f6da9..18b847a40 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -47,7 +47,7 @@ using the Universal Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index 31e4bf242..0ee4614a4 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -7,7 +7,7 @@ description: "Read more about the concept of user identities in Infisical." A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. -Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). +Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../project). Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms/overview.mdx index 577373ab8..bf2cf69f0 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms/overview.mdx @@ -10,7 +10,7 @@ Infisical can be used as a Key Management System (KMS), referred to as Infisical By default your Infisical data such as projects and the data within them are encrypted at rest using Infisical's own KMS. This ensures that your data is secure and protected from unauthorized access. -If you are on-premise, your KMS root key will be created at random with the `ROOT_ENCRYPTION_KEY` environment variable. You can also use a Hardware Security Module (HSM), to create the root key. Read more about [HSM](/docs/documentation/platform/kms/encryption-strategies). +If you are on-premise, your KMS root key will be created at random with the `ROOT_ENCRYPTION_KEY` environment variable. You can also use a Hardware Security Module (HSM), to create the root key. Read more about [HSM](/documentation/platform/kms/hsm-integration). Keys managed in KMS are not extractable from the platform. Additionally, data @@ -109,7 +109,7 @@ In the following steps, we explore how to generate a key and use it to encrypt d To encrypt data, make an API request to the [Encrypt - Data](/api-reference/endpoints/kms/keys/encrypt) API endpoint, + Data](/api-reference/endpoints/kms/encryption/encrypt) API endpoint, specifying the key to use. @@ -168,7 +168,7 @@ In the following steps, we explore how to use decrypt data using an existing key To decrypt data, make an API request to the [Decrypt - Data](/api-reference/endpoints/kms/keys/decrypt) API endpoint, + Data](/api-reference/endpoints/kms/encryption/decrypt) API endpoint, specifying the key to use. ### Sample request diff --git a/docs/documentation/platform/pam/session-recording.mdx b/docs/documentation/platform/pam/session-recording.mdx index 7e560d5c2..e9061430c 100644 --- a/docs/documentation/platform/pam/session-recording.mdx +++ b/docs/documentation/platform/pam/session-recording.mdx @@ -21,7 +21,7 @@ The content captured during a session depends on the type of resource being acce For database connections, Infisical captures all queries executed and their corresponding responses. -Support for additional resource types like SSH and RDP is coming soon. +Support for additional resource types like SSH, RDP, Kubernetes, and MCP is coming soon. ## Viewing Recordings diff --git a/docs/documentation/platform/pki/alerting.mdx b/docs/documentation/platform/pki/alerting.mdx index 158c47877..c2342e020 100644 --- a/docs/documentation/platform/pki/alerting.mdx +++ b/docs/documentation/platform/pki/alerting.mdx @@ -5,145 +5,21 @@ description: "Learn how to set up alerting for expiring certificates with Infisi ## Concept -In order to ensure that your certificates are always up-to-date and not expired, you can set up alerting for expiring CA and leaf certificates in Infisical. - -## Workflow - -A typical alerting workflow for expiring certificates consists of the following steps: - -1. Creating a PKI/Certificate collection and adding certificates that you wish to monitor for expiration to it. -2. Creating an alert and binding it to the PKI/Certificate collection. As part of the configuration, you specify when the alert should trigger based on the number of days before certificate expiration and the email addresses of the recipients to notify. +In order to ensure that your certificates are always up-to-date and not expired, you can set up alerting in Infisical for expiring CA and leaf certificates based on customizable filters. ## Guide to Creating an Alert - - - - - To create a PKI/Certificate collection, head to your Project > Internal - PKI > Alerting > Certificate Collection and press **Create**. - - ![pki create collection](/images/platform/pki/alerting/collection-create.png) - - Give the collection a name and proceed to create the empty collection. +To create an alert, head to your Certificate Management Project > Alerting and press **Create Certificate Alert**. - ![pki create collection](/images/platform/pki/alerting/collection-create-2.png) +![pki alerting](/images/platform/pki/alerting/alert-create.png) - Next, in the Collection Page, add the certificate authorities and leaf certificates - that you wish to monitor for expiration to the collection. +![pki alerting modal](/images/platform/pki/alerting/alert-create-modal.png) - ![pki add cert to collection](/images/platform/pki/alerting/collection-add-cert.png) - - - To create an alert, head to your Project > Internal PKI > Alerting > Alerts and press **Create**. +Here's some guidance for each field in the alert configuration sequence: - ![pki create alert](/images/platform/pki/alerting/alert-create.png) - - Here, set the **Certificate Collection** to the PKI/Certificate collection you created in the previous step and fill out details for the alert. - - ![pki create alert](/images/platform/pki/alerting/alert-create-2.png) - - Here's some guidance on each field: - - - Name: A name for the alert. - - Collection Collection: The PKI/Certificate collection to bind the alert to from the previous step. - - Alert Before / Unit: The time before certificate expiration to trigger the alert. - - Emails to Alert: A comma-delimited list of email addresses to notify when the alert triggers. - - Finally, press **Create** to create the alert. - - ![pki alerts](/images/platform/pki/alerting/alerts.png) - - Great! You've successfully created a PKI/Certificate collection and an alert to monitor the expiring certificates in the collection. Once the alert triggers, the specified email addresses will be notified. - - - - - - - - 1.1. To create a PKI/Certificate collection, make an API request to the [Create PKI Collection](/api-reference/endpoints/pki-collections/create) API endpoint. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/collections' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "projectId": "", - "name": "My Certificate Collection" - }' - ``` - - ### Sample response - - ```bash Response - { - id: "", - name: "My Certificate Collection", - ... - } - ``` - - 1.2. Next, make an API request to the [Add Collection Item](/api-reference/endpoints/pki-collections/add-item) API endpoint to add a certificate to the collection. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/collections//items' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "type": "certificate", - "itemId": "id-of-certificate" - }' - ``` - - ### Sample response - - ```bash Response - { - id: "", - type: "certificate", - itemId: "id-of-certificate" - ... - } - ``` - - - To create an alert, make an API request to the [Create Alert](/api-reference/endpoints/pki-alerts/create) API endpoint, specifying the PKI/Certificate collection to bind the alert to, the alert configuration, and the email addresses to notify. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/alerts' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "projectId": "", - "pkiCollectionId": "", - "name": "My Alert", - "alertBeforeDays": 30, - "emails": ["johndoe@gmail.com", "janedoe@gmail.com"] - }' - ``` - - ### Sample response - - ```bash Response - { - id: "", - name: "My Alert", - alertBeforeDays: 30, - recipientEmails: "johndoe@gmail.com,janedoe@gmail.com" - ... - } - ``` - - Great! You've successfully created a PKI/Certificate collection and an alert to monitor the expiring certificate in the collection. Once the alert triggers, the specified email addresses will be notified. - - - - - +- Alert Type: The type of alert to create such as **Certificate Expiration**. +- Alert Name: A slug-friendly name for the alert such as `tls-expiry-alert`. +- Description: An optional description for the alert. +- Alert Before: The time before certificate expiration to trigger the alert such as 30 days denoted by `30d`. +- Filters: A list of filters that determine which certificates the alert applies to. Each row includes a **Field**, **Operator**, and **Value** to match against. For example, you can filter for certificates with a common name containing `example.com` by setting the field to **Common Name**, the operator to **Contains**, and the value to `example.com`. +- Channels / Email Recipients: A list of email addresses to notify when the alert triggers. diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/ca/acme-ca.mdx similarity index 97% rename from docs/documentation/platform/pki/acme-ca.mdx rename to docs/documentation/platform/pki/ca/acme-ca.mdx index bf130da9e..76f5cdc8f 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/ca/acme-ca.mdx @@ -1,5 +1,5 @@ --- -title: "Certificates with ACME CA" +title: "ACME-compatible CA" description: "Learn how to automatically provision and manage TLS certificates using ACME Certificate Authorities like Let's Encrypt with Infisical PKI" --- @@ -255,8 +255,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte The issued certificate and private key are now available through Infisical and can be: - Downloaded directly from the Infisical UI - - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) + - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) + ## Example: Let's Encrypt Integration @@ -264,19 +265,23 @@ In the following steps, we explore how to set up ACME Certificate Authority inte Let's Encrypt is a free, automated, and open Certificate Authority that provides domain-validated SSL/TLS certificates. Here's how the integration works with Infisical: ### Production Environment + - **Directory URL**: `https://acme-v02.api.letsencrypt.org/directory` - **Rate Limits**: 50 certificates per registered domain per week - **Certificate Validity**: 90 days with automatic renewal - **Trusted By**: All major browsers and operating systems ### Staging Environment (for testing) + - **Directory URL**: `https://acme-staging-v02.api.letsencrypt.org/directory` - **Rate Limits**: Much higher limits for testing - **Certificate Validity**: 90 days (not trusted by browsers) - **Use Case**: Testing your ACME integration without hitting production rate limits - Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. + Always test your ACME integration using Let's Encrypt's staging environment + first. This allows you to verify your DNS configuration and certificate + issuance process without consuming your production rate limits. ## Example: DigiCert Integration @@ -289,7 +294,9 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - **Trusted By**: All major browsers and operating systems. - When integrating with DigiCert ACME, ensure you have obtained the necessary External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your DigiCert account. + When integrating with DigiCert ACME, ensure you have obtained the necessary + External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your + DigiCert account. ## FAQ @@ -303,11 +310,13 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Can be fully automated without manual intervention Support for additional DNS providers is planned for future releases. + Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. + Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: @@ -317,6 +326,7 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Ensure systems stay up-to-date with certificate management practices When configured, Infisical automatically handles certificate renewal for subscribers. + Yes! You can register multiple ACME CAs in the same project: @@ -326,5 +336,6 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Backup providers for redundancy Each subscriber can be configured to use a specific ACME CA based on your requirements. + diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/ca/azure-adcs.mdx similarity index 64% rename from docs/documentation/platform/pki/azure-adcs.mdx rename to docs/documentation/platform/pki/ca/azure-adcs.mdx index 23df3ed3b..3bcb4b912 100644 --- a/docs/documentation/platform/pki/azure-adcs.mdx +++ b/docs/documentation/platform/pki/ca/azure-adcs.mdx @@ -1,5 +1,5 @@ --- -title: "Certificates with Azure ADCS" +title: "Microsoft AD CS" description: "Learn how to issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) with Infisical." --- @@ -10,7 +10,7 @@ Issue and manage certificates using Microsoft Active Directory Certificate Servi Before setting up ADCS integration, ensure you have: - Microsoft Active Directory Certificate Services (ADCS) server running and accessible -- Domain administrator account with certificate management permissions +- Domain administrator account with certificate management permissions - ADCS web enrollment enabled on your server - Network connectivity from Infisical to the ADCS server - **IP whitelisting**: Your ADCS server must allow connections from Infisical's IP addresses @@ -24,67 +24,64 @@ This section walks you through the complete end-to-end process of setting up Azu - In your Infisical project, go to your **Certificate Project** β†’ **Certificate Authority** to access the external CAs page. - - ![External CA Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) + In your Infisical project, go to your **Certificate Project** β†’ + **Certificate Authority** to access the external CAs page. ![External CA + Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) - - - Click **Create CA** and configure: - - **Type**: Choose **Active Directory Certificate Services (AD CS)** - - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - - **App Connection**: Choose your ADCS connection from the dropdown - - ![External CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) - - - - Once created, your Azure ADCS Certificate Authority will appear in the list and be ready for use. - - ![External CA Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) - - - - Go to **Subscribers** to access the subscribers page. - - ![Subscribers Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) - - - - Click **Add Subscriber** and configure: - - **Name**: Unique subscriber name (e.g., "web-server-certs") - - **Certificate Authority**: Select your ADCS CA - - **Common Name**: Certificate CN (e.g., "api.example.com") - - **Certificate Template**: Select from dynamically loaded ADCS templates - - **Subject Alternative Names**: DNS names, IP addresses, or email addresses - - **TTL**: Certificate validity period (e.g., "1y" for 1 year) - - **Additional Subject Fields**: Organization, OU, locality, state, country, email (if required by template) - - ![Subscribers Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) - - - - Your subscriber is now created and ready to issue certificates. - - ![Subscriber Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) - - - - Click into your subscriber and click **Order Certificate** to generate a new certificate using your ADCS template. - - ![Issue New Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) - - - - Your certificate has been successfully issued by the ADCS server and is ready for use. - - ![Certificate Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) - - + + + Click **Create CA** and configure: - **Type**: Choose **Active Directory + Certificate Services (AD CS)** - **Name**: Friendly name for this CA (e.g., + "Production ADCS CA") - **App Connection**: Choose your ADCS connection from + the dropdown ![External CA + Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) + + + + Once created, your Azure ADCS Certificate Authority will appear in the list + and be ready for use. ![External CA + Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) + + + + Go to **Subscribers** to access the subscribers page. ![Subscribers + Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) + + + + Click **Add Subscriber** and configure: - **Name**: Unique subscriber name + (e.g., "web-server-certs") - **Certificate Authority**: Select your ADCS CA - + **Common Name**: Certificate CN (e.g., "api.example.com") - **Certificate + Template**: Select from dynamically loaded ADCS templates - **Subject + Alternative Names**: DNS names, IP addresses, or email addresses - **TTL**: + Certificate validity period (e.g., "1y" for 1 year) - **Additional Subject + Fields**: Organization, OU, locality, state, country, email (if required by + template) ![Subscribers + Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) + + + + Your subscriber is now created and ready to issue certificates. ![Subscriber + Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) + + + + Click into your subscriber and click **Order Certificate** to generate a new + certificate using your ADCS template. ![Issue New + Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) + + + + Your certificate has been successfully issued by the ADCS server and is ready + for use. ![Certificate + Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) + + - Navigate to **Certificates** to view detailed information about all issued certificates, including expiration dates, serial numbers, and certificate chains. - - ![Certificates Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) + Navigate to **Certificates** to view detailed information about all issued + certificates, including expiration dates, serial numbers, and certificate + chains. ![Certificates + Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) @@ -95,6 +92,7 @@ Infisical automatically retrieves available certificate templates from your ADCS ### Common Template Types ADCS templates you might see include: + - **Web Server**: For SSL/TLS certificates with server authentication - **Computer**: For machine authentication certificates - **User**: For client authentication certificates @@ -106,13 +104,16 @@ ADCS templates you might see include: ### Template Requirements Ensure your ADCS templates are configured with: + - **Enroll permissions** for your connection account - **Auto-enroll permissions** if using automated workflows - **Subject name requirements** matching your certificate requests - **Key usage extensions** appropriate for your use case -**Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. + **Dynamic Template Discovery**: Infisical queries your ADCS server in + real-time to populate available templates. Only templates you have permission + to use will be displayed during certificate issuance. ## Certificate Issuance Limitations @@ -120,10 +121,13 @@ Ensure your ADCS templates are configured with: ### Immediate Issuance Only -**Manual Approval Not Supported**: Infisical currently supports only **immediate certificate issuance**. Certificates that require manual approval or are held by ADCS policies cannot be issued through Infisical yet. + **Manual Approval Not Supported**: Infisical currently supports only + **immediate certificate issuance**. Certificates that require manual approval + or are held by ADCS policies cannot be issued through Infisical yet. For successful certificate issuance, ensure your ADCS templates and policies are configured to: + - **Auto-approve** certificate requests without manual intervention - **Not require** administrator approval for the templates you plan to use - **Allow** the connection account to request and receive certificates immediately @@ -131,19 +135,22 @@ For successful certificate issuance, ensure your ADCS templates and policies are ### What Happens with Manual Approval If a certificate request requires manual approval: + 1. The request will be submitted to ADCS successfully 2. Infisical will attempt to retrieve the certificate with exponential backoff (up to 5 retries over ~1 minute) 3. If the certificate is not approved within this timeframe, the request will **fail** 4. **No background polling**: Currently, Infisical does not check for certificates that might be approved hours or days later -**Future Enhancement**: Background polling for delayed certificate approvals is planned for future releases. + **Future Enhancement**: Background polling for delayed certificate approvals + is planned for future releases. ### Certificate Revocation -Certificate revocation is **not supported** by the Azure ADCS connector due to security and complexity considerations. + Certificate revocation is **not supported** by the Azure ADCS connector due to + security and complexity considerations. ## Advanced Configuration @@ -166,28 +173,33 @@ This allows Infisical to control certificate expiration dates directly. ### Common Issues **Certificate Request Denied** + - Verify ADCS template permissions for your connection account - Check template subject name requirements - Ensure template allows the requested key algorithm and size **Revocation Service Unavailable** + - Verify IIS is running and the revocation endpoint is accessible - Check IIS application pool permissions - Test endpoint connectivity from Infisical **Template Not Found** + - Verify template exists on ADCS server and is published - Check that your connection account has enrollment permissions for the template - Ensure the template is properly configured and available in the ADCS web enrollment interface - Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear **Certificate Request Pending/Timeout** + - Check if your ADCS template requires manual approval - Infisical only supports immediate issuance - Verify the certificate template is configured for auto-approval - Ensure your connection account has sufficient permissions to request certificates without approval - Review ADCS server policies that might be holding the certificate request **Network Connectivity Issues** + - Verify your ADCS server's firewall allows connections from Infisical - For Infisical Cloud: Ensure Infisical's IP addresses are whitelisted (see [Networking Configuration](/documentation/setup/networking)) - For self-hosted: Whitelist your Infisical server's IP address on the ADCS server @@ -195,11 +207,13 @@ This allows Infisical to control certificate expiration dates directly. - Check for any network security appliances blocking the connection **Authentication Failures** + - Verify ADCS connection credentials - Check domain account permissions - Ensure network connectivity to ADCS server **SSL/TLS Certificate Errors** + - For ADCS servers with self-signed or private certificates: disable "Reject Unauthorized" in the SSL tab of your Azure ADCS app connection, or provide the certificate in PEM format - Common SSL errors: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED` - The SSL configuration applies to all HTTPS communications between Infisical and your ADCS server diff --git a/docs/documentation/platform/pki/ca/external-ca.mdx b/docs/documentation/platform/pki/ca/external-ca.mdx new file mode 100644 index 000000000..1dc89ec96 --- /dev/null +++ b/docs/documentation/platform/pki/ca/external-ca.mdx @@ -0,0 +1,50 @@ +--- +title: "External CA" +sidebarTitle: "External CA" +description: "Learn how to connect External Certificate Authorities with Infisical." +--- + +## Concept + +Infisical lets you integrate with External Certificate Authorities (CAs), allowing you to use existing PKI infrastructure or connect to public CAs to issue digital certificates for your end-entities. + +
+ +```mermaid +graph TD + A1[External Public CA
e.g. Let's Encrypt, ZeroSSL, ...] --> Infisical + A2[External Private CA
e.g. AWS Private CA, HashiCorp Vault PKI, ...] --> Infisical +``` + +
+ +As shown above, these CAs commonly fall under two categories: + +- External Private CAs: CAs like AWS Private CA, HashiCorp Vault PKI, Azure ADCS, etc. that are privately owned and are used to issue certificates for internal services; these are often either cloud-hosted private CAs or on-prem / enterprise CAs. +- External Public CAs: CAs like Let's Encrypt, DigiCert, GlobalSign, etc. that are publicly trusted and are used to issue certificates for public-facing services. + +Note that Infisical can also act as an _ACME client_, allowing you to integrate upstream with any ACME-compatible CA to automate certificate issuance and renewal. + +## Workflow + +A typical workflow for integrating an External CA with Infisical consists of choosing the desired External CA type +and specifying the configuration or connection details necessary to connect to the CA. + +The specific steps and requirements vary depending on the External CA type you choose to integrate. + +## Supported External CA Types + +Infisical currently supports the following External CA types out of the box: + +- [ACME CA](/documentation/platform/pki/ca/acme-ca): An ACME-compatible CA that supports the ACME protocol, such as Let's Encrypt, ZeroSSL, Buypass, Digicert, etc. +- [Azure ADCS](/documentation/platform/pki/ca/azure-adcs): A Microsoft Active Directory Certificate Services (ADCS) that supports the ADCS protocol, such as AWS Private CA, Azure ADCS, etc. + +If you don’t see a specific external CA listed here or need a dedicated integration guide, please reach out to sales@infisical.com and we’ll help you set up the integration for your external CA. + +## FAQ + + + + Yes. You can have both Private and External CAs in the same project. + + diff --git a/docs/documentation/platform/pki/ca/overview.mdx b/docs/documentation/platform/pki/ca/overview.mdx new file mode 100644 index 000000000..9a815992a --- /dev/null +++ b/docs/documentation/platform/pki/ca/overview.mdx @@ -0,0 +1,13 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +Before issuing and managing certificates with Infisical, you'll need to configure a Certificate Authority (CA). + +This is the trusted entity that signs and validates the X.509 certificates used to secure your end-entities. + +Infisical supports two categories of CAs: + +- [Internal CA](/documentation/platform/pki/ca/private-ca): Internally operated root and intermediate CAs managed within Infisical. This is useful if you need complete control over your PKI and are issuing certificates for private networks, internal services, or managed devices. +- [External CA](/documentation/platform/pki/ca/external-ca): Third-party public (e.g. Let's Encrypt, DigiCert) or private (e.g. AWS Private CA, HashiCorp Vault PKI, etc.) CAs that can be integrated with Infisical. This is useful if you want to leverage existing PKI infrastructure or issue publicly trusted certificates. diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/ca/private-ca.mdx similarity index 92% rename from docs/documentation/platform/pki/private-ca.mdx rename to docs/documentation/platform/pki/ca/private-ca.mdx index 7d7ee1220..74913d4cc 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/ca/private-ca.mdx @@ -1,13 +1,12 @@ --- -title: "Private CA" -sidebarTitle: "Private CA" +title: "Internal CA" +sidebarTitle: "Internal CA" description: "Learn how to create a Private CA hierarchy with Infisical." --- ## Concept -The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities -used to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers). +Infisical lets you build your Internal PKI through a Private Certificate Authority (CA) hierarchy, enabling you to issue and manage digital certificates for your end-entities.
@@ -47,7 +46,7 @@ consisting of an (optional) root CA and an intermediate CA. If you wish to use an external root CA, you can skip this step and head to step 2 to create an intermediate CA. - To create a root CA, head to your Project > Internal PKI > Certificate Authorities and press **Create CA**. + To create a root CA, head to your Certificate Management Project > Certificate Authorities > Internal Certificate Authorities and press **Create CA**. ![pki create ca](/images/platform/pki/ca/ca-create.png) @@ -55,18 +54,17 @@ consisting of an (optional) root CA and an intermediate CA. ![pki create root ca](/images/platform/pki/ca/ca-create-root.png) - Here's some guidance on each field: + Here's some guidance for each field: - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. - Path Length: The maximum number of intermediate CAs that can be chained to this CA. A path of `-1` implies no limit; a path of `0` implies no intermediate CAs can be chained. - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair that the CA creates when it issues a certificate. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. - - Friendly Name: A friendly name for the CA; this is only for display and defaults to the subject of the CA if left empty. + - Name: A slug-friendly name for the CA. - Organization (O): The organization name. - Country (C): The country code. - State or Province Name: The state or province. - Locality Name: The city or locality. - Common Name: The name of the CA. - - Require Template for Certificate Issuance: Whether or not certificates for this CA can only be issued through certificate templates (recommended). The Organization, Country, State or Province Name, Locality Name, and Common Name make up the **Distinguished Name (DN)** or **subject** of the CA. @@ -98,8 +96,7 @@ consisting of an (optional) root CA and an intermediate CA. ![pki cas](/images/platform/pki/ca/cas.png) - Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. - Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. + Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the [Certificates section](/documentation/platform/pki/certificates/overview) to learn more about how to issue X.509 certificates using the intermediate CA. 2.3b. If you have an external root CA, select **External CA** for the **Parent CA Type** field. @@ -110,7 +107,7 @@ consisting of an (optional) root CA and an intermediate CA. Finally, press **Install** to import the certificate and certificate chain as part of the installation step for the intermediate CA Great! You've successfully created a Private CA hierarchy with an intermediate CA chained to an external root CA. - Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. + Now check out the [Certificates section](/documentation/platform/pki/certificates/overview) to learn more about how to issue X.509 certificates using the intermediate CA. diff --git a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx index de064bf4e..e33f46f3e 100644 --- a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx @@ -5,82 +5,87 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync **Prerequisites:** -- Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) - Create an [AWS Connection](/integrations/app-connections/aws) The AWS Certificate Manager Certificate Sync requires the following ACM permissions to be set on the IAM user/role for Infisical to sync certificates to AWS Certificate Manager: `acm:ListCertificates`, `acm:DescribeCertificate`, `acm:ImportCertificate`, `acm:DeleteCertificate`, and `acm:ListTagsForCertificate`. - These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. +These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. + - Certificates synced to AWS Certificate Manager will be stored as imported certificates, preserving both the certificate and private key components. + Certificates synced to AWS Certificate Manager will be stored as imported + certificates, preserving both the certificate and private key components. - 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. - ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + 1. Navigate to **Project** > **Integrations** > **Certificate Syncs** and press **Add Sync**. + ![Certificate Syncs Tab](/images/platform/pki/certificate-syncs/general/create-certificate-sync.png) 2. Select the **AWS Certificate Manager** option. - ![Select ACM](/images/certificate-syncs/aws-certificate-manager/select-acm-option.png) + ![Select ACM](/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png) - 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. - ![Configure Source](/images/certificate-syncs/aws-certificate-manager/acm-source.png) + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png) - - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + - **AWS Connection**: The AWS Connection to authenticate with. + - **AWS Region**: The AWS region where certificates should be stored. - 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. - ![Configure Destination](/images/certificate-syncs/aws-certificate-manager/acm-destination.png) + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png) - - **AWS Connection**: The AWS Connection to authenticate with. - - **AWS Region**: The AWS region where certificates should be stored. + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Preserve ARN on Renewal**: If enabled, Infisical will sync renewed certificates to the destination under the same ARN as the original synced certificate instead of creating a new certificate with a new ARN. + - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. - 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. - ![Configure Options](/images/certificate-syncs/aws-certificate-manager/acm-options.png) - - - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. - - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. - - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. - - - **AWS Certificate Manager Certificate Limits**: AWS Certificate Manager has limits on the number of certificates per account and region. Refer to AWS documentation for current limits. Deleted certificates count toward your quota until they are permanently purged by AWS (typically after 30 days). - - - 6. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. - ![Configure Details](/images/certificate-syncs/aws-certificate-manager/acm-details.png) + 5. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png) - **Name**: The name of your sync. Must be slug-friendly. - **Description**: An optional description for your sync. + 6. Select which certificates should be synced to AWS Certificate Manager. + ![Select Certificates](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png) + 7. Review your AWS Certificate Manager Certificate Sync configuration, then click **Create Sync**. - ![Confirm Configuration](/images/certificate-syncs/aws-certificate-manager/acm-review.png) + ![Confirm Configuration](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png) 8. If enabled, your AWS Certificate Manager Certificate Sync will begin syncing your certificates to the destination endpoint. - ![Sync Certificates](/images/certificate-syncs/aws-certificate-manager/acm-synced.png) - + ![Sync Certificates](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png) To create an **AWS Certificate Manager Certificate Sync**, make an API request to the [Create AWS Certificate Manager Certificate Sync](/api-reference/endpoints/pki/syncs/aws-certificate-manager/create) API endpoint. ### Sample request + + You can optionally specify `certificateIds` during sync creation to immediately add certificates to the sync. + If not provided, you can add certificates later using the certificate management endpoints. + + ```bash Request curl --request POST \ --url https://app.infisical.com/api/v1/pki/syncs/aws-certificate-manager \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "name": "my-acm-cert-sync", "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "description": "an example certificate sync", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "destination": "aws-certificate-manager", "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], "syncOptions": { "canRemoveCertificates": true, + "preserveArnOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -104,10 +109,10 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync }, "syncOptions": { "canRemoveCertificates": true, + "preserveArnOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-01-01T00:00:00.000Z", "updatedAt": "2023-01-01T00:00:00.000Z" @@ -115,24 +120,27 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync } ``` + ## Certificate Management Your AWS Certificate Manager Certificate Sync will: -- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to AWS Certificate Manager -- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur -- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled) +- **Automatic Deployment**: Deploy certificates in Infisical to AWS Certificate Manager. +- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur. +- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled). - **Tagging**: Automatically tag certificates with an InfisicalCertificate tag for easy identification and management - AWS Certificate Manager Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + AWS Certificate Manager Certificate Syncs support both automatic and manual + synchronization modes. When auto-sync is enabled, certificates are + automatically deployed as they are issued or renewed. ## Manual Certificate Sync -You can manually trigger certificate synchronization from your PKI subscriber to AWS Certificate Manager using the sync certificates functionality. This is useful for: +You can manually trigger certificate synchronization to AWS Certificate Manager using the sync certificates functionality. This is useful for: - Initial setup when you have existing certificates to deploy - One-time sync of specific certificates @@ -142,5 +150,8 @@ You can manually trigger certificate synchronization from your PKI subscriber to To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates) API endpoint or the manual sync option in the Infisical UI. -AWS Certificate Manager does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from AWS Certificate Manager. Only certificates imported into ACM (not AWS-issued certificates) can be managed by the sync. - \ No newline at end of file + AWS Certificate Manager does not support importing certificates back into + Infisical due to security limitations where private keys cannot be extracted + from AWS Certificate Manager. Only certificates imported into ACM (not + AWS-issued certificates) can be managed by the sync. + diff --git a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx index 6190ab3c3..cfdbfe136 100644 --- a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx @@ -5,47 +5,43 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf **Prerequisites:** - - Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) - - Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) - - Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. +- Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) +- Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. The Azure Key Vault Certificate Sync requires the following certificate permissions to be set on the user / service principal for Infisical to sync certificates to Azure Key Vault: `certificates/list`, `certificates/get`, `certificates/import`, `certificates/delete`. - Any role with these permissions would work such as the **Key Vault Certificates Officer** role. +Any role with these permissions would work such as the **Key Vault Certificates Officer** role. + - Certificates synced to Azure Key Vault will be stored as certificate objects, preserving both the certificate and private key components. + Certificates synced to Azure Key Vault will be stored as certificate objects, + preserving both the certificate and private key components. - 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. - ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + 1. Navigate to **Project** > **Integrations** > **Certificate Syncs** and press **Add Sync**. + ![Certificate Syncs Tab](/images/platform/pki/certificate-syncs/general/create-certificate-sync.png) 2. Select the **Azure Key Vault** option. - ![Select Key Vault](/images/certificate-syncs/azure-key-vault/select-key-vault-option.png) + ![Select Key Vault](/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png) - 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. - ![Configure Source](/images/certificate-syncs/azure-key-vault/vault-source.png) - - - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. - - 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. - ![Configure Destination](/images/certificate-syncs/azure-key-vault/vault-destination.png) + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png) - **Azure Connection**: The Azure Connection to authenticate with. - **Vault Base URL**: The URL of your Azure Key Vault. -

- 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. - ![Configure Options](/images/certificate-syncs/azure-key-vault/vault-options.png) + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png) - - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. - - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Enable Versioning on Renewal**: If enabled, Infisical will sync renewed certificates to the destination under a new version of the original synced certificate instead of creating a new certificate. - **Certificate Name Schema** (Optional): Customize how certificate names are generated in Azure Key Vault. Use `{{certificateId}}` as a placeholder for the certificate ID. If not specified, defaults to `Infisical-{{certificateId}}`. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. **Azure Key Vault Soft Delete**: When certificates are removed from Azure Key Vault, they are placed in a soft-deleted state rather than being permanently deleted. This means: @@ -53,38 +49,50 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf - To resync removed certificates, you must either manually **purge** them from Azure Key Vault or **recover** them through the Azure portal/CLI - 6. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. - ![Configure Details](/images/certificate-syncs/azure-key-vault/vault-details.png) + 5. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png) - **Name**: The name of your sync. Must be slug-friendly. - **Description**: An optional description for your sync. + 6. Select which certificates should be synced to Azure Key Vault. + ![Select Certificates](/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png) + 7. Review your Azure Key Vault Certificate Sync configuration, then click **Create Sync**. - ![Confirm Configuration](/images/certificate-syncs/azure-key-vault/vault-review.png) + ![Confirm Configuration](/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png) 8. If enabled, your Azure Key Vault Certificate Sync will begin syncing your certificates to the destination endpoint. - ![Sync Certificates](/images/certificate-syncs/azure-key-vault/vault-synced.png) - + ![Sync Certificates](/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png) To create an **Azure Key Vault Certificate Sync**, make an API request to the [Create Azure Key Vault Certificate Sync](/api-reference/endpoints/pki/syncs/azure-key-vault/create) API endpoint. ### Sample request + + You can optionally specify `certificateIds` during sync creation to immediately add certificates to the sync. + If not provided, you can add certificates later using the certificate management endpoints. + + ```bash Request curl --request POST \ --url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "name": "my-key-vault-cert-sync", "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "description": "an example certificate sync", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "destination": "azure-key-vault", "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], "syncOptions": { "canRemoveCertificates": true, + "enableVersioningOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -108,10 +116,10 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf }, "syncOptions": { "canRemoveCertificates": true, + "enableVersioningOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-01-01T00:00:00.000Z", "updatedAt": "2023-01-01T00:00:00.000Z" @@ -119,24 +127,27 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf } ``` + ## Certificate Management Your Azure Key Vault Certificate Sync will: -- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to Azure Key Vault +- **Automatic Deployment**: Deploy certificates in Infisical to Azure Key Vault. - **Certificate Updates**: Update certificates in Azure Key Vault when renewals occur - **Expiration Handling**: Optionally remove expired certificates from Azure Key Vault (if enabled) - **Format Preservation**: Maintain certificate format and metadata during sync operations - Azure Key Vault Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + Azure Key Vault Certificate Syncs support both automatic and manual + synchronization modes. When auto-sync is enabled, certificates are + automatically deployed as they are issued or renewed. ## Manual Certificate Sync -You can manually trigger certificate synchronization from your PKI subscriber to Azure Key Vault using the sync certificates functionality. This is useful for: +You can manually trigger certificate synchronization to Azure Key Vault using the sync certificates functionality. This is useful for: - Initial setup when you have existing certificates to deploy - One-time sync of specific certificates @@ -146,5 +157,7 @@ You can manually trigger certificate synchronization from your PKI subscriber to To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates) API endpoint or the manual sync option in the Infisical UI. -Azure Key Vault does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from Azure Key Vault. - \ No newline at end of file + Azure Key Vault does not support importing certificates back into Infisical + due to security limitations where private keys cannot be extracted from Azure + Key Vault. + diff --git a/docs/documentation/platform/pki/certificate-syncs/overview.mdx b/docs/documentation/platform/pki/certificate-syncs/overview.mdx index db4844a92..d7931d893 100644 --- a/docs/documentation/platform/pki/certificate-syncs/overview.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/overview.mdx @@ -3,17 +3,19 @@ sidebarTitle: "Overview" description: "Learn how to sync certificates from Infisical PKI to third-party services." --- -Certificate Syncs enable you to sync certificates from Infisical PKI to third-party services using [App Connections](/integrations/app-connections/overview). +Certificate Syncs enable you to push certificates from Infisical to third-party services using [App Connections](/integrations/app-connections/overview). - Certificate Syncs are designed to automatically deploy certificates issued by your Certificate Authority to external services, ensuring your certificates are always up-to-date across your infrastructure. + Certificate Syncs are designed to automatically deploy certificates issued by + your Certificate Authority to external services, ensuring your certificates + are always up-to-date across your infrastructure. ## Concept -Certificate Syncs are a project-level resource used to sync certificates, via an [App Connection](/integrations/app-connections/overview), from a particular PKI subscriber (source) -to a third-party service (destination). When new certificates are issued or existing certificates are renewed, changes will automatically be propagated to the destination, ensuring -your certificates are always current. +Certificate Syncs are a project-level resource used to push certificates, via an [App Connection](/integrations/app-connections/overview), from Infisical +to a third-party service (destination). When paired with [server-side auto-renewal](/documentation/platform/pki/certificates/certificates#server-driven-certificate-renewal), renewed certificates are automatically synced to the destination, +ensuring your certificates stay current.
@@ -31,17 +33,15 @@ your certificates are always current. G[Certificate 1] H[Certificate 2] I[Certificate 3] - J[PKI Subscriber] B --> A - C --> J - D --> J - E --> J + C --> B + D --> B + E --> B A --> F F --> G F --> H F --> I - J --> B classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px @@ -61,39 +61,50 @@ your certificates are always current. ## Workflow -Configuring a Certificate Sync requires three components: a source PKI subscriber to retrieve certificates from, +Configuring a Certificate Sync requires three components: The certificates that you'd like to push, a destination endpoint to deploy certificates to, and configuration options to determine how your certificates should be synced. Follow these steps to start syncing: - For step-by-step guides on syncing to a particular third-party service, refer to the Certificate Syncs section in the Navigation Bar. + For step-by-step guides on syncing to a particular third-party service, refer + to the Certificate Syncs section in the Navigation Bar. -1. Create App Connection: If you have not already done so, create an [App Connection](/integrations/app-connections/overview) -via the UI or API for the third-party service you intend to sync certificates to. +1. Create App Connection: If you have not already done so, create + an [App Connection](/integrations/app-connections/overview) via the UI or API + for the third-party service you intend to sync certificates to. -2. Create Certificate Sync: Configure a Certificate Sync in the desired project by specifying the following parameters via the UI or API: - - Source: The PKI subscriber you wish to retrieve certificates from. - - Destination: The App Connection to utilize and the destination endpoint to deploy certificates to. These can vary between services. - - Options: Customize how certificates should be synced, including: - - Whether certificates should be removed from the destination when they expire - - Certificate naming schema to control how certificate names are generated in the destination +2. Create Certificate Sync: Configure a Certificate Sync in the + desired project by specifying the following parameters via the UI or API: + + - Destination: The App Connection to utilize and the destination + endpoint to deploy certificates to such as [AWS Certificate Manager](/documentation/platform/pki/certificate-syncs/aws-certificate-manager) + or [Azure Key Vault](/documentation/platform/pki/certificate-syncs/azure-key-vault). + - Certificates: The certificates you wish to push to the destination. + - Options: Customize how certificates should be synced, including: + - Whether certificates should be removed from the destination when they expire. + - Certificate naming schema to control how certificate names are generated in + the destination. - Only certificates managed by Infisical will be affected during sync operations. Certificates not created or - managed by Infisical will remain untouched, and changes made to Infisical-managed certificates directly - in the destination service may be overwritten by future syncs. + Only certificates managed by Infisical will be affected during sync + operations. Certificates not created or managed by Infisical will remain + untouched, and changes made to Infisical-managed certificates directly in the + destination service may be overwritten by future syncs. - Some third-party services do not support removing expired certificates automatically. + Some third-party services do not support removing expired certificates + automatically. -3. Utilize Sync: Any new certificates issued or renewals from the source PKI subscriber will now automatically be propagated to the destination endpoint. +3. Utilize Sync: Selected certificates will now be pushed to the + destination endpoint and automatically redeployed whenever they are renewed. - Infisical is continuously expanding its Certificate Sync third-party service support. If the service you need isn't available, - contact us at team@infisical.com to make a request. + Infisical is continuously expanding its Certificate Sync third-party service + support. If the service you need isn't available, contact us at + team@infisical.com to make a request. ## Certificate Naming @@ -111,32 +122,12 @@ You can customize certificate naming by providing a **Certificate Name Schema** - `{{certificateId}}` - The unique certificate identifier (required) **Examples:** + - `myapp-{{certificateId}}` β†’ `myapp-abc123def456` - `ssl/{{certificateId}}` β†’ `ssl/abc123def456` **Rules:** + - Must include exactly one `{{certificateId}}` placeholder -- Only alphanumeric characters, dashes (-), underscores (_), and slashes (/) are allowed +- Only alphanumeric characters, dashes (-), underscores (\_), and slashes (/) are allowed - Certificate names matching your schema will be managed by Infisical during sync operations - -## Certificate Management - -Certificate Syncs handle the full lifecycle of certificate management: - -- **Automatic Deployment**: New certificates are automatically deployed to configured destinations -- **Renewal Propagation**: Certificate renewals are seamlessly pushed to all connected services -- **Expiration Handling**: Expired certificates can be automatically removed from destinations (service-dependent) -- **Certificate Validation**: Certificates are validated before deployment to ensure integrity - -

- ```mermaid - graph LR - A[Certificate Issued] -->|Deploy| B[Destination Service] - C[Certificate Renewed] -->|Update| B - D[Certificate Expired] -->|Remove| B - style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px - style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px - style C fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px - style D fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px - ``` -
\ No newline at end of file diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index f1c434e9c..de8de4541 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -109,49 +109,126 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year. - To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint, specifying the issuing CA. + To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates-v2/create) API endpoint, specifying the issuing CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificate-templates' \ + curl --request POST \ + --url https://us.infisical.com/api/v2/certificate-templates \ --header 'Content-Type: application/json' \ - --data-raw '{ - "caId": "", - "name": "My Certificate Template", - "commonName": ".*.acme.com", - "subjectAlternativeName": ".*.acme.com", - "ttl": "1y", - }' + --data '{ + "projectId": "", + "name": "", + "description": "", + "subject": [ + { + "type": "common_name", + "allowed": [ + "*.infisical.com" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*.sample.com" + ] + } + ], + "keyUsages": { + "allowed": [ + "digital_signature" + ] + }, + "extendedKeyUsages": { + "allowed": [ + "client_auth" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA" + ], + "keyAlgorithm": [ + "RSA-2048" + ] + }, + "validity": { + "max": "365d" + } + }' ``` ### Sample response ```bash Response { - id: "...", - caId: "...", - name: "...", - commonName: "...", - subjectAlternativeName: "...", - ttl: "...", + "certificateTemplate": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "", + "description": "", + "subject": [ + { + "type": "common_name", + "allowed": [ + "*.infisical.com" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*.sample.com" + ] + } + ], + "keyUsages": { + "allowed": [ + "digital_signature" + ] + }, + "extendedKeyUsages": { + "allowed": [ + "client_auth" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA" + ], + "keyAlgorithm": [ + "RSA-2048" + ] + }, + "validity": { + "max": "365d" + }, + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z" + } } ``` - To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint, + To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint, specifying the issuing CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ --header 'Content-Type: application/json' \ --data-raw '{ - "certificateTemplateId": "", + "profileId": "", "commonName": "service.acme.com", "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048" }' ``` @@ -221,16 +298,16 @@ In the following steps, we explore how to revoke a X.509 certificate under a CA selecting the **Revoke Certificate** option for it and specifying the reason for revocation. - ![pki revoke certificate](/images/platform/pki/cert-revoke.png) + ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) - ![pki revoke certificate modal](/images/platform/pki/cert-revoke-modal.png) + ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) In order to check the revocation status of a certificate, you can check it against the CRL of a CA by heading to its Issuing CA and downloading the CRL. - ![pki view crl](/images/platform/pki/ca-crl.png) + ![pki view crl](/images/platform/pki/ca/ca-crl.png) To verify a certificate against the downloaded CRL with OpenSSL, you can use the following command: @@ -254,7 +331,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem - Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, specifying the serial number of the certificate and the reason for revocation. ### Sample request @@ -280,7 +357,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crls) API endpoint. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. ### Sample request diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx new file mode 100644 index 000000000..05703beee --- /dev/null +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -0,0 +1,265 @@ +--- +title: "Certificates" +sidebarTitle: "Certificates" +--- + + + PKI architecture is a complex topic and there are many ways to orchestrate + certificate management including renewal operations. For specific guidance and + access to enterprise features, we recommend reaching out to + sales@infisical.com to schedule a demo. + + +## Concept + +A certificate is the (X.509) leaf certificate issued for a certificate profile. + +Once issued, a certificate is kept track of in the certificate inventory +where you can manage various aspects of its lifecycle including deployment to cloud key stores, server-side auto-renewal behavior, revocation, and more. + +## Guide to Issuing Certificates + +To issue a certificate, you must first create a [certificate profile](/documentation/platform/pki/certificates/profiles) and a [certificate template](/documentation/platform/pki/certificates/templates) to go along with it. + +The [enrollment method](/documentation/platform/pki/enrollment-methods/overview) configured on the certificate profile determines how a certificate is issued for it. +Refer to the documentation for each enrollment method below to learn more about how to issue certificates using it. + +- [API](/documentation/platform/pki/enrollment-methods/api): Issue a certificate over UI or by making an API request to Infisical. +- [EST](/documentation/platform/pki/enrollment-methods/est): Issue a certificate over the EST protocol. + +## Guide to Renewing Certificates + +To [renew a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), you can either request a new certificate from a certificate profile or have the platform +automatically request a new one for you. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate +profile as well as your infrastructure use-case. + +### Client-Driven Certificate Renewal + +Client-driven certificate renewal is when renewal is initiated client-side by the end-entity consuming the certificate. +This is the most common approach to certificate renewal and is suitable for most use-cases. + +### Server-Driven Certificate Renewal + +Server-driven certificate renewal is when renewal is initiated server-side by Infisical rather than by the end-entity consuming the certificate. +When a certificate considered for auto-renewal meets a specified _renewal days before expiration_ threshold, Infisical reaches out to the issuing CA bound to the [certificate profile](/documentation/platform/pki/certificates/profiles) of the expiring certificate +to request for a new one. +The resulting renewed certificate is stored in the platform and made available to be fetched back or pushed downstream to end-entities or external systems such as cloud key stores. + +Note that server-driven certificate renewal is only available for certificates issued via the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) where key pairs are generated server-side. +A certificate can be considered for auto-renewal at time of issuance if the **Enable Auto-Renewal By Default** option is selected on its [certificate profile](/documentation/platform/pki/certificates/profiles) or after issuance by toggling this option manually. + + + For server-driven certificate renewal workflows, you can programmatically fetch the latest active certificate bundle for a certificate profile using the [Get Latest Active Certificate Bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) API endpoint. + + This ensures you always retrieve the most current valid certificate, including any that have been automatically renewed, making it particularly useful for deployment pipelines and automation workflows where you don't want to track individual serial numbers. + + +The following examples demonstrate different approaches to certificate renewal: + +- Using the ACME enrollment method, you may connect an ACME client like [certbot](https://certbot.eff.org/) to fetch back and renew certificates for Apache, Nginx, or other server. The ACME client will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to the server's configuration. +- Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets. +- Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination. + +## Guide to Exporting Certificates + +In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure. + +### Accessing the Export Certificate Modal + +To export any certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu. + +![pki export certificate option](/images/platform/pki/certificate/cert-export-option.png) + + + + + + In the export modal, choose **PEM** as the format and click **Export**. + + ![pki export certificate pem](/images/platform/pki/certificate/cert-export-pem.png) + + The PEM export modal will display the certificate details including: + - **Serial Number**: The unique identifier for the certificate + - **Certificate Body**: The X.509 certificate in PEM format + - **Certificate Chain**: The intermediate and root CA certificates + - **Private Key**: The private key associated with the certificate (if available) + + ![pki export certificate pem modal](/images/platform/pki/certificate/cert-export-pem-modal.png) + + You can copy each component individually or use the **Copy All** button to copy the complete certificate bundle. + + + PEM format certificates can be used directly with most web servers and applications: + + - **Apache HTTP Server**: Configure SSL certificates in your virtual host + - **Nginx**: Use the certificate and private key files in your server configuration + - **Docker containers**: Mount certificate files for TLS-enabled applications + - **Load balancers**: Upload PEM certificates to AWS ALB, Azure Application Gateway, etc. + + Example Nginx configuration: + ```nginx + server { + listen 443 ssl; + server_name example.com; + + ssl_certificate /path/to/certificate.pem; + ssl_certificate_key /path/to/private-key.pem; + } + ``` + + + + + + + In the export modal, choose **PKCS12** as the format and provide the required configuration: + + ![pki export certificate pkcs12](/images/platform/pki/certificate/cert-export-pkcs12.png) + + - **Password**: A secure password to protect the PKCS12 keystore + - **Alias**: A friendly name for the certificate within the keystore + + Click **Export** to generate and download the `.p12` file containing the certificate, certificate chain, and private key. + + + PKCS12 files (`.p12` extension) are binary keystore files that contain the certificate, certificate chain, and private key in a single encrypted file: + + - **Java applications**: Import directly into Java KeyStore (JKS) or use with SSL/TLS + - **Windows IIS**: Import the PKCS12 file for web server SSL configuration + - **Browser certificates**: Install client certificates for authentication + - **Mobile applications**: Deploy certificates to iOS and Android applications + + To verify the contents of a PKCS12 file: + ```bash + openssl pkcs12 -in certificate.p12 -nokeys -clcerts + ``` + + To extract the private key: + ```bash + openssl pkcs12 -in certificate.p12 -nocerts -out private-key.pem + ``` + + + If you need to convert the PKCS12 file to Java KeyStore (JKS) format for applications running on Java 8 or earlier, use the following keytool command: + + ```bash + keytool -importkeystore \ + -srckeystore certificate.p12 \ + -srcstoretype PKCS12 \ + -srcstorepass \ + -destkeystore certificate.jks \ + -deststoretype JKS \ + -deststorepass + ``` + + Replace `` with the password you used when exporting the PKCS12 file, and `` with your desired JKS keystore password. + + The resulting `.jks` file can then be used with Java applications that require JKS format keystores. + + + + + + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA. + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by + selecting the **Revoke Certificate** option for it and specifying the reason + for revocation. + + ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) + + ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by heading to its Issuing CA and downloading the CRL. + + ![pki view crl](/images/platform/pki/ca/ca-crl.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + +Note that you can also obtain the CRL from the certificate itself by +referencing the CRL distribution point extension on the certificate. + +To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: + +```bash +openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem +``` + + + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, + specifying the serial number of the certificate and the reason for revocation. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "revocationReason": "UNSPECIFIED" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully revoked certificate", + serialNumber: "...", + revokedAt: "..." + } + ``` + + + In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crls' \ + --header 'Authorization: Bearer ' + ``` + + ### Sample response + + ```bash Response + [ + { + id: "...", + crl: "..." + }, + ... + ] + ``` + + To verify a certificate against the CRL with OpenSSL, you can use the following command: + + ```bash + openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem + ``` + + + + + diff --git a/docs/documentation/platform/pki/certificates/overview.mdx b/docs/documentation/platform/pki/certificates/overview.mdx new file mode 100644 index 000000000..ea559a0df --- /dev/null +++ b/docs/documentation/platform/pki/certificates/overview.mdx @@ -0,0 +1,15 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +To issue a certificate with Infisical, you create a certificate profile and a certificate template to go along with it. You then issue a certificate against +a specific profile depending on the enrollment method associated with it. + +There are three components to understand: + +- [Certificate Profile](/documentation/platform/pki/certificates/profiles): A configuration set specifying how certificates should be issued under that profile including the [issuing CA](/documentation/platform/pki/ca/overview), a certificate template, and the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) (such as ACME, EST, API, etc.) used to enroll certificates. + +- [Certificate Template](/documentation/platform/pki/certificates/templates): A policy structure specifying the permitted attributes for requested certificates including subject naming conventions, SAN fields, key usages, and extended key usages. + +- [Certificate](/documentation/platform/pki/certificates/certificates): The actual X.509 certificate issued for a profile. Once issued, a certificate kept track of in the certificate inventory. diff --git a/docs/documentation/platform/pki/certificates/profiles.mdx b/docs/documentation/platform/pki/certificates/profiles.mdx new file mode 100644 index 000000000..ccbef89cd --- /dev/null +++ b/docs/documentation/platform/pki/certificates/profiles.mdx @@ -0,0 +1,28 @@ +--- +title: "Certificate Profiles" +sidebarTitle: "Profiles" +--- + +## Concept + +A certificate profile is a configuration set specifying how leaf certificates should be issued for a group of end-entities including the [issuing CA](/documentation/platform/pki/ca/overview), a [certificate template](/documentation/platform/pki/certificates/templates), and the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) (e.g. ACME, EST, API, etc.) used to enroll certificates. + +You typically request certificates against a certificate profile through its associated enrollment method. Each method defines its own interaction flow which you can read more about in its respective documentation. + +## Guide to Creating a Certificate Profile + +To create a certificate profile, head to your Certificate Management Project > Certificates > Certificate Profiles and press **Create Profile**. + +![pki certificate profile](/images/platform/pki/certificate/cert-profile.png) + +![pki certificate profile modal](/images/platform/pki/certificate/cert-profile-modal.png) + +Here's some guidance on each field: + +- Name: A slug-friendly name for the profile such as `web-servers`. +- Description: An optional description for the profile. +- Issuing CA: The [issuing CA](/documentation/platform/pki/ca/overview) that should be used to issue certificates for the profile. +- Certificate Template: The [certificate template](/documentation/platform/pki/certificates/templates) that should be used to validate certificate requests for the profile. +- Enrollment Method: The enrollment method that should be used to enroll certificates for the profile such as ACME, EST, API, etc. + +Depending on which enrollment method you choose, you may be presented with additional enrollment-specific configuration fields. diff --git a/docs/documentation/platform/pki/certificates/templates.mdx b/docs/documentation/platform/pki/certificates/templates.mdx new file mode 100644 index 000000000..38b5570dd --- /dev/null +++ b/docs/documentation/platform/pki/certificates/templates.mdx @@ -0,0 +1,30 @@ +--- +title: "Certificate Templates" +sidebarTitle: "Templates" +--- + +## Concept + +A certificate template is a policy structure specifying permitted attributes for requested certificates. This includes constraints around subject naming conventions, SAN fields, key usages, and extended key usages. + +Each certificate requested against a profile is validated against the template bound to that profile. If the request fails any criteria included in the template, the certificate is not issued. This helps administrators enforce uniformity and security standards across all issued certificates. + +## Guide to Creating a Certificate Template + +To create a certificate template, head to your Certificate Management Project > Certificates > Certificate Templates and press **Create Template**. + +![pki certificate template](/images/platform/pki/certificate/cert-template.png) + +![pki certificate template modal](/images/platform/pki/certificate/cert-template-modal.png) + +Here's some guidance on each field: + +- Template Name: A slug-friendly name for the template such as `tls-server`. +- Description: An optional description for the template. +- Subject Attributes: A list of common names that can be included in the certificate subject. Each row accepts a fixed value or pattern such as `example.com` or `*.example.com` and whether it is allowed or denied. +- Subject Alternative Names (SANs): A list of SANs that can appear in the certificate. Each row accepts a SAN type (e.g. DNS, IP, Email, URI), a fixed value or pattern such as `example.com` or `*.example.com`, and an allow or deny flag. +- Allowed Signature Algorithms: The set of signature algorithms permitted to sign certificates under this template such as `SHA256-RSA`, `SHA512-RSA`, etc. +- Allowed Key Algorithms: The set of public key algorithms permitted for certificate requests such as `RSA-2048`, `RSA-4096`, etc. +- Key Usages: The cryptographic purposes of the certificate such as Digital Signature, Key Encipherment, etc. +- Extended Key Usages: The higher-level intended uses of the certificate such as Server Authentication, Client Authentication, etc. +- Certificate Validity: The maximum lifetime of certificates that can be requested for certificates validated against this template. You can specify both a duration and unit (days, months, or years). diff --git a/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx b/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx new file mode 100644 index 000000000..cf19ac867 --- /dev/null +++ b/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx @@ -0,0 +1,53 @@ +--- +title: "Certificate Lifecycle" +description: "Learn what is the certificate lifecycle and how it works." +--- + +## Certificate Lifecycle + +Typically, a certificate goes through a series of stages during its lifetime from creation to retirement. This is called the certificate lifecycle. The exact names of these stages may vary from vendor to vendor, but they typically include [discovery](/documentation/platform/pki/concepts/certificate-lifecycle#discovery), [enrollment](/documentation/platform/pki/concepts/certificate-lifecycle#enrollment), [deployment](/documentation/platform/pki/concepts/certificate-lifecycle#deployment), [renewal](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), [revocation](/documentation/platform/pki/concepts/certificate-lifecycle#revocation), and [retirement](/documentation/platform/pki/concepts/certificate-lifecycle#retirement). + +Note that not every stage is needed. For instance: + +- You are not required to discover certificates in order to start issuing and managing them. +- You may not need to revoke a certificate explicitly if it expires naturally and is replaced during routine renewal. + +## Discovery + +Certificate discovery is the process of identifying all active and inactive certificates across an environment, including those found on web servers, load balancers, services, and devices. A complete inventory prevents outages from forgotten certificates and creates the foundation for automation and monitoring. + +## Enrollment (Request / Issuance) + +Certificate enrollment is the process of requesting a certificate from a CA and can follow different approaches depending on the system or protocol in use. + +Common approaches to certificate enrollment include: + +- CSR-based enrollment: The client generates a key pair locally and submits a Certificate Signing Request (CSR) to a CA for certificate issuance. +- CSR-less enrollment: The client requests a certificate directly from a CA which may handle key generation internally and return the key pair in the response. + +Enrollment can be manually completed via API or fully automated using protocols like EST or ACME. The choice of enrollment method depends on security requirements, operational constraints, and integration context. + +## Deployment + +Certificate deployment involves installing the issued certificate on the appropriate systems and services, such as web servers, load balancers, or internal endpoints. It can also include distributing or [synchronizing certificates](/documentation/platform/pki/certificate-syncs/overview) to external systems like cloud key stores (e.g., AWS Secrets Manager, Google Secret Manager, Azure Key Vault) so they can be securely consumed by workloads running in the cloud. + +Deployment can happen manually or through automated mechanisms such as configuration pipelines, agents, or webhook integrations. + +## Renewal + +Certificate renewal is the process of requesting a new certificate from a CA before it expires to maintain trust and availability; this process can involve reusing the same key pair or rotating to a new one. + +The renewal process can be server-driven or client-driven: + +- Server-driven: Infisical automatically renews the certificate on your behalf. The renewed certificate is stored in the platform and can be synchronized to external systems such as cloud key stores. +- Client-driven: An external client, such as an agent or workload, initiates the renewal against Infisical. This is useful when key material needs to remain under client control or when rotation is tied to application-specific logic. + +This flexibility allows certificates to be renewed in a way that aligns with different security, automation, and infrastructure models. + +## Revocation + +Certificate revocation is the process of invalidating a certificate to prevent it from being used. This is required when a certificate is compromised, misconfigured, or no longer needed. The CA signals this status to clients through CRLs or OCSP. A new certificate can be issued and deployed if needed. + +## Retirement + +Certificate retirement is the process of removing a certificate from the system. This is typically done when a certificate is no longer needed or has expired. diff --git a/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx new file mode 100644 index 000000000..efdb8a072 --- /dev/null +++ b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx @@ -0,0 +1,20 @@ +--- +title: "Certificate Management" +description: "Learn what is certificate management and why it matters for building secure systems." +--- + +## What is a Certificate? + +A (digital) _certificate_ is a file that is tied to a cryptographic key pair and is used to verify the identity of a website, user, device, or service. It helps establish trust and secure, encrypted communication between systems. + +For example, when you visit a website over HTTPS, your browser checks the TLS certificate deployed on the web server or load balancer to make sure it’s really the site it claims to be. If the certificate is valid, your browser establishes an encrypted connection with the server. + +Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the CA that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service. + +## What is Certificate Management? + +As infrastructure scales and systems become more distributed, certificates sprawl. Without proper visibility and automation in place, certificates scatter across IT infrastructure, creating blind spots that can lead to service outages when certificates aren't renewed in time. + +To solve certificate sprawl and avoid outages, organizations rely on certificate management: the practice of centralizing and automating the certificate lifecycle from issuance through renewal and revocation. + +A consistent approach makes it easier to keep certificates valid and trusted, reduce operational risk, and maintain secure communication across environments. diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx new file mode 100644 index 000000000..559b0cab8 --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -0,0 +1,8 @@ +--- +title: "Certificate Enrollment via ACME" +sidebarTitle: "ACME" +--- + + + ACME-based certificate enrollment is currently under development and will be included in a future release. + diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx new file mode 100644 index 000000000..4adcdc01b --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -0,0 +1,179 @@ +--- +title: "Certificate Enrollment via API" +sidebarTitle: "API" +--- + +## Concept + +The API enrollment method allows you to issue certificates against a specific certificate profile over Web UI or by making an API request to Infisical. + +## Guide to Certificate Enrollment via API + +In the following steps, we explore how to issue a X.509 certificate using the API enrollment method. + + + + + + + Create a [certificate + profile](/documentation/platform/pki/certificates/profiles) with **API** + selected as the enrollment method. + + Notice that the API enrollment method supports an option called **Enable Auto-Renewal By Default**. + If selected, _eligible_ certificates are automatically considered for server-side auto-renewal based + on a specified renewal days before expiration threshold at the time of issuance; for more information + about server-side auto-renewal, refer to the documentation [here](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates). + + + + To create a certificate, head to your Project > Certificates > Certificates and press **Issue**. + + ![pki certificates](/images/platform/pki/certificate/cert-issue.png) + +Here, select the certificate profile from step 1 that will be used to issue the certificate and fill out the rest of the details for the certificate to be issued. + +![pki certificate issue modal](/images/platform/pki/certificate/cert-issue-modal.png) + + + + Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. + + ![pki certificate body](/images/platform/pki/certificate/cert-body.png) + + + Make sure to download and store the **Private Key** in a secure location as it + will only be displayed once at the time of certificate issuance. The + **Certificate Body** and **Certificate Chain** will remain accessible and can + be copied at any time. + + + + + + + + + + + To create a certificate [profile](/documentation/platform/pki/certificates/profiles), make an API request to the [Create Certificate Profile](/api-reference/endpoints/certificate-profiles/create) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificate-profiles' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectId": "", + "caId": "", + "certificateTemplateId": "", + "slug": "my-api-profile", + "description": "Certificate profile for API enrollment", + "enrollmentType": "API", + "apiConfig": { + "autoRenew": true, + "renewBeforeDays": 7 + } + }' + ``` + + ### Sample response + + ```bash Response + { + "certificateProfile": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "projectId": "65f0a4b0-c123-4567-8901-23456789abcd", + "caId": "550e8400-e29b-41d4-a716-446655440000", + "certificateTemplateId": "660f1234-e29b-41d4-a716-446655440001", + "slug": "my-api-profile", + "description": "Certificate profile for API enrollment", + "enrollmentType": "API", + "apiConfigId": "770g2345-e29b-41d4-a716-446655440002", + "createdAt": "2023-01-19T09:44:36.267Z", + "updatedAt": "2023-01-19T09:44:36.267Z" + } + } + ``` + + + + + To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "profileId": "", + "commonName": "service.acme.com", + "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048", + "keyUsages": ["digital_signature", "key_encipherment"], + "extendedKeyUsages": ["server_auth"], + "altNames": [ + { + "type": "DNS", + "value": "service.acme.com" + }, + { + "type": "DNS", + "value": "www.service.acme.com" + } + ] + }' + ``` + + ### Sample response + + ```bash Response + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", + "serialNumber": "123456789012345678", + "certificateId": "880h3456-e29b-41d4-a716-446655440003" + } + ``` + + + Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + + + If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/sign-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "profileId": "", + "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBE9oaW8...\n-----END CERTIFICATE REQUEST-----", + "ttl": "1y" + }' + ``` + + ### Sample response + + ```bash Response + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "serialNumber": "123456789012345679", + "certificateId": "990i4567-e29b-41d4-a716-446655440004" + } + ``` + + + + + diff --git a/docs/documentation/platform/pki/enrollment-methods/est.mdx b/docs/documentation/platform/pki/enrollment-methods/est.mdx new file mode 100644 index 000000000..a4e463a2f --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/est.mdx @@ -0,0 +1,71 @@ +--- +title: "Certificate Enrollment via EST" +sidebarTitle: "EST" +--- + +## Concept + +The API enrollment method allows you to issue and manage certificates against a specific certificate profile using the [EST protocol](https://en.wikipedia.org/wiki/Enrollment_over_Secure_Transport). +This method is suitable for environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services. + +Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints: + +- **cacerts** - provides the necessary CA chain for the client to validate certificates issued by the CA. +- **simpleenroll** - allows an EST client to request a new certificate from Infisical's EST server +- **simplereenroll** - similar to the /simpleenroll endpoint but is used for renewing an existing certificate. + +These EST endpoints are exposed on port 8443 under the .well-known/est path +and structured under `https://app.infisical.com:8443/.well-known/est/{profile_id}/...` + +## Prerequisites + +- Your client devices need to have a bootstrap/pre-installed certificate. +- Your client devices must trust the server certificates used by Infisical's EST server. If the devices are new or lack existing trust configurations, you need to manually establish trust for the appropriate certificates. + + + For Infisical Cloud users, the devices must be configured to trust the [Amazon + root CA certificates](https://www.amazontrust.com/repository). + + +## Guide to Certificate Enrollment via EST + +In the following steps, we explore how to issue a X.509 certificate using the EST enrollment method. + + + + Create a [certificate + profile](/documentation/platform/pki/certificates/profiles) with **EST** + selected as the enrollment method and fill in EST-specific configuration. + + ![pki est config](/images/platform/pki/enrollment-methods/est/est-config.png) + + Here's some guidance on each EST-specific configuration field: + + - Disable Bootstrap CA Validation: Enable this if your devices are not configured with a bootstrap certificate. + - EST Passphrase: This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. + - CA Chain Certificate: This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. + + + + Once the EST enrollment method configuration is complete, you can use the ID of the associated certificate profile + `profile_id` as the EST label when enrolling EST clients with Infisical. + + ![pki est label](/images/platform/pki/enrollment-methods/est/est-label.png) + + The complete URL structure of the supported EST endpoints may look like the following: + + - https://app.infisical.com:8443/.well-known/est/{profile_id}/cacerts + - https://app.infisical.com:8443/.well-known/est/{profile_id}/simpleenroll + - https://app.infisical.com:8443/.well-known/est/{profile_id}/simplereenroll + + + + To use the EST passphrase in your clients, configure it as the EST password. The EST username can be set to any arbitrary value. + Use the appropriate client certificates for invoking the EST endpoints. + - For `simpleenroll`, use the bootstrapped/manufacturer client certificate. + - For `simplereenroll`, use a valid EST-issued client certificate. + When configuring the PKCS#12 objects for the client certificates, only include the leaf certificate and the private key. + + + + diff --git a/docs/documentation/platform/pki/enrollment-methods/overview.mdx b/docs/documentation/platform/pki/enrollment-methods/overview.mdx new file mode 100644 index 000000000..f1af9375d --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/overview.mdx @@ -0,0 +1,11 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +Enrollment methods determine how certificates are issued and managed for a [certificate profile](/documentation/platform/pki/certificates/profiles). + +Refer to the documentation for each enrollment method to learn more about how to enroll certificates using it. + +- [API](/documentation/platform/pki/enrollment-methods/api): Enroll certificates via API. +- [EST](/documentation/platform/pki/enrollment-methods/est): Enroll certificates via EST protocol. diff --git a/docs/documentation/platform/pki/enrollment-methods/scep.mdx b/docs/documentation/platform/pki/enrollment-methods/scep.mdx new file mode 100644 index 000000000..977e7b388 --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/scep.mdx @@ -0,0 +1,8 @@ +--- +title: "Certificate Enrollment via SCEP" +sidebarTitle: "SCEP" +--- + + + SCEP-based certificate enrollment is currently under development and will be included in a future release. + diff --git a/docs/documentation/platform/pki/est.mdx b/docs/documentation/platform/pki/est.mdx deleted file mode 100644 index ecbd98dd9..000000000 --- a/docs/documentation/platform/pki/est.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Enrollment over Secure Transport (EST)" -sidebarTitle: "Enrollment over Secure Transport (EST)" -description: "Learn how to manage certificate enrollment of clients using EST" ---- - -## Concept - -Enrollment over Secure Transport (EST) is a protocol used to automate the secure provisioning of digital certificates for devices and applications over a secure HTTPS connection. It is primarily used when a client device needs to obtain or renew a certificate from a Certificate Authority (CA) on Infisical in a secure and standardized manner. EST is commonly employed in environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services. - -Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints: - -- **cacerts** - provides the necessary CA chain for the client to validate certificates issued by the CA. -- **simpleenroll** - allows an EST client to request a new certificate from Infisical's EST server -- **simplereenroll** - similar to the /simpleenroll endpoint but is used for renewing an existing certificate. - -These endpoints are exposed on port 8443 under the .well-known/est path e.g. -`https://app.infisical.com:8443/.well-known/est/estLabel/cacerts` - -## Prerequisites - -- You need to have an existing [CA hierarchy](/documentation/platform/pki/private-ca). -- The client devices need to have a bootstrap/pre-installed certificate. -- The client devices must trust the server certificates used by Infisical's EST server. If the devices are new or lack existing trust configurations, you need to manually establish trust for the appropriate certificates. - - For Infisical Cloud users, the devices must be configured to trust the [Amazon root CA certificates](https://www.amazontrust.com/repository). - -## Guide to configuring EST - -1. Set up a certificate template with your selected issuing CA. This template will define the policies and parameters for certificates issued through EST. For detailed instructions on configuring a certificate template, refer to the certificate templates [documentation](/documentation/platform/pki/certificates#guide-to-issuing-certificates). - -2. Proceed to the certificate template's enrollment settings - ![est enrollment dashboard](/images/platform/pki/est/template-enroll-hover.png) - -3. Select **EST** as the client enrollment method and fill up the remaining fields. - - ![est enrollment modal create](/images/platform/pki/est/template-enrollment-modal.png) - - - **Disable Bootstrap Certificate Validation** - Enable this if your devices are not configured with a bootstrap certificate. - - **Certificate Authority Chain** - This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. - - **Passphrase** - This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. - - For security reasons, Infisical authenticates EST clients using both client certificate and passphrase. - -4. Once the configuration of enrollment options is completed, a new **EST Label** field appears in the enrollment settings. This is the value to use as label in the URL when configuring the connection of EST clients to Infisical. - ![est enrollment modal create](/images/platform/pki/est/template-enrollment-est-label.png) - - The complete URL of the supported EST endpoints will look like the following: - - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/cacerts - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simpleenroll - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simplereenroll - -## Setting up EST clients - -- To use the EST passphrase in your clients, configure it as the EST password. The EST username can be set to any arbitrary value. -- Use the appropriate client certificates for invoking the EST endpoints. - - For `simpleenroll`, use the bootstrapped/manufacturer client certificate. - - For `simplereenroll`, use a valid EST-issued client certificate. -- When configuring the PKCS#12 objects for the client certificates, only include the leaf certificate and the private key. diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx deleted file mode 100644 index efe029149..000000000 --- a/docs/documentation/platform/pki/external-ca.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: "External CA" -sidebarTitle: "External CA" -description: "Learn how to connect External Certificate Authorities with Infisical." ---- - -## Concept - -In addition to creating a Private CA hierarchy, Infisical allows you to integrate with External Certificate Authorities (CAs) to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established certificate authority infrastructure while centralizing your certificate management within Infisical. - -
- -```mermaid -graph TD - B[Infisical] -->|Manages Certificates| D[Subscribers] - - A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B - A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B - A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B - - A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B - A5[Future: Cloud CAs] -.->|REST APIs| B -``` - -
- -When you integrate an External CA with Infisical, you benefit from: - -1. **Trust by Default**: Certificates issued by public CAs are trusted by default in browsers and operating systems. -2. **Unified Management**: Manage all certificatesβ€”both internally and externally issuedβ€”from a single platform. -3. **Automation**: Leverage Infisical's automation capabilities for certificate lifecycle management. -4. **Compliance**: Meet requirements for publicly trusted certificates, especially for public-facing services. -5. **Flexibility**: Choose the most appropriate CA for different use cases while maintaining consistent management. - -## General Workflow - -A typical workflow for integrating an External CA with Infisical consists of the following steps: - -1. **Select External CA Type**: Choose the appropriate external CA based on your requirements and supported protocols. -2. **Configure Prerequisites**: Set up any required credentials, connections, or configurations specific to your chosen CA type. -3. **Register External CA**: Add the External CA configuration to your Infisical project. -4. **Create Subscribers**: Set up subscribers that use the External CA as their issuing authority. -5. **Manage Certificate Lifecycle**: Handle certificate issuance, renewal, and revocation through Infisical's unified interface. - -The specific steps and requirements vary depending on the External CA type you choose to integrate. - -## Supported Integration Methods - -Infisical currently supports integration with External Certificate Authorities through the following protocol: - -### ACME Protocol Integration - -ACME (Automatic Certificate Management Environment) is a widely adopted protocol for automated certificate issuance and management. Infisical can integrate with any CA that supports the ACME protocol, including: - -**Public Certificate Authorities:** -- Let's Encrypt - Free, automated SSL/TLS certificates -- ZeroSSL - Free and premium SSL certificates -- Buypass - Norwegian CA with free ACME certificates - -**Enterprise Certificate Authorities:** -- HashiCorp Vault PKI - Enterprise secret management with ACME support -- Step CA - Open-source certificate authority with ACME - -**Cloud Certificate Authorities:** -- Some managed certificate services that support ACME protocol - -[Learn more about ACME integration β†’](/documentation/platform/pki/acme-ca) - -## Use Cases - -External CA integration is ideal for various scenarios: - -### Public-Facing Services -Use publicly trusted CAs for websites and services that need browser compatibility: -- Web applications and APIs -- Load balancers and CDNs -- Public-facing microservices - -### Compliance Requirements -Meet specific compliance standards that require certificates from accredited CAs: -- PCI DSS compliance -- SOC 2 requirements -- Industry-specific regulations - -### Hybrid Infrastructure -Combine internal and external CAs for different use cases: -- Internal services with Private CAs -- Public services with External CAs -- Development vs. production environments - -### Legacy System Integration -Integrate with existing enterprise PKI infrastructure: -- Windows Active Directory Certificate Services -- Network device management -- IoT device provisioning - -## Benefits of Centralized Management - -Managing External CAs through Infisical provides several advantages over direct CA management: - -### Unified Certificate Inventory -- Single dashboard for all certificates -- Centralized expiration tracking -- Cross-CA certificate analytics - -### Automated Lifecycle Management -- Automatic certificate reissuance before expiration -- Proactive expiration alerts -- Standardized certificate management processes - -### Enhanced Security -- Centralized access controls -- Audit trails for all certificate operations -- Policy enforcement across CAs - -### Operational Efficiency -- Reduced manual certificate management -- Consistent deployment workflows -- API-driven automation -- Integration with existing tools - -## Available Integration Guides - -Get started with External CA integration: - - - - Set up automated certificate issuance with any ACME-compatible CA - - - Custom CA integrations via REST APIs (Coming Soon) - - - -## FAQ - - - - Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: - - - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass - - **Enterprise CAs**: HashiCorp Vault PKI, Step CA - - **Cloud CAs**: ACME-compatible managed services - - Integration uses DNS-01 validation through Route53 or Cloudflare. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). - - Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. - - - Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: - - - Use Private CAs for internal services and applications - - Use External CAs for public-facing services - - Apply consistent management practices across all certificate types - - Implement appropriate security controls based on certificate usage - - - The types of certificates you can issue depend on the External CA provider and type: - - - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) - - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types - - **Cloud CAs**: Support various certificate types depending on the service - - Certificate capabilities vary by provider and integration method. - - - Certificate reissuance is handled automatically by Infisical based on the CA type: - - - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration - - **Other CA types**: Certificate management methods depend on the specific integration (when available) - - All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. - - - Authentication methods vary by CA type: - - - **Public CAs**: ACME account registration with email and account keys - - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) - - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) - - Infisical securely stores and manages all authentication credentials. - - - Yes, Infisical provides policy enforcement capabilities: - - - Certificate template constraints - - Monitoring and alerting policies - - Access controls for certificate operations - - These policies ensure consistent governance across both internal and external certificate sources. - - diff --git a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx new file mode 100644 index 000000000..78f0301e1 --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx @@ -0,0 +1,185 @@ +--- +title: "Apache Server" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Apache Server with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache HTTP Server](https://httpd.apache.org/). + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Apache benefits from excellent Certbot integration, allowing both certificate-only mode and automatic SSL configuration. + +## Prerequisites + +Before you begin, make sure you have: + +- An [Apache HTTP Server](https://httpd.apache.org/) running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Apache server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot with the Apache plugin on the server where Apache is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version and proper Apache plugin integration. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --apache \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your Apache configuration files; this mode is recommended if you prefer to manage your Apache SSL configuration manually or have a complex setup. + - `--apache`: Specifies the Apache plugin so Certbot can solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by creating temporary files served by Apache. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + If `--certonly` is used: Certbot does **not** modify your Apache configuration, so you must manually update your Apache virtual host to reference the new certificate files and reload the server to apply the changes. + + Here's an example SSL virtual host configuration for Apache: + + ```apache + + ServerName example.infisical.com + DocumentRoot /var/www/html + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/example.infisical.com/cert.pem + SSLCertificateKeyFile /etc/letsencrypt/live/example.infisical.com/privkey.pem + SSLCertificateChainFile /etc/letsencrypt/live/example.infisical.com/chain.pem + + # Your existing configuration... + + ``` + + After updating the virtual host configuration, test and reload Apache to apply the changes: + + ```bash + sudo apache2ctl configtest + sudo systemctl reload apache2 + ``` + + If `--certonly` was **not** used: Certbot uses installer mode, which attempts to automatically configure HTTPS by updating your Apache virtual host configuration and reloading the server if needed. + + At this point, your Apache server should be successfully serving HTTPS using the certificate issued by Infisical. + + + + After configuring Apache SSL, verify that your certificate was issued correctly and Apache is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Certbot automatically installs a `systemd` timer during installation. This timer runs twice per day and checks whether any certificates are due for renewal. Because Certbot stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configurationβ€”no additional settings are required. + + Note that Certbot automatically renews certificates when they are within 30 days of expiration; renewal settings can be adjusted in `/etc/letsencrypt/renewal/{domain-name}.conf`. + + ```ini + # ... your existing configuration ... + + renew_before_expiry = 30 days + ``` + + To test the renewal process, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, automatic renewal will work as expected. + + To trigger an actual renewal immediately, run the following command: + + ```bash + sudo certbot renew --force-renewal + ``` + + Note that after a certificate is renewed, Apache must be reloaded so it can begin using the new certificate. To do this, run the following command: + + ```bash + sudo systemctl reload apache2 + ``` + + To automate the process of renewing a certificate and reloading Apache, you can create a simple deploy hook that Certbot will run after every successful renewal. + + Inside `/etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh`, add the following: + + ```bash + #!/bin/sh + systemctl reload apache2 + ``` + + Then make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh + ``` + + Alternatively, you can use the `--post-hook` option when manually renewing: + + ```bash + sudo certbot renew --post-hook "systemctl reload apache2" + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the Apache reload automatically. Apache has native Certbot plugin integration, so no additional configuration is typically needed. + + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx index 2a04f5e3a..d1f1273fd 100644 --- a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -1,5 +1,5 @@ --- -title: "Gloo Mesh Integration" +title: "Gloo Mesh" description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" --- @@ -7,8 +7,8 @@ This guide will provide a high level overview on how you can use Infisical PKI a ## Overview -In this setup, we will use Infisical PKI to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. -To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). +In this setup, we will use Infisical PKI to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. +To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). Cert-manager is a Kubernetes controller that helps you automate the process of obtaining and renewing certificates from various PKI providers. With this approach, you get the following benefits: @@ -18,10 +18,10 @@ With this approach, you get the following benefits: - Use cert-manager to automatically issue and renew Istio intermediate CA certificates from the same root, ensuring cross-cluster workload communication. - Increased auditability of private key infrastructure. - ## General Setup + The certificate provisioning workflow begins with setting up your PKI hierarchy in Infisical, where you create root and subordinate certificate authorities. -When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the Infisical PKI Issuer controller to authenticate with Infisical using machine identity credentials and request an intermediate CA certificate. +When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the Infisical PKI Issuer controller to authenticate with Infisical using machine identity credentials and request an intermediate CA certificate. Infisical verifies the request against your certificate templates and returns the signed certificate. From there, Istio's control plane will automatically use this intermediate CA to sign leaf certificates for workloads in the service mesh, enabling secure mTLS communication across your entire Gloo Mesh infrastructure. @@ -35,5 +35,5 @@ For Gloo Mesh-specific configuration, ensure that: ## Using the certificates -Once the `cacerts` Kubernetes secret is created in the `istio-system` namespace, Istio automatically uses the custom CA certificate instead of the default self-signed certificate. -When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical PKI intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. \ No newline at end of file +Once the `cacerts` Kubernetes secret is created in the `istio-system` namespace, Istio automatically uses the custom CA certificate instead of the default self-signed certificate. +When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical PKI intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. diff --git a/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx new file mode 100644 index 000000000..c0e1c896b --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx @@ -0,0 +1,226 @@ +--- +title: "JBoss/WildFly" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on JBoss/WildFly with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). JBoss/WildFly requires certificates in Java keystore format, which this guide addresses through the certificate conversion process. + +## Prerequisites + +Before you begin, make sure you have: + +- A [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your JBoss/WildFly server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. +- [Java Development Kit (JDK)](https://openjdk.org/) installed for keystore management tools. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where JBoss/WildFly is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version of Certbot. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Since JBoss/WildFly doesn't have a native Certbot plugin, use the standalone authenticator to obtain certificates. **Important**: You must stop JBoss/WildFly before running this command as Certbot needs to bind to port 80 for the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + + Stop your JBoss/WildFly server: + + ```bash + sudo systemctl stop wildfly + # or for older JBoss versions + # sudo systemctl stop jboss + ``` + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --standalone \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your JBoss/WildFly configuration; this mode is recommended because JBoss/WildFly requires certificates in Java keystore format rather than the PEM format that Certbot provides. + - `--standalone`: Uses Certbot's standalone authenticator to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by starting a temporary web server on port 80. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + Because JBoss/WildFly requires certificates in Java keystore format, you'll need to convert the PEM certificates provided by Certbot in the next step. + + + + JBoss/WildFly requires certificates in Java keystore format rather than the PEM format provided by Certbot. Convert the PEM certificates to PKCS#12 format, which is supported by modern JBoss/WildFly versions. + + Create a PKCS#12 keystore from the PEM files: + + ```bash + sudo openssl pkcs12 -export \ + -out /opt/wildfly/standalone/configuration/keystore.p12 \ + -inkey /etc/letsencrypt/live/example.infisical.com/privkey.pem \ + -in /etc/letsencrypt/live/example.infisical.com/cert.pem \ + -certfile /etc/letsencrypt/live/example.infisical.com/chain.pem \ + -passout pass:changeit + ``` + + Set appropriate file permissions for security: + + ```bash + sudo chown wildfly:wildfly /opt/wildfly/standalone/configuration/keystore.p12 + sudo chmod 600 /opt/wildfly/standalone/configuration/keystore.p12 + ``` + + You will need to configure JBoss/WildFly to use the new keystore. This process varies depending on your JBoss/WildFly version and security configuration (legacy security realms vs. Elytron subsystem). Refer to your [JBoss](https://access.redhat.com/documentation/en-us/red_hat_jboss_enterprise_application_platform)/[WildFly](https://docs.wildfly.org/) administration guide for specific SSL/TLS configuration steps. + + + Replace `changeit` with a strong password and adjust the WildFly installation path based on your environment. Modern WildFly versions support PKCS#12 keystores directly, while older versions may require conversion to JKS format using the [keytool](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html) utility. + + + + + After configuring JBoss/WildFly SSL, verify that your certificate was issued correctly and the keystore was created properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + Verify the PKCS#12 keystore was created: + + ```bash + sudo ls -la /opt/wildfly/standalone/configuration/keystore.p12 + ``` + + Test the keystore contents (optional): + + ```bash + sudo keytool -list -storetype PKCS12 -keystore /opt/wildfly/standalone/configuration/keystore.p12 -storepass changeit + ``` + + Once you've configured JBoss/WildFly to use the keystore and restarted the service, you can verify HTTPS is working by accessing your application over SSL. + + + + Unlike standard web servers, JBoss/WildFly certificate renewal requires additional steps because certificates must be converted to Java keystore format and the application server must be restarted to use the new certificates. + + To test the renewal process without affecting your live certificates, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, the renewal mechanism itself will work as expected. + + For actual renewal, since JBoss/WildFly requires the standalone authenticator, you'll need to stop the server, perform the renewal, convert the certificate, and restart: + + ```bash + # Stop JBoss/WildFly + sudo systemctl stop wildfly + + # Renew the certificate + sudo certbot renew --quiet + + # Convert to keystore format + sudo openssl pkcs12 -export \ + -out /opt/wildfly/standalone/configuration/keystore.p12 \ + -inkey /etc/letsencrypt/live/example.infisical.com/privkey.pem \ + -in /etc/letsencrypt/live/example.infisical.com/cert.pem \ + -certfile /etc/letsencrypt/live/example.infisical.com/chain.pem \ + -passout pass:changeit + + # Set permissions + sudo chown wildfly:wildfly /opt/wildfly/standalone/configuration/keystore.p12 + sudo chmod 600 /opt/wildfly/standalone/configuration/keystore.p12 + + # Start JBoss/WildFly + sudo systemctl start wildfly + ``` + + To automate this process, you can create a renewal script. Create `/etc/letsencrypt/renewal-hooks/deploy/jboss-renewal.sh`: + + ```bash + #!/bin/bash + # JBoss/WildFly certificate renewal hook + + DOMAIN="example.infisical.com" + KEYSTORE_PATH="/opt/wildfly/standalone/configuration/keystore.p12" + KEYSTORE_PASSWORD="changeit" + + # Convert certificate to keystore format + openssl pkcs12 -export \ + -out "$KEYSTORE_PATH" \ + -inkey "/etc/letsencrypt/live/$DOMAIN/privkey.pem" \ + -in "/etc/letsencrypt/live/$DOMAIN/cert.pem" \ + -certfile "/etc/letsencrypt/live/$DOMAIN/chain.pem" \ + -passout "pass:$KEYSTORE_PASSWORD" + + # Set permissions + chown wildfly:wildfly "$KEYSTORE_PATH" + chmod 600 "$KEYSTORE_PATH" + + # Restart WildFly to load new certificate + systemctl restart wildfly + ``` + + Make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/jboss-renewal.sh + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the keystore conversion and service restart automatically. Because JBoss/WildFly requires the standalone authenticator (which stops the service temporarily), plan for brief service interruptions during renewal. + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx new file mode 100644 index 000000000..f28e5ee09 --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx @@ -0,0 +1,175 @@ +--- +title: "Nginx" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Nginx with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Nginx](https://nginx.org/) server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). + +## Prerequisites + +Before you begin, make sure you have: + +- An [Nginx](https://nginx.org/) web server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Nginx server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where Nginx is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version and proper Nginx plugin integration. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --nginx \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying and reloading your Nginx configuration file(s); this mode is recommended if you prefer to manage your Nginx TLS configuration manually, use automation tools, or integrate certificates into an existing deployment workflow. + - `--nginx`: Specifies the Nginx plugin so Certbot can solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by creating temporary files served by Nginx. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server’s Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + If `--certonly` is used: Certbot does **not** modify your Nginx configuration, so you must manually update your Nginx server block to reference the new certificate files and reload the server to apply the changes. + + Here's how you can configure your server block: + + ```nginx + server { + listen 443 ssl; + server_name example.infisical.com; + + ssl_certificate /etc/letsencrypt/live/example.infisical.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.infisical.com/privkey.pem; + + # ...your existing configuration... + } + + ``` + + After updating the server block, you should test and reload Nginx to apply the changes: + + ```bash + sudo nginx -t + sudo systemctl reload nginx + ``` + + If `--certonly` was **not** used: Certbot uses Nginx installer mode, which attempts to automatically configure HTTPS by updating your Nginx server block and reloading the server if needed. + + At this point, your Nginx server should be successfully serving HTTPS using the certificate issued by Infisical. + + + + After configuring Nginx SSL, verify that your certificate was issued correctly and Nginx is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Certbot automatically installs a `systemd` timer during installation. This timer runs twice per day and checks whether any certificates are due for renewal. Because Certbot stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configurationβ€”no additional settings are required. + + Note that Certbot automatically renews certificates when they are within 30 days of expiration; renewal settings can be adjusted in `/etc/letsencrypt/renewal/{domain-name}.conf`. + + ```ini + # ... your existing configuration ... + + renew_before_expiry = 30 days + ``` + + To test the renewal process, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, automatic renewal will work as expected. + + To trigger an actual renewal immediately, run the following command: + + ```bash + sudo certbot renew --force-renewal + ``` + + Note that after a certificate is renewed, Nginx must be reloaded so it can begin using the new certificate. To do this, run the following command: + + ```bash + systemctl reload nginx + ``` + + To automate the process of renewing a certificate and reloading Nginx, you can create a simple deploy hook that Certbot will run after every successful renewal. + + Inside `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`, add the following: + + ```bash + #!/bin/sh + systemctl reload nginx + ``` + + Then make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + ``` + + + + diff --git a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx new file mode 100644 index 000000000..ffb07bf1b --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx @@ -0,0 +1,251 @@ +--- +title: "Tomcat" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Tomcat with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache Tomcat](https://tomcat.apache.org/) application server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Unlike web servers with native Certbot plugins, Tomcat requires certificates to be manually configured after issuance. + +## Prerequisites + +Before you begin, make sure you have: + +- An [Apache Tomcat](https://tomcat.apache.org/) application server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Tomcat server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where Tomcat is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version of Certbot. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Since Tomcat doesn't have a native Certbot plugin, use the standalone authenticator to obtain certificates. **Important**: You must stop Tomcat before running this command as Certbot needs to bind to port 80 for the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + + Stop your Tomcat server: + + ```bash + sudo systemctl stop tomcat + ``` + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --standalone \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your Tomcat configuration; this mode is recommended because Tomcat requires manual SSL connector configuration in its server.xml file. + - `--standalone`: Uses Certbot's standalone authenticator to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by starting a temporary web server on port 80. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + Because Tomcat requires manual SSL configuration, you'll need to configure the SSL connector in your Tomcat server.xml file to reference these certificate files. You can restart Tomcat after the certificate is issued, but SSL won't be enabled until you complete the server configuration. + + ```bash + sudo systemctl start tomcat + ``` + + + + To enable SSL/TLS in Tomcat, you need to configure an SSL connector in the server.xml configuration file. Tomcat can use the PEM certificates directly without conversion to Java keystore format (available in Tomcat 8.5+ with the NIO or NIO2 connector). + + Edit your Tomcat server.xml file (typically located at `/opt/tomcat/conf/server.xml` or `/usr/share/tomcat/conf/server.xml`): + + ```xml + + + + + + ``` + + Restart Tomcat to apply the SSL configuration: + + ```bash + sudo systemctl restart tomcat + ``` + + You can verify SSL is working by accessing your Tomcat application at `https://example.infisical.com:8443`. For production deployments, consider configuring a reverse proxy (like [Apache HTTP Server](https://httpd.apache.org/) or [Nginx](https://nginx.org/)) to handle SSL termination on standard port 443. + + + The certificate paths must be readable by the Tomcat user. You may need to adjust file permissions or copy the certificates to a location accessible by Tomcat. For security, ensure the private key file has restricted permissions (600) and is owned by the Tomcat user. + + + + + After configuring Tomcat SSL, verify that your certificate was issued correctly and Tomcat is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Unlike web servers with native Certbot plugins, Tomcat certificate renewal requires stopping the server, renewing the certificate, and restarting to load the new certificates. + + To test the renewal process without affecting your live certificates, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, the renewal mechanism will work as expected. + + For actual renewal, since Tomcat requires the standalone authenticator, you'll need to stop the server, perform the renewal, and restart: + + ```bash + # Stop Tomcat + sudo systemctl stop tomcat + + # Renew the certificate + sudo certbot renew --quiet + + # Start Tomcat + sudo systemctl start tomcat + ``` + + **Important considerations for Tomcat renewal:** + + Because Tomcat uses the standalone authenticator, the server must be stopped during renewal. This creates a service interruption that requires manual coordination: + + 1. **Plan maintenance windows** for certificate renewals (typically every 60-90 days) + 2. **Monitor renewal dates** to schedule downtime appropriately + 3. **Consider load balancers** or multiple instances for high availability during renewals + + Create a deploy hook to automate post-renewal tasks. Create `/etc/letsencrypt/renewal-hooks/deploy/tomcat-renewal.sh`: + + ```bash + #!/bin/bash + # Tomcat certificate renewal hook + # This runs AFTER Certbot successfully renews certificates + + DOMAIN="example.infisical.com" + TOMCAT_USER="tomcat" + + # Ensure certificate files are readable by Tomcat + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/cert.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/privkey.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/chain.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" + + # Set appropriate permissions + chmod 640 "/etc/letsencrypt/live/$DOMAIN/cert.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/privkey.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/chain.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" + + # Start Tomcat (it was stopped for renewal) + systemctl start tomcat + + # Wait for startup and verify service is running + sleep 10 + if ! systemctl is-active --quiet tomcat; then + echo "ERROR: Tomcat failed to start after certificate renewal" + exit 1 + fi + + echo "Tomcat certificate renewal completed successfully" + ``` + + Make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/tomcat-renewal.sh + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the certificate permissions and service restart automatically. Because Tomcat requires the standalone authenticator (which stops the service temporarily), plan for brief service interruptions during renewal. + + + + + If you need to manually apply renewed certificates to Tomcat (when the deploy hook isn't used), follow these steps: + + **Step 1: Set certificate file permissions** + + After Certbot renews your certificates, ensure they're readable by Tomcat: + + ```bash + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/cert.pem + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/privkey.pem + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/chain.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/cert.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/privkey.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/chain.pem + ``` + + **Step 2: Restart Tomcat to load new certificates** + + ```bash + sudo systemctl restart tomcat + ``` + + That's it! Tomcat will automatically use the renewed certificates since your `server.xml` already points to the Let's Encrypt certificate files. + + + Since Tomcat reads certificates from the file system on startup, you only need to restart the service after certificate renewal. The certificate file paths in `/etc/letsencrypt/live/` are symbolic links that automatically point to the latest certificates. + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx new file mode 100644 index 000000000..2aab0870d --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx @@ -0,0 +1,194 @@ +--- +title: "Windows Server" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Windows Server with win-acme" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Windows Server](https://www.microsoft.com/en-us/windows-server) environments. + +It uses [win-acme](https://www.win-acme.com/), a feature-rich [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client designed specifically for Windows, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Win-acme offers excellent integration with IIS, Windows Certificate Store, and various certificate storage options. + +## Prerequisites + +Before you begin, make sure you have: + +- A [Windows Server](https://www.microsoft.com/en-us/windows-server) instance running with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Windows Server to Infisical. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that win-acme will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install win-acme on your Windows Server using one of the following methods. + + + 1. Visit the [win-acme releases page](https://github.com/win-acme/win-acme/releases). + 2. Download the latest stable release ZIP file. + 3. Extract the contents to a folder (e.g., `C:\win-acme`). + 4. Open Command Prompt or PowerShell as Administrator. + 5. Navigate to the win-acme folder. + + ```powershell + cd C:\win-acme + ``` + + + If you have [.NET Core](https://dotnet.microsoft.com/en-us/download) installed, you can install win-acme as a global tool: + + ```powershell + dotnet tool install win-acme --global + ``` + + This makes the `wacs` command available system-wide. + + + + + + Run the following win-acme command to request a certificate from Infisical: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pemfiles --pemfilespath "C:\certificates" --verbose + ``` + + For guidance on each parameter: + + - `--target manual`: Specifies manual target configuration for domain specification. + - `--host`: The domain name for which the certificate is being requested. + - `--baseuri`: The Infisical ACME directory URL from Step 1. This instructs win-acme to communicate with Infisical's ACME server instead of other ACME providers. + - `--eab-key-identifier`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-key`: The EAB secret associated with the KID from Step 1. + - `--validation selfhosting`: Uses self-hosting validation method to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + - `--store pemfiles`: Stores certificates as PEM files in a specified directory. + - `--pemfilespath`: Directory where certificates will be saved on your Windows Server. + - `--verbose`: Enables detailed logging for troubleshooting and monitoring the certificate request process. + + The win-acme command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Win-acme stores the private key and resulting leaf certificate and full certificate chain in the specified directory path. + + + Replace the placeholder values with your actual configuration: + - `example.infisical.com`: Your actual domain name + - `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`: Your Infisical ACME endpoint from Step 1 + - `your-eab-key-identifier` and `your-eab-secret`: Your External Account Binding credentials from Step 1 + - `C:\certificates`: Your desired certificate storage location + + + + + Win-acme supports various certificate storage options beyond PEM files. Here are common alternatives for different deployment scenarios: + + + + Store certificates directly in the [Windows Certificate Store](https://docs.microsoft.com/en-us/windows-hardware/drivers/install/certificate-stores) for integration with IIS and other Windows services: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store certificatestore --verbose + ``` + + + Generate [PFX files](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/certutil) with password protection for easy deployment across Windows environments: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pfxfile --pfxfilepath "C:\certificates" --pfxpassword "your-secure-password" --verbose + ``` + + + For IIS Central SSL store integration in high-scale environments: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store centralssl --centralsslstore "C:\CentralSSL" --verbose + ``` + + + + + + Win-acme can automatically create a [Windows Scheduled Task](https://docs.microsoft.com/en-us/windows/win32/taskschd/about-the-task-scheduler) for certificate renewal. Because win-acme stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configurationβ€”no additional settings are required. + + **Option 1: Enable during initial certificate request** + + Include the `--setuptaskscheduler` parameter in your initial command to automatically create the renewal task: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pemfiles --pemfilespath "C:\certificates" --setuptaskscheduler --verbose + ``` + + **Option 2: Test manual renewal** + + You can test the renewal process manually before setting up automation to ensure the configuration works correctly: + + ```powershell + wacs.exe --renew --force --verbose + ``` + + This command simulates the full renewal process and verifies that win-acme can successfully contact Infisical and renew your certificate using the stored configuration. + + **Option 3: Verify scheduled task creation** + + Check that the scheduled task was created successfully: + + ```powershell + Get-ScheduledTask -TaskName "*win-acme*" + ``` + + The automatic renewal task will: + - Run under the SYSTEM account for elevated privileges. + - Check certificates daily for renewal eligibility. + - Automatically renew certificates that are within the renewal threshold (typically 30 days before expiration). + - Log renewal activities to Windows Event Viewer and win-acme log files for monitoring and troubleshooting. + + + + Win-acme stores renewal configurations automatically in its settings directory, so once a certificate is created, the renewal process will use the same parameters (ACME endpoint, EAB credentials, storage options) for future renewals. The renewal threshold can be adjusted in the win-acme configuration files if needed. + + + + + After successful certificate issuance, verify that the certificate files have been created correctly based on your chosen storage method. + + + Check your specified PEM files directory to ensure all certificate components are present: + + ```powershell + Get-ChildItem "C:\certificates" -Filter "*.pem" + ``` + + You should see files like: + - `example.infisical.com-crt.pem` (certificate) + - `example.infisical.com-key.pem` (private key) + - `example.infisical.com-chain.pem` (complete certificate chain) + - `example.infisical.com-chain-only.pem` (only certificate chain) + + ![Windows Server Generated PEM files](/images/platform/pki/integrations/windows-server/certificates-created.png) + + + If you used the certificate store option, check that the certificate was properly installed using PowerShell: + + ```powershell + Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object {$_.Subject -like "*example.infisical.com*"} + ``` + + The certificate should appear in the [Local Computer Personal certificate store](https://docs.microsoft.com/en-us/dotnet/framework/wcf/feature-details/working-with-certificates#certificate-stores), making it available for use with IIS, other Windows services, and applications that integrate with the Windows Certificate Store. + + + + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index b2351813c..9aba032b1 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -1,13 +1,19 @@ --- -title: "Infisical PKI" +title: "Certificate Management" sidebarTitle: "Overview" -description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." +description: "Manage Certificate Authorities and automate X.509 certificate lifecycle management." --- -Infisical can be used to create and manage Certificate Authorities (CAs) and issue X.509 certificates. This allows you to manage PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. +Infisical can be used to create and manage Certificate Authorities (CAs) and issue digital X.509 certificates. This allows you to manage PKI infrastructure and issue certificates for end-entities such as load balancers, web servers, devices, and more. -Infisical's PKI offering is split into three components: +It helps teams automate certificate management including enrollment and renewal, and adopt secure workflows to ensure certificates remain valid, trusted, and synchronized across infrastructure. -- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage CAs, including root and intermediate CAs. -- [Subscribers](/documentation/platform/pki/subscribers): Define and manage entities that will request X.509 certificates from CAs. This module provides a centralized view of all subscribers, enabling you to issue certificates and monitor their status. -- [Certificates](/documentation/platform/pki/certificates): Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. +Core capabilities include: + +- [Private CA](/documentation/platform/pki/ca/private-ca): Create and manage your own private CA hierarchy including root and intermediate CAs. +- [External CA integration](/documentation/platform/pki/ca/external-ca): Integrate with external public and private CAs including [Azure ADCS](/documentation/platform/pki/ca/azure-adcs) and [ACME-compatible CAs](/documentation/platform/pki/ca/acme-ca) like Let's Encrypt and DigiCert. +- [Certificate Enrollment](/documentation/platform/pki/enrollment-methods/overview): Support enrollment methods including [API](/documentation/platform/pki/enrollment-methods/api), ACME, [EST](/documentation/platform/pki/enrollment-methods/est), and more to automate certificate issuance for services, devices, and workloads. +- Certificate Inventory: Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. +- Certificate Lifecycle Automation: Automate issuance, [renewal](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates), and [revocation](/documentation/platform/pki/certificates/certificates#guide-to-revoking-certificates) with policy-based workflows, ensuring certificates remain valid, compliant, and up to date across your infrastructure. +- [Certificate Syncs](/documentation/platform/pki/certificate-syncs/overview): Push certificates to cloud certificate managers like [AWS Certificate Manager](/documentation/platform/pki/certificate-syncs/aws-certificate-manager) and [Azure Key Vault](/documentation/platform/pki/certificate-syncs/azure-key-vault). +- [Certificate Alerts](/documentation/platform/pki/alerting): Receive alerts and webhook events for certificate lifecycle changes such as certificate expiration. diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx index 13214bfb7..a1d07c98b 100644 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ b/docs/documentation/platform/pki/pki-issuer.mdx @@ -1,5 +1,5 @@ --- -title: "Cert Manager Issuer" +title: "Kubernetes Issuer" description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" --- diff --git a/docs/documentation/platform/project-templates.mdx b/docs/documentation/platform/project-templates.mdx index 7dd5ceb50..9526f4092 100644 --- a/docs/documentation/platform/project-templates.mdx +++ b/docs/documentation/platform/project-templates.mdx @@ -106,13 +106,14 @@ In the following steps, we'll explore how to use a project template when creatin Your project will be provisioned with the configured template roles and environments.
- To use a project template, make an API request to the [Create Project](/api-reference/endpoints/workspaces/create-workspace) API endpoint with the specified template name included. + To use a project template, make an API request to the [Create Project](/api-reference/endpoints/projects/create-project) API endpoint with the specified template name included. ### Sample request ```bash Request curl --request POST \ - --url https://app.infisical.com/api/v2/workspace \ + --url https://app.infisical.com/api/v1/projects \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "projectName": "My Project", diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 545ed6b3b..dfb3bf0ec 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -17,6 +17,10 @@ For example, if secret A references values from secrets B and C located in diffe This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references. + + You can hold the `Cmd` (Mac) or `Ctrl` (Windows/Linux) key and click the secret reference to be redirected to it. + + ### Syntax When defining a secret reference, interpolation syntax is used to define references to secrets in other environments and [folders](./folder). diff --git a/docs/documentation/platform/secret-rotation/overview.mdx b/docs/documentation/platform/secret-rotation/overview.mdx index d11334440..d2e5755c4 100644 --- a/docs/documentation/platform/secret-rotation/overview.mdx +++ b/docs/documentation/platform/secret-rotation/overview.mdx @@ -98,8 +98,8 @@ Using a __30-Day__ rotation interval as an example, here's how the process unfol ## Infisical Secret Rotation Strategies -- [PostgreSQL Credentials](./postgres) -- [Microsoft SQL Server Credentials](./mssql) +- [PostgreSQL Credentials](./postgres-credentials) +- [Microsoft SQL Server Credentials](./mssql-credentials) ## FAQ diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx index 9fe68fb07..8e19b0937 100644 --- a/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx @@ -3,7 +3,7 @@ title: "Secrets Management" description: "Learn what is secrets management and why it matters for building secure systems." --- -## What is Secret? +## What is a Secret? A _secret_ is a confidential value used by an application such as database credential, API key, or other configuration. diff --git a/docs/documentation/platform/secrets-mgmt/project.mdx b/docs/documentation/platform/secrets-mgmt/project.mdx index 3e7c7cbc7..58f50ce37 100644 --- a/docs/documentation/platform/secrets-mgmt/project.mdx +++ b/docs/documentation/platform/secrets-mgmt/project.mdx @@ -16,7 +16,7 @@ customized depending on the intended use case. ## Secrets Overview -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](/documentation/platform/folder) across environments. This is useful for comparing secrets, identifying if anything is missing, and making quick changes. ![project secrets overview](/images/platform/project/project-secrets-overview-open.png) diff --git a/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx index 74bfca228..1c22550cd 100644 --- a/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx +++ b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx @@ -21,6 +21,6 @@ Because certificates are time-bound and centrally managed, they’re easier to a Infisical SSH gives you a secure, scalable way to manage infrastructure access using SSH certificates β€” without the overhead of running your own certificate authority, wiring trust across hosts, or building issuance workflows from scratch. -It replaces long-lived SSH keys with short-lived, identity-bound certificates and handles all the moving parts for you: operating CAs, configuring trust between users and hosts, and issuing certificates on demand. With Infisical SSH, you can register a host with [`infisical ssh add-host`](/docs/cli/commands/ssh#infisical-ssh-add-host), then connect with [`infisical ssh connect`](/docs/cli/commands/ssh#infisical-ssh-connect) β€” that’s all it takes. +It replaces long-lived SSH keys with short-lived, identity-bound certificates and handles all the moving parts for you: operating CAs, configuring trust between users and hosts, and issuing certificates on demand. With Infisical SSH, you can register a host with [`infisical ssh add-host`](/cli/commands/ssh#infisical-ssh-add-host), then connect with [`infisical ssh connect`](/cli/commands/ssh#infisical-ssh-connect) β€” that’s all it takes. The result is centralized, auditable SSH access that’s easy to use and built to scale with your infrastructure. diff --git a/docs/documentation/platform/sso/general-oidc/overview.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx index 586f66f26..5ba469264 100644 --- a/docs/documentation/platform/sso/general-oidc/overview.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -77,7 +77,7 @@ Prerequisites: - If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite OIDC login. + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](/documentation/platform/admin-panel/server-admin#default-organization) to expedite OIDC login. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 727bf9be6..0a32df228 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -103,7 +103,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." - If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite OIDC login. + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](/documentation/platform/admin-panel/server-admin#default-organization) to expedite OIDC login. diff --git a/docs/images/app-connections/azure/client-secrets/create-certificate-method.png b/docs/images/app-connections/azure/client-secrets/create-certificate-method.png new file mode 100644 index 000000000..37f642310 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-certificate-method.png differ diff --git a/docs/images/app-connections/azure/client-secrets/upload-certificate.png b/docs/images/app-connections/azure/client-secrets/upload-certificate.png new file mode 100644 index 000000000..518c70b2d Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/upload-certificate.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png deleted file mode 100644 index 42a20fc99..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png deleted file mode 100644 index 483cee003..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png deleted file mode 100644 index aa08b2d19..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png deleted file mode 100644 index 5f7b216ad..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png deleted file mode 100644 index 0d92fe69e..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png deleted file mode 100644 index 7e1ed12c5..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png deleted file mode 100644 index 79515516a..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png deleted file mode 100644 index 0a9093ee8..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png deleted file mode 100644 index 96295362b..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-details.png b/docs/images/certificate-syncs/azure-key-vault/vault-details.png deleted file mode 100644 index cc2ebc691..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-details.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-options.png b/docs/images/certificate-syncs/azure-key-vault/vault-options.png deleted file mode 100644 index 980dad0ef..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-options.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-review.png b/docs/images/certificate-syncs/azure-key-vault/vault-review.png deleted file mode 100644 index 4853be086..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-review.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-source.png b/docs/images/certificate-syncs/azure-key-vault/vault-source.png deleted file mode 100644 index 5ab8c5dc6..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-source.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png deleted file mode 100644 index 2bacd1a7b..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png and /dev/null differ diff --git a/docs/images/certificate-syncs/general/certificate-sync-tab.png b/docs/images/certificate-syncs/general/certificate-sync-tab.png deleted file mode 100644 index f97a10aa3..000000000 Binary files a/docs/images/certificate-syncs/general/certificate-sync-tab.png and /dev/null differ diff --git a/docs/images/platform/pki/acme/acme-configuration-modal.png b/docs/images/platform/pki/acme/acme-configuration-modal.png new file mode 100644 index 000000000..584a6e643 Binary files /dev/null and b/docs/images/platform/pki/acme/acme-configuration-modal.png differ diff --git a/docs/images/platform/pki/acme/certificate-profile-acme-option.png b/docs/images/platform/pki/acme/certificate-profile-acme-option.png new file mode 100644 index 000000000..463438f20 Binary files /dev/null and b/docs/images/platform/pki/acme/certificate-profile-acme-option.png differ diff --git a/docs/images/platform/pki/alerting/alert-create-2.png b/docs/images/platform/pki/alerting/alert-create-2.png deleted file mode 100644 index 812f253f8..000000000 Binary files a/docs/images/platform/pki/alerting/alert-create-2.png and /dev/null differ diff --git a/docs/images/platform/pki/alerting/alert-create-modal.png b/docs/images/platform/pki/alerting/alert-create-modal.png new file mode 100644 index 000000000..f65530ce4 Binary files /dev/null and b/docs/images/platform/pki/alerting/alert-create-modal.png differ diff --git a/docs/images/platform/pki/alerting/alert-create.png b/docs/images/platform/pki/alerting/alert-create.png index 4a7b3227f..620484223 100644 Binary files a/docs/images/platform/pki/alerting/alert-create.png and b/docs/images/platform/pki/alerting/alert-create.png differ diff --git a/docs/images/platform/pki/alerting/alerts.png b/docs/images/platform/pki/alerting/alerts.png deleted file mode 100644 index c7a5096ce..000000000 Binary files a/docs/images/platform/pki/alerting/alerts.png and /dev/null differ diff --git a/docs/images/platform/pki/alerting/collection-add-cert.png b/docs/images/platform/pki/alerting/collection-add-cert.png deleted file mode 100644 index 6300cc892..000000000 Binary files a/docs/images/platform/pki/alerting/collection-add-cert.png and /dev/null differ diff --git a/docs/images/platform/pki/alerting/collection-create-2.png b/docs/images/platform/pki/alerting/collection-create-2.png deleted file mode 100644 index 53378ef05..000000000 Binary files a/docs/images/platform/pki/alerting/collection-create-2.png and /dev/null differ diff --git a/docs/images/platform/pki/alerting/collection-create.png b/docs/images/platform/pki/alerting/collection-create.png deleted file mode 100644 index 7c4883201..000000000 Binary files a/docs/images/platform/pki/alerting/collection-create.png and /dev/null differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png deleted file mode 100644 index efe7d3b4a..000000000 Binary files a/docs/images/platform/pki/ca-crl.png and /dev/null differ diff --git a/docs/images/platform/pki/ca/ca-create-intermediate.png b/docs/images/platform/pki/ca/ca-create-intermediate.png index ac83db3e9..fe87345e6 100644 Binary files a/docs/images/platform/pki/ca/ca-create-intermediate.png and b/docs/images/platform/pki/ca/ca-create-intermediate.png differ diff --git a/docs/images/platform/pki/ca/ca-create-root.png b/docs/images/platform/pki/ca/ca-create-root.png index a8bf936a3..acf2d6a7a 100644 Binary files a/docs/images/platform/pki/ca/ca-create-root.png and b/docs/images/platform/pki/ca/ca-create-root.png differ diff --git a/docs/images/platform/pki/ca/ca-create.png b/docs/images/platform/pki/ca/ca-create.png index 915ed684c..f8cedd572 100644 Binary files a/docs/images/platform/pki/ca/ca-create.png and b/docs/images/platform/pki/ca/ca-create.png differ diff --git a/docs/images/platform/pki/ca/ca-crl.png b/docs/images/platform/pki/ca/ca-crl.png new file mode 100644 index 000000000..961297409 Binary files /dev/null and b/docs/images/platform/pki/ca/ca-crl.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate-csr.png b/docs/images/platform/pki/ca/ca-install-intermediate-csr.png index 77c7df0b9..b35b2ab10 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate-csr.png and b/docs/images/platform/pki/ca/ca-install-intermediate-csr.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate-opt.png b/docs/images/platform/pki/ca/ca-install-intermediate-opt.png index 16afd2f0b..4fdd64af7 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate-opt.png and b/docs/images/platform/pki/ca/ca-install-intermediate-opt.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate.png b/docs/images/platform/pki/ca/ca-install-intermediate.png index 10c9424ff..c0a0adde5 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate.png and b/docs/images/platform/pki/ca/ca-install-intermediate.png differ diff --git a/docs/images/platform/pki/ca/cas.png b/docs/images/platform/pki/ca/cas.png index d3189fd1a..628b03a34 100644 Binary files a/docs/images/platform/pki/ca/cas.png and b/docs/images/platform/pki/ca/cas.png differ diff --git a/docs/images/platform/pki/cert-revoke-modal.png b/docs/images/platform/pki/cert-revoke-modal.png deleted file mode 100644 index 07bc7fce8..000000000 Binary files a/docs/images/platform/pki/cert-revoke-modal.png and /dev/null differ diff --git a/docs/images/platform/pki/cert-revoke.png b/docs/images/platform/pki/cert-revoke.png deleted file mode 100644 index ff7fcc597..000000000 Binary files a/docs/images/platform/pki/cert-revoke.png and /dev/null differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png new file mode 100644 index 000000000..42d83c0f7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png new file mode 100644 index 000000000..6d0460601 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png new file mode 100644 index 000000000..8d7757fd0 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png new file mode 100644 index 000000000..03254ee9f Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png new file mode 100644 index 000000000..520b5e7f7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png new file mode 100644 index 000000000..f6b40afa7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png new file mode 100644 index 000000000..b1b79dda5 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png new file mode 100644 index 000000000..04040365a Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png new file mode 100644 index 000000000..05361ae81 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png new file mode 100644 index 000000000..642eeef34 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png new file mode 100644 index 000000000..69cd37d21 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png new file mode 100644 index 000000000..ae0c96574 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png new file mode 100644 index 000000000..157b4bf32 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png new file mode 100644 index 000000000..1fa0c0ca1 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png differ diff --git a/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png b/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png new file mode 100644 index 000000000..f0f62f386 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png differ diff --git a/docs/images/platform/pki/certificate/cert-body.png b/docs/images/platform/pki/certificate/cert-body.png index 8c1433b54..02c61840a 100644 Binary files a/docs/images/platform/pki/certificate/cert-body.png and b/docs/images/platform/pki/certificate/cert-body.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-option.png b/docs/images/platform/pki/certificate/cert-export-option.png new file mode 100644 index 000000000..ae966905c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-option.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem-modal.png b/docs/images/platform/pki/certificate/cert-export-pem-modal.png new file mode 100644 index 000000000..df01db0bf Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem.png b/docs/images/platform/pki/certificate/cert-export-pem.png new file mode 100644 index 000000000..927227394 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pkcs12.png b/docs/images/platform/pki/certificate/cert-export-pkcs12.png new file mode 100644 index 000000000..773c69c74 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pkcs12.png differ diff --git a/docs/images/platform/pki/certificate/cert-issue-modal.png b/docs/images/platform/pki/certificate/cert-issue-modal.png index 352c4979c..e3de549bb 100644 Binary files a/docs/images/platform/pki/certificate/cert-issue-modal.png and b/docs/images/platform/pki/certificate/cert-issue-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-issue.png b/docs/images/platform/pki/certificate/cert-issue.png index 614271d19..4506b9552 100644 Binary files a/docs/images/platform/pki/certificate/cert-issue.png and b/docs/images/platform/pki/certificate/cert-issue.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile-modal.png b/docs/images/platform/pki/certificate/cert-profile-modal.png new file mode 100644 index 000000000..29280d01c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-profile-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile.png b/docs/images/platform/pki/certificate/cert-profile.png new file mode 100644 index 000000000..3337f8065 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-profile.png differ diff --git a/docs/images/platform/pki/certificate/cert-revoke-modal.png b/docs/images/platform/pki/certificate/cert-revoke-modal.png new file mode 100644 index 000000000..5b47dc7ca Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-revoke-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-revoke.png b/docs/images/platform/pki/certificate/cert-revoke.png new file mode 100644 index 000000000..bc6bdb47c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-revoke.png differ diff --git a/docs/images/platform/pki/certificate/cert-template-modal.png b/docs/images/platform/pki/certificate/cert-template-modal.png index 2f6c88166..970b2f2f1 100644 Binary files a/docs/images/platform/pki/certificate/cert-template-modal.png and b/docs/images/platform/pki/certificate/cert-template-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-template.png b/docs/images/platform/pki/certificate/cert-template.png new file mode 100644 index 000000000..88080e7ca Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-template.png differ diff --git a/docs/images/platform/pki/enrollment-methods/est/est-config.png b/docs/images/platform/pki/enrollment-methods/est/est-config.png new file mode 100644 index 000000000..63892a99d Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/est/est-config.png differ diff --git a/docs/images/platform/pki/enrollment-methods/est/est-label.png b/docs/images/platform/pki/enrollment-methods/est/est-label.png new file mode 100644 index 000000000..76f77a2cb Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/est/est-label.png differ diff --git a/docs/images/platform/pki/est/template-enroll-hover.png b/docs/images/platform/pki/est/template-enroll-hover.png deleted file mode 100644 index 7bec8e3f6..000000000 Binary files a/docs/images/platform/pki/est/template-enroll-hover.png and /dev/null differ diff --git a/docs/images/platform/pki/est/template-enrollment-est-label.png b/docs/images/platform/pki/est/template-enrollment-est-label.png deleted file mode 100644 index 4ad7bbeb1..000000000 Binary files a/docs/images/platform/pki/est/template-enrollment-est-label.png and /dev/null differ diff --git a/docs/images/platform/pki/est/template-enrollment-modal.png b/docs/images/platform/pki/est/template-enrollment-modal.png deleted file mode 100644 index 4ce08cfe0..000000000 Binary files a/docs/images/platform/pki/est/template-enrollment-modal.png and /dev/null differ diff --git a/docs/images/platform/pki/integrations/windows-server/certificates-created.png b/docs/images/platform/pki/integrations/windows-server/certificates-created.png new file mode 100644 index 000000000..270e12b1a Binary files /dev/null and b/docs/images/platform/pki/integrations/windows-server/certificates-created.png differ diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index cb25fb596..4b55a7354 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -66,29 +66,72 @@ Infisical currently only supports two methods for connecting to Azure, which are - - Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. - **Prerequisites:** - - An active Azure setup. + - - - For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. + + **Prerequisites:** + - An active Azure setup. + + + + For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + #### Required API Permissions + + **Microsoft Graph** + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + + Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. + + **Prerequisites:** + - An active Azure setup. + + + + For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + #### Required API Permissions + + **Microsoft Graph** + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + Navigate to the **Certificates & secrets** section of your Azure App Registration, and press the **Upload certificate** button. + + Select the **Upload** button and upload your certificate. + + ![Upload certificate](/images/app-connections/azure/client-secrets/upload-certificate.png) + + + Keep in mind that both the certificate and its private key are required to configure the Azure Client Secrets connection in Infisical. + + + + + + + - #### Required API Permissions - - **Microsoft Graph** - - `Application.ReadWrite.All` - - `Application.ReadWrite.OwnedBy` - - `Application.ReadWrite.All` (Delegated) - - `Directory.ReadWrite.All` (Delegated) - - `User.Read` (Delegated) - ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) -
- - ## Setup Azure Connection in Infisical @@ -123,6 +166,17 @@ Infisical currently only supports two methods for connecting to Azure, which are ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-client-secrets-method.png)
+ + + Fill in the **Tenant ID**, **Client ID**, **Certificate (PEM format)**, and **Private Key** fields with the Directory (Tenant) ID, Application (Client) ID, Certificate and Private Key you obtained in the [previous step](#certificate-authentication). + + + The private key is never transmitted to Azure, and it is only used to sign the client assertion used to authenticate with Azure. + + + ![Connect via Azure Certificate](/images/app-connections/azure/client-secrets/create-certificate-method.png) + +
diff --git a/docs/integrations/platforms/docker-swarm-with-agent.mdx b/docs/integrations/platforms/docker-swarm-with-agent.mdx index 30118a8f0..40d9986bb 100644 --- a/docs/integrations/platforms/docker-swarm-with-agent.mdx +++ b/docs/integrations/platforms/docker-swarm-with-agent.mdx @@ -4,7 +4,7 @@ description: "Learn how to manage secrets in Docker Swarm services." --- In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm. -Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens. +Specifically, we'll set up a sidecar container using the [Infisical Agent](/integrations/platforms/infisical-agent), which authenticates with Infisical to retrieve secrets and access tokens. These secrets are then stored in a shared volume accessible by other services in your Docker Swarm. ## Prerequisites @@ -12,7 +12,7 @@ These secrets are then stored in a shared volume accessible by other services in - Docker version 20.10.24 or newer - Basic knowledge of Docker Swarm - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system -- Familiarity with the [Infisical Agent](/infisical-agent/overview) +- Familiarity with the [Infisical Agent](/integrations/platforms/infisical-agent) ## Objective Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments. diff --git a/docs/integrations/platforms/ecs-with-agent.mdx b/docs/integrations/platforms/ecs-with-agent.mdx index 31c5a982b..b6b9ce7f2 100644 --- a/docs/integrations/platforms/ecs-with-agent.mdx +++ b/docs/integrations/platforms/ecs-with-agent.mdx @@ -7,7 +7,7 @@ description: "Learn how to deliver secrets to Amazon Elastic Container Service." This guide will go over the steps needed to access secrets stored in Infisical from Amazon Elastic Container Service (ECS). -At a high level, the steps involve setting up an ECS task with an [Infisical Agent](/infisical-agent/overview) as a sidecar container. This sidecar container uses [AWS Auth](/documentation/platform/identities/aws-auth) to authenticate with Infisical to fetch secrets/access tokens. +At a high level, the steps involve setting up an ECS task with an [Infisical Agent](/integrations/platforms/infisical-agent) as a sidecar container. This sidecar container uses [AWS Auth](/documentation/platform/identities/aws-auth) to authenticate with Infisical to fetch secrets/access tokens. Once the secrets/access tokens are retrieved, they are then stored in a shared [Amazon Elastic File System](https://aws.amazon.com/efs/) (EFS) volume. This volume is then made accessible to your application and all of its replicas. This guide primarily focuses on integrating Infisical Cloud with Amazon ECS on AWS Fargate and Amazon EFS. @@ -21,7 +21,7 @@ This guide requires the following prerequisites: - Git installed - Terraform v1.0 or later installed - Access to AWS credentials -- Understanding of [Infisical Agent](/infisical-agent/overview) +- Understanding of [Infisical Agent](/integrations/platforms/infisical-agent) ## What we will deploy diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index a93bfcdf0..43d322faa 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -48,6 +48,8 @@ While specifying an authentication method is mandatory to start the agent, confi | Field | Description | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `infisical.address` | The URL of the Infisical service. Default: `"https://app.infisical.com"`. | +| `infisical.exit-after-auth` | Whether to exit the agent after authentication and first secret render. Default: `"false"`. | +| `infisical.revoke-credentials-on-shutdown` | Whether to revoke all managed dynamic secret leases and identity access tokens on shutdown. Default: `"false"`. | | `auth.type` | The type of authentication method used. Available options: `universal-auth`, `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, `aws-iam` | | `auth.config.identity-id` | The file path where the machine identity id is stored

This field is required when using any of the following auth types: `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, or `aws-iam`. | | `auth.config.service-account-token` | Path to the Kubernetes service account token to use (optional)

Default: `/var/run/secrets/kubernetes.io/serviceaccount/token` | @@ -58,7 +60,7 @@ While specifying an authentication method is mandatory to start the agent, confi | `sinks[].type` | The type of sink in a list of sinks. Each item specifies a sink type. Currently, only `"file"` type is available. | | `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | | `templates[].source-path` | The path to the template file that should be used to render secrets. | -| `templates[].template-content` | The inline secret template to be used for rendering the secrets. | +| `templates[].template-content` | The inline secret template to be used for rendering the secrets. | | `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | | `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5 minutes` (optional) | | `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index 22262ffbf..9903dcbc3 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -53,25 +53,69 @@ $ kubectl logs deployment/infisical-agent-injector ## Windows support -The Infisical Agent Injector supports both running on Windows-based pods, and injecting the agent into Windows-based pods. +By default the agent injector is built for Linux-based pods, but supports injecting into Windows-based pods. -To run the agent injector on a Windows pod, it's important that you add the `nodeSelector.kubernetes.io/os` label to the pod's deployment with the value `windows`. -This can be done by changing the helm values.yaml by adding the following: +**To inject into Windows-based pods, no extra configuration is needed.** The agent injector will automatically detect and handle injections into Windows-based pods. -```yaml values.yaml -nodeSelector: - kubernetes.io/os: windows -``` +However, if you are trying to run the agent injector itself on a Windows-based pod, you'll need to configure your helm values.yaml file to point to a Windows-based image. -By default the agent injector will run on Linux-based pods, unless you specify otherwise like in the example above. -No extra configuration is needed to inject into Windows-based pods, as the agent injector will detect and handle the injection automatically. -The Agent Injector will only run and inject into Windows-based pods that are running on the supported Windows versions: +The Agent Injector will only run on and inject into Windows-based pods that are running on the supported Windows versions: - **Windows Server 2022** +- **Windows Server 2019** We're looking to add support for other Windows versions in the future. If you're using a different Windows version, please let us know by opening [an issue](https://github.com/Infisical/infisical-agent-injector/issues/new), and we'll look into adding support for your desired version as soon as possible. +You will need to set the `nodeSelector.kubernetes.io/os` label to `windows` and set the image tag to a Windows-based image. Below are two examples for Windows Server 2019 and Windows Server 2022. + + + + + Create your `values.yaml` file and add the following: + + ```yaml values.yaml + image: + repository: infisical/infisical-agent-injector + tag: "v0.1.4-windows-server-2019" + + nodeSelector: + kubernetes.io/os: windows + ``` + + Install the agent injector using the values.yaml file you created above. + + ```bash + helm install --generate-name infisical-helm-charts/infisical-agent-injector -f values.yaml + ``` + + + + + Create your `values.yaml` file and add the following: + + ```yaml values.yaml + image: + repository: infisical/infisical-agent-injector + tag: "v0.1.4-windows-server-2022" + + nodeSelector: + kubernetes.io/os: windows + ``` + + Install the agent injector using the values.yaml file you created above. + + ```bash + helm install --generate-name infisical-helm-charts/infisical-agent-injector -f values.yaml + ``` + + + + + + Note that Windows support is only supported in version `v0.1.4` and above. If you are using an older version, you will need to upgrade to `v0.1.4` or above to use Windows support. + + ## Supported annotations The Infisical Agent Injector supports the following annotations: @@ -101,6 +145,17 @@ The entire config needs to be of string format and needs to be assigned to the ` The address of your Infisical instance. This field is optional and will default to `https://app.infisical.com` if not provided. + + Whether to revoke all managed dynamic secret leases and identity access tokens on shutdown. Default: `"false"`. + + If this is set to `true`, all managed dynamic secret leases and identity access tokens will be revoked when a `SIGTERM` signal is sent to the agents container _(such as when a pod is terminated or when the pod is restarted)_. + **Note:** In disaster events such as cluster power outages, a `SIGTERM` signal won't be sent to the agents container, and the credentials will not be revoked. + + + Note that this is currently unsupported on Windows-based pods, and will only work when injecting into Linux-based pods. + + + The authentication type to use to connect to Infisical. Currently only the `kubernetes` authentication type is supported. You can refer to our [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) documentation for more information on how to create a machine identity for Kubernetes Auth. diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 848da4055..f2911ac2f 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -19,7 +19,7 @@ This CRD offers the following features: ### Prerequisites - A project within Infisical. -- A [machine identity](/docs/documentation/platform/identities/overview) ready for use in Infisical that has permissions to create dynamic secret leases in the project. +- A [machine identity](/documentation/platform/identities/machine-identities) ready for use in Infisical that has permissions to create dynamic secret leases in the project. - You have already configured a dynamic secret in Infisical. - The operator is installed on to your Kubernetes cluster. diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index 0affe7841..a5c68e503 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -17,7 +17,7 @@ This CRD offers the following features: ### Prerequisites - A project within Infisical. -- A [machine identity](/docs/documentation/platform/identities/overview) ready for use in Infisical that has permissions to create secrets in your project. +- A [machine identity](/documentation/platform/identities/machine-identities) ready for use in Infisical that has permissions to create secrets in your project. - The operator is installed on to your Kubernetes cluster. ## Example usage diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index d7fb6249a..a7f3dd4ce 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -256,7 +256,7 @@ spec: ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -432,7 +432,7 @@ spec: ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -803,7 +803,7 @@ Follow the instructions below to create and store the service token in a Kuberne #### 1. Generate service token -You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. +You can generate a [service token](/documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. #### 2. Create Kubernetes secret containing service token diff --git a/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx b/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx index 91657c760..56c047056 100644 --- a/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx +++ b/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx @@ -77,7 +77,7 @@ description: "Learn how to configure a DigitalOcean App Platform Sync for Infisi - To create a **DigitalOcean App Platform Sync**, make an API request to the [Create DigitalOcean Sync](/api-reference/endpoints/secret-syncs/digital-ocean/create) API endpoint. + To create a **DigitalOcean App Platform Sync**, make an API request to the [Create DigitalOcean Sync](/api-reference/endpoints/secret-syncs/digital-ocean-app-platform/create) API endpoint. ### Sample request diff --git a/docs/internals/overview.mdx b/docs/internals/overview.mdx index 510a6b06c..fc58faca2 100644 --- a/docs/internals/overview.mdx +++ b/docs/internals/overview.mdx @@ -15,7 +15,7 @@ This section covers the internals of Infisical including its technical underpinn We do not recommend hardcoding your [Machine Identity - Tokens](/platform/identities/overview). Setting it as an environment variable + Tokens](/documentation/platform/identities/machine-identities). Setting it as an environment variable would be best. diff --git a/docs/sdks/languages/ruby.mdx b/docs/sdks/languages/ruby.mdx index 3fb9cb3e1..c5417fcdc 100644 --- a/docs/sdks/languages/ruby.mdx +++ b/docs/sdks/languages/ruby.mdx @@ -36,7 +36,7 @@ puts "Secret: #{single_test_secret}" This example demonstrates how to use the Infisical Ruby SDK in a simple Ruby application. The application retrieves a secret named `API_KEY` from the `dev` environment of the `YOUR_PROJECT_ID` project. - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/machine-identities). Setting it as an environment variable would be best. # Installation diff --git a/docs/self-hosting/deployment-options/native/standalone-binary.mdx b/docs/self-hosting/deployment-options/native/standalone-binary.mdx deleted file mode 100644 index 5767ca948..000000000 --- a/docs/self-hosting/deployment-options/native/standalone-binary.mdx +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Standalone" -description: "Learn how to deploy Infisical in a standalone environment." ---- - -# Self-Hosting Infisical with Standalone Infisical - -Deploying Infisical in a standalone environment is a great way to get started with Infisical without having to use containers. This guide will walk you through the process of deploying Infisical in a standalone environment. -This is one of the easiest ways to deploy Infisical. It is a single executable, currently only supported on Debian-based systems. - -The standalone deployment implements the "bring your own database" (BYOD) approach. This means that you will need to provide your own databases (specifically Postgres and Redis) for the Infisical services to use. The standalone deployment does not include any databases. - -If you wish to streamline the deployment process, we recommend using the Ansible role for Infisical. The Ansible role automates the end to end deployment process, and will take care of everything like databases, redis deployment, web serving, and availability. -- [Automated Deployment with high availability (HA)](/self-hosting/deployment-options/native/high-availability) - - -## Prerequisites -- A server running a Debian-based operating system (e.g., Ubuntu, Debian) -- A Postgres database -- A Redis database - -## Installing Infisical -Installing Infisical is as simple as running a single command. You can install Infisical by running the following command: - -```bash - $ curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-core/cfg/setup/bash.deb.sh' | sudo bash && sudo apt-get install -y infisical-core -``` - -## Running Infisical -Running Infisical and serving it to the web has a few steps. Below are the steps to get you started with running Infisical in a standalone environment. - * Setup environment variables - * Running Postgres migrations - * Create system daemon - * Exposing Infisical to the internet - - - - - To use Infisical you'll need to configure the environment variables beforehand. You can acheive this by creating an environment file to be used by Infisical. - - - #### Create environment file - ```bash - $ mkdir -p /etc/infisical && touch /etc/infisical/environment - ``` - - After creating the environment file, you'll need to fill it out with your environment variables. - - #### Edit environment file - ```bash - $ nano /etc/infisical/environment - ``` - - ```bash - DB_CONNECTION_URI=postgres://user:password@localhost:5432/infisical # Replace with your Postgres database connection URI - REDIS_URL=redis://localhost:6379 # Replace with your Redis connection URI - ENCRYPTION_KEY=your_encryption_key # Replace with your encryption key (can be generated with: openssl rand -hex 16) - AUTH_SECRET=your_auth_secret # Replace with your auth secret (can be generated with: openssl rand -base64 32) - ``` - - - The minimum required environment variables are `DB_CONNECTION_URI`, `REDIS_URL`, `ENCRYPTION_KEY`, and `AUTH_SECRET`. We recommend You take a look at our [list of all available environment variables](/docs/self-hosting/configuration/envars#general-platform), and configure the ones you need. - - - - - Assuming you're starting with a fresh Postgres database, you'll need to run the Postgres migrations to syncronize the database schema. - The migration command will use the environment variables you configured in the previous step. - - - ```bash - $ eval $(cat /etc/infisical/environment) infisical-core migration:latest - ``` - - - This step will need to be repeated if you update Infisical in the future. - - - - - - ```bash - $ nano /etc/systemd/system/infisical.service - ``` - - - - Create a systemd service file for Infisical. Creating a systemd service file will allow Infisical to start automatically when the system boots or in case of a crash. - - ```bash - $ nano /etc/systemd/system/infisical.service - ``` - - ```ini - [Unit] - Description=Infisical Service - After=network.target - - [Service] - # The path to the environment file we created in the previous step - EnvironmentFile=/etc/infisical/environment - Type=simple - # Change the user to the user you want to run Infisical as - User=root - ExecStart=/usr/local/bin/infisical-core - Restart=always - RestartSec=30 - - [Install] - WantedBy=multi-user.target - ``` - - Now we need to reload the systemd daemon and start the Infisical service. - - ```bash - $ systemctl daemon-reload - $ systemctl start infisical - $ systemctl enable infisical - ``` - - - You can check the status of the Infisical service by running `systemctl status infisical`. - It is also a good idea to check the logs for any errors by running `journalctl --no-pager -u infisical`. - - - - Exposing Infisical to the internet requires setting up a reverse proxy. You can use any reverse proxy of your choice, but we recommend using HAProxy or Nginx. Below is an example of how to set up a reverse proxy using HAProxy. - - #### Install HAProxy - ```bash - $ apt-get install -y haproxy - ``` - - #### Edit HAProxy configuration - ```bash - $ nano /etc/haproxy/haproxy.cfg - ``` - - ```ini - global - log /dev/log local0 - log /dev/log local1 notice - chroot /var/lib/haproxy - stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners - stats timeout 30s - user haproxy - group haproxy - daemon - - defaults - log global - mode http - option httplog - option dontlognull - timeout connect 5000 - timeout client 50000 - timeout server 50000 - - frontend http-in - bind *:80 - default_backend infisical - - backend infisical - server infisicalapp 127.0.0.1:8080 check - ``` - - - If you decide to use Nginx, then please be aware that the configuration will be different. **Infisical listens on port 8080**. - - - #### Restart HAProxy - ```bash - $ systemctl restart haproxy - ``` - - - - -And that's it! You have successfully deployed Infisical in a standalone environment. You can now access Infisical by visiting `http://your-server-ip`. - - - Please take note that the Infisical team cannot provide infrastructure support for **free self-hosted** deployments.
If you need help with infrastructure, we recommend upgrading to a [paid plan](https://infisical.com/pricing) which includes infrastructure support. - - You can also join our community [Slack](https://infisical.com/slack) for help and support from the community. -
- -## Troubleshooting - - - This is a common issue related to the HAProxy configuration file. The error is caused by the missing newline character at the end of the file. You can fix this by adding a newline character at the end of the file. - - ```bash - $ echo "" >> /etc/haproxy/haproxy.cfg - ``` - - - This issue can be caused by a number of reasons, mostly realted to the network configuration. Here are a few things you can check: - 1. Ensure that the firewall is not blocking the connection. You can check this by running `ufw status`. Ensure that port 80 is open. - 2. If you're using a cloud provider like AWS or GCP, ensure that the security group allows traffic on port 80. - 3. Ensure that the HAProxy service is running. You can check this by running `systemctl status haproxy`. - 4. Ensure that the Infisical service is running. You can check this by running `systemctl status infisical`. - \ No newline at end of file diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index acb692711..040bfa6b7 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -11,7 +11,7 @@ Choose from a number of deployment options listed below to get started. title="Docker" color="#000000" icon="docker" - href="deployment-options/standalone-infisical" + href="./deployment-options/standalone-infisical" > Use the fully packaged docker image to deploy Infisical anywhere.
@@ -20,7 +20,7 @@ Choose from a number of deployment options listed below to get started. title="Docker Compose" color="#000000" icon="docker" - href="deployment-options/docker-compose" + href="./deployment-options/docker-compose" > Install Infisical using our Docker Compose template. @@ -28,7 +28,7 @@ Choose from a number of deployment options listed below to get started. title="Kubernetes" color="#000000" icon="gear-complex-code" - href="deployment-options/kubernetes-helm" + href="./deployment-options/kubernetes-helm" > Use our Helm chart to Install Infisical on your Kubernetes cluster. @@ -36,7 +36,7 @@ Choose from a number of deployment options listed below to get started. Install Infisical on your system without containers using our Linux package. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8fdda096..8965ce45e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -150,8 +150,7 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -196,6 +195,7 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -488,6 +488,7 @@ "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.2.tgz", "integrity": "sha512-KjKXlcjKbUz8dKw7PY56F7qlfOFgxTU6tnlJ8YrbDyWkJMIlHa6VRWzCD8RU20zbJUC1hExhOFggZjm6tf1mUw==", "license": "MIT", + "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -546,6 +547,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1323,6 +1325,7 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.1" }, @@ -2013,6 +2016,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -4408,6 +4412,7 @@ "integrity": "sha512-RnO1SaiCFHn666wNz2QfZEFxvmiNRqhzaMXHXxXXKt+MEP7aajlPxUSMIQpKAaJfverpovEYqjBOXDq6dDcaOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.13.0", "eslint-visitor-keys": "^4.2.0", @@ -5033,6 +5038,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.95.1.tgz", "integrity": "sha512-P5x4yNhcdkYsCEoYeGZP8Q9Jlxf0WXJa4G/xvbmM905seZc9FqJqvCSRvX3dWTPOXRABhl4g+8DHqfft0c/AvQ==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.95.0", "@tanstack/react-store": "^0.7.0", @@ -5244,7 +5250,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5265,7 +5270,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5276,7 +5280,6 @@ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", @@ -5296,8 +5299,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@testing-library/user-event": { "version": "14.6.1", @@ -5305,7 +5307,6 @@ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12", "npm": ">=6" @@ -5326,8 +5327,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5380,7 +5380,6 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -5454,8 +5453,7 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/doctrine": { "version": "0.0.9", @@ -5591,6 +5589,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.16.tgz", "integrity": "sha512-oh8AMIC4Y2ciKufU8hnKgs+ufgbA/dhPTACaZPM86AbwX9QwnFtSoPWEeRUj8fge+v6kFt78BXcDhAU1SrrAsw==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5602,6 +5601,7 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5649,6 +5649,7 @@ "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", @@ -5689,6 +5690,7 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -5960,7 +5962,6 @@ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", @@ -5978,7 +5979,6 @@ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -6006,7 +6006,6 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -6017,7 +6016,6 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -6031,7 +6029,6 @@ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tinyspy": "^4.0.3" }, @@ -6045,7 +6042,6 @@ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", @@ -6119,6 +6115,7 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6235,7 +6232,6 @@ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -6284,7 +6280,6 @@ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6365,7 +6360,6 @@ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6453,7 +6447,6 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" } @@ -6464,7 +6457,6 @@ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -6477,8 +6469,7 @@ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/asynckit": { "version": "0.4.0", @@ -6528,7 +6519,6 @@ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">= 0.4" } @@ -6630,7 +6620,6 @@ "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "open": "^8.0.4" }, @@ -6867,6 +6856,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7048,7 +7038,6 @@ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -7119,7 +7108,6 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 16" } @@ -7505,8 +7493,7 @@ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cssesc": { "version": "3.0.0", @@ -7525,7 +7512,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cva": { "name": "class-variance-authority", @@ -7597,6 +7585,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7650,8 +7639,7 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -7761,7 +7749,6 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -7796,7 +7783,6 @@ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -7918,8 +7904,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8129,7 +8114,6 @@ "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -8213,6 +8197,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8254,7 +8239,6 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -8291,6 +8275,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8347,6 +8332,7 @@ "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -8369,6 +8355,7 @@ "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "confusing-browser-globals": "^1.0.10", "object.assign": "^4.1.2", @@ -8399,6 +8386,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8498,6 +8486,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8627,7 +8616,6 @@ "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8661,6 +8649,7 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8684,7 +8673,6 @@ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -8698,7 +8686,6 @@ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -8717,7 +8704,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -8879,7 +8865,6 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -9910,6 +9895,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -10003,7 +9989,6 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10280,7 +10265,6 @@ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "is-docker": "cli.js" }, @@ -10602,7 +10586,6 @@ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -10638,7 +10621,6 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", - "peer": true, "funding": { "type": "GitHub Sponsors ❀", "url": "https://github.com/sponsors/dmonad" @@ -10650,7 +10632,6 @@ "integrity": "sha512-x4WH0BWmrMmg4oHHl+duwubhrvczGlyuGAZu3nvrf0UXOfPu8IhZObFEr7DE/iv01YgVZrsOiRcqw2srkKEDIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -10800,7 +10781,6 @@ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -10835,8 +10815,7 @@ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, - "license": "CC0-1.0", - "peer": true + "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", @@ -10844,7 +10823,6 @@ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -10877,7 +10855,6 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", "license": "MIT", - "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -11202,8 +11179,7 @@ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lru-cache": { "version": "5.1.1", @@ -11230,7 +11206,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11922,7 +11897,6 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -12271,7 +12245,6 @@ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -12541,7 +12514,6 @@ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14.16" } @@ -12693,6 +12665,7 @@ "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12809,7 +12782,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12825,7 +12797,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -12838,8 +12809,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prismjs": { "version": "1.30.0", @@ -13040,6 +13010,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13067,6 +13038,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -13158,6 +13130,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13208,6 +13181,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13458,7 +13432,6 @@ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", @@ -13476,7 +13449,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13493,7 +13465,6 @@ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -13508,7 +13479,6 @@ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -13723,6 +13693,7 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14233,7 +14204,6 @@ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14249,7 +14219,6 @@ "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14277,7 +14246,6 @@ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -14498,7 +14466,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.2.1", @@ -14602,7 +14571,6 @@ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14613,7 +14581,6 @@ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -14898,6 +14865,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15283,6 +15251,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15700,7 +15669,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15745,6 +15713,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15895,6 +15864,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/public/images/integrations/Cassandra.png b/frontend/public/images/integrations/Cassandra.png new file mode 100644 index 000000000..76552ceac Binary files /dev/null and b/frontend/public/images/integrations/Cassandra.png differ diff --git a/frontend/public/images/integrations/CockroachDB.png b/frontend/public/images/integrations/CockroachDB.png new file mode 100644 index 000000000..4e7b778e8 Binary files /dev/null and b/frontend/public/images/integrations/CockroachDB.png differ diff --git a/frontend/public/images/integrations/DynamoDB.png b/frontend/public/images/integrations/DynamoDB.png new file mode 100644 index 000000000..457701c24 Binary files /dev/null and b/frontend/public/images/integrations/DynamoDB.png differ diff --git a/frontend/public/images/integrations/Elastic.png b/frontend/public/images/integrations/Elastic.png new file mode 100644 index 000000000..9e480e27a Binary files /dev/null and b/frontend/public/images/integrations/Elastic.png differ diff --git a/frontend/public/images/integrations/MCP.png b/frontend/public/images/integrations/MCP.png new file mode 100644 index 000000000..ba293b810 Binary files /dev/null and b/frontend/public/images/integrations/MCP.png differ diff --git a/frontend/public/images/integrations/MongoDB.png b/frontend/public/images/integrations/MongoDB.png new file mode 100644 index 000000000..5fe595688 Binary files /dev/null and b/frontend/public/images/integrations/MongoDB.png differ diff --git a/frontend/public/images/integrations/SQLite.png b/frontend/public/images/integrations/SQLite.png new file mode 100644 index 000000000..5f2754308 Binary files /dev/null and b/frontend/public/images/integrations/SQLite.png differ diff --git a/frontend/public/images/integrations/Snowflake.png b/frontend/public/images/integrations/Snowflake.png new file mode 100644 index 000000000..6bbefae93 Binary files /dev/null and b/frontend/public/images/integrations/Snowflake.png differ diff --git a/frontend/public/images/integrations/Web.png b/frontend/public/images/integrations/Web.png new file mode 100644 index 000000000..981dceeb1 Binary files /dev/null and b/frontend/public/images/integrations/Web.png differ diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 22f47fa17..58b9179bd 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -67,7 +67,8 @@ export const createNotification = ( ...toastProps, autoClose: toastProps.autoClose || 15000, theme: "dark", - type: myProps?.type || "info" + type: myProps?.type || "info", + className: `pointer-events-auto ${toastProps.className}` }); export const NotificationContainer = () => ( diff --git a/frontend/src/components/v2/Alert/Alert.tsx b/frontend/src/components/v2/Alert/Alert.tsx index f4910e495..70d0d5fc8 100644 --- a/frontend/src/components/v2/Alert/Alert.tsx +++ b/frontend/src/components/v2/Alert/Alert.tsx @@ -9,7 +9,7 @@ import { cva, type VariantProps } from "cva"; import { twMerge } from "tailwind-merge"; const alertVariants = cva( - "w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-4", + "w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-3", { variants: { variant: { @@ -28,6 +28,7 @@ type AlertProps = { title?: string; hideTitle?: boolean; icon?: React.ReactNode; + iconClassName?: string; }; const variantTitleMap = { @@ -45,36 +46,41 @@ const variantIconMap = { const Alert = forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps & AlertProps ->(({ className, variant, title, icon, hideTitle = false, children, ...props }, ref) => { - const defaultTitle = title ?? variantTitleMap[variant ?? "default"]; - return ( -
-
- {typeof icon !== "undefined" ? ( - <>{icon} - ) : ( - - )} +>( + ( + { className, variant, title, icon, hideTitle = false, children, iconClassName, ...props }, + ref + ) => { + const defaultTitle = title ?? variantTitleMap[variant ?? "default"]; + return ( +
+
+ {typeof icon !== "undefined" ? ( + <>{icon} + ) : ( + + )} +
+
+ {hideTitle ? null : ( +
+ {defaultTitle} +
+ )} + {children} +
-
- {hideTitle ? null : ( -
- {defaultTitle} -
- )} - {children} -
-
- ); -}); + ); + } +); Alert.displayName = "Alert"; const AlertDescription = forwardRef< diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index 8bc73bf89..d6c6712ef 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -55,6 +55,19 @@ export const DrawerContent = forwardRef( {...props} ref={forwardedRef} className={twMerge(drawerContentVariation({ direction, className }))} + onPointerDownOutside={(e) => { + const target = e.target as HTMLElement; + const toastElement = target.closest('[class*="Toastify"]'); + if (toastElement) { + e.preventDefault(); + return; + } + + if (onClose) { + onClose(); + } + props.onPointerDownOutside?.(e); + }} > {title && ( diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 9c4dc0b83..a109004e1 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -2,10 +2,15 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useSt import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; +import { useNavigate } from "@tanstack/react-router"; -import { useProject } from "@app/context"; +import { createNotification } from "@app/components/notifications"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useProject, useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce, useToggle } from "@app/hooks"; import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretInput } from "../SecretInput"; @@ -80,6 +85,8 @@ export const InfisicalSecretInput = forwardRef( ) => { const { currentProject } = useProject(); const projectId = currentProject?.id || ""; + const navigate = useNavigate({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path }); + const { permission } = useProjectPermission(); const [debouncedValue] = useDebounce(value, 100); @@ -307,6 +314,120 @@ export const InfisicalSecretInput = forwardRef( } }, []); + const handleClickSegment = useCallback( + (segment: string, allSegments: string[]) => { + if (!projectId) { + createNotification({ + text: "Project ID is not set", + type: "error" + }); + return; + } + + if (allSegments.length === 0) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + if (allSegments.length === 1) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: propEnvironment ?? "*", + secretPath: propSecretPath ?? "/", + secretName: segment, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + + navigate({ + search: (prev) => ({ + ...prev, + search: segment, + filterBy: "secret", + tags: "" + }) + }); + return; + } + + const environmentSlug = allSegments[0]; + const secretName = allSegments[allSegments.length - 1]; + let folderPath = "/"; + + if (allSegments.length > 2) { + const pathSegments = allSegments.slice(1, -1); + for (let i = 0; i < pathSegments.length; i += 1) { + if (!pathSegments[i]) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + const pathSegment = pathSegments[i]; + folderPath += `${pathSegment}`; + if (pathSegment === segment) { + folderPath += "/"; + break; + } + folderPath += "/"; + } + } + + // Only validate secret permission, users can always view environments and folders + if (segment === secretName) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: environmentSlug, + secretPath: folderPath, + secretName, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + } + + navigate({ + to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path, + params: { + projectId, + envSlug: environmentSlug + }, + search: (prev) => ({ + ...prev, + secretPath: segment === environmentSlug ? "/" : folderPath, + search: segment === secretName ? secretName : prev.search, + filterBy: segment === secretName ? "secret" : prev.filterBy, + tags: "" + }) + }); + }, + [navigate, projectId, permission, propEnvironment, propSecretPath] + ); + return ( @@ -329,6 +450,7 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} + onClickSegment={handleClickSegment} /> void; overlayClassName?: string; showCloseButton?: boolean; + closeOnOutsideClick?: boolean; }; export const ModalContent = forwardRef( @@ -29,6 +30,7 @@ export const ModalContent = forwardRef( bodyClassName, onClose, showCloseButton = true, + closeOnOutsideClick = true, ...props }, forwardedRef @@ -38,7 +40,23 @@ export const ModalContent = forwardRef( className={twMerge("animate-fade-in fixed inset-0 z-30 h-full w-full", overlayClassName)} style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }} /> - + { + const target = e.target as HTMLElement; + const toastElement = target.closest('[class*="Toastify"]'); + if (toastElement) { + e.preventDefault(); + return; + } + + if (closeOnOutsideClick && onClose) { + onClose(); + } + props.onPointerDownOutside?.(e); + }} + > void, + hoveredPart?: string, + isCmdOrCtrlPressed?: boolean, + onClickSegment?: (segment: string, allSegments: string[]) => void, + placeholder?: string ) => { if (isLoadingValue) return HIDDEN_SECRET_VALUE; if (isErrorLoadingValue) return Error loading secret value.; if (isImport && !content) return "IMPORTED"; + if (placeholder && (content === "" || !content)) return placeholder; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; if (!isVisible) return HIDDEN_SECRET_VALUE; @@ -27,10 +33,57 @@ const syntaxHighlight = ( const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; + const part = el; + const innerContent = el.slice(2, -1); // Remove ${ and } + const parts = innerContent.split("."); + return ( - + ${ - {el.slice(2, -1)} + {parts.map((segment, segmentIndex) => { + const segmentKey = `${part}-segment-${segmentIndex}`; + const isHovered = hoveredPart === segmentKey; + const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed; + + return ( + + onHoverPart?.(segmentKey)} + onMouseLeave={() => onHoverPart?.("")} + onMouseDown={(e) => { + if (isCmdOrCtrlPressed) { + e.preventDefault(); + e.stopPropagation(); + } + }} + onClick={(e) => { + e.stopPropagation(); + if (isCmdOrCtrlPressed) { + e.preventDefault(); + onClickSegment?.(segment, parts); + } + }} + onKeyDown={(e) => { + if (isCmdOrCtrlPressed && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + e.stopPropagation(); + onClickSegment?.(segment, parts); + } + }} + > + {segment} + + {segmentIndex < parts.length - 1 && ( + . + )} + + ); + })} } ); @@ -60,6 +113,7 @@ type Props = TextareaHTMLAttributes & { canEditButNotView?: boolean; isLoadingValue?: boolean; isErrorLoadingValue?: boolean; + onClickSegment?: (segment: string, allSegments: string[]) => void; }; const commonClassName = "font-mono text-sm caret-white border-none outline-hidden w-full break-all"; @@ -79,11 +133,43 @@ export const SecretInput = forwardRef( canEditButNotView, isLoadingValue, isErrorLoadingValue, + onClickSegment, + placeholder, ...props }, ref ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); + const [hoveredPart, setHoveredPart] = useState(); + const [isCmdOrCtrlPressed, setIsCmdOrCtrlPressed] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey) { + setIsCmdOrCtrlPressed(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey) { + setIsCmdOrCtrlPressed(false); + } + }; + + const handleBlur = () => { + setIsCmdOrCtrlPressed(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); return (
( style={{ maxHeight: `${21 * 7}px` }} >
-
+          
             
-              
+              
                 {syntaxHighlight(
                   value,
                   isVisible || (isSecretFocused && !valueAlwaysHidden),
                   isImport,
                   isLoadingValue,
-                  isErrorLoadingValue
+                  isErrorLoadingValue,
+                  (part) => {
+                    setHoveredPart(part);
+                  },
+                  hoveredPart,
+                  isCmdOrCtrlPressed,
+                  onClickSegment,
+                  placeholder
                 )}