diff --git a/.env.example b/.env.example index f67488c23..9da28e528 100644 --- a/.env.example +++ b/.env.example @@ -31,25 +31,14 @@ SMTP_FROM_NAME= SMTP_USERNAME= SMTP_PASSWORD= -# Integration -# Optional only if integration is used -CLIENT_ID_HEROKU= -CLIENT_ID_VERCEL= -CLIENT_ID_NETLIFY= +# CICD Integration CLIENT_ID_GITHUB= CLIENT_ID_GITHUB_APP= CLIENT_SLUG_GITHUB_APP= -CLIENT_ID_GITLAB= -CLIENT_ID_BITBUCKET= -CLIENT_SECRET_HEROKU= -CLIENT_SECRET_VERCEL= -CLIENT_SECRET_NETLIFY= CLIENT_SECRET_GITHUB= CLIENT_SECRET_GITHUB_APP= +CLIENT_ID_GITLAB= CLIENT_SECRET_GITLAB= -CLIENT_SECRET_BITBUCKET= -CLIENT_SLUG_VERCEL= - CLIENT_PRIVATE_KEY_GITHUB_APP= CLIENT_APP_ID_GITHUB_APP= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8a64e7b4..2803cbbb5 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,25 @@ -# Description 📣 +## Context - + -## Type ✨ +## Screenshots -- [ ] Bug fix -- [ ] New feature + + +## Steps to verify the change + +## Type + +- [ ] Fix +- [ ] Feature - [ ] Improvement -- [ ] Breaking change -- [ ] Documentation +- [ ] Breaking +- [ ] Docs +- [ ] Chore -# Tests 🛠️ +## Checklist - - -```sh -# Here's some code block to paste some code snippets -``` - ---- - -- [ ] 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 +- [ ] Title follows the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/#summary) format: `type(scope): short description` (scope is optional, e.g., `fix: prevent crash on sync` or `fix(api): handle null response`). +- [ ] Tested locally +- [ ] Updated docs (if needed) +- [ ] Read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview) \ No newline at end of file diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml new file mode 100644 index 000000000..1e590139c --- /dev/null +++ b/.github/workflows/validate-pr-title.yml @@ -0,0 +1,55 @@ +name: Validate PR Title + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +jobs: + validate-pr-title: + name: Validate PR Title Format + runs-on: ubuntu-latest + steps: + - name: Check PR Title Format + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const title = context.payload.pull_request.title; + + // Valid PR types based on pull_request_template.md + const validTypes = ['fix', 'feature', 'improvement', 'breaking', 'docs', 'chore']; + + // Regex pattern: type(optional-scope): short description + // - Type must be one of the valid types + // - Scope is optional, must be in parentheses, lowercase alphanumeric with hyphens + // - Followed by colon, space, and description (must start with lowercase letter) + const pattern = new RegExp(`^(${validTypes.join('|')})(\\([a-z0-9-]+\\))?: [a-z].+$`); + + if (!pattern.test(title)) { + const errorMessage = ` + ❌ **Invalid PR Title Format** + + Your PR title: \`${title}\` + + **Expected format:** \`type(scope): short description\` (description must start with lowercase) + + **Valid types:** + - \`fix\` - Bug fixes + - \`feature\` - New features + - \`improvement\` - Enhancements to existing features + - \`breaking\` - Breaking changes + - \`docs\` - Documentation updates + - \`chore\` - Maintenance tasks + + **Scope:** Optional, short identifier in parentheses (e.g., \`(api)\`, \`(auth)\`, \`(ui)\`) + + **Examples:** + - \`fix: prevent crash on sync\` + - \`fix(api): handle null response from auth endpoint\` + - \`docs(cli): update installation guide\` + `; + + core.setFailed(errorMessage); + } else { + console.log(`✅ PR title is valid: "${title}"`); + } + diff --git a/.gitignore b/.gitignore index b4e9a07c2..0ad950da3 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ cli/test/infisical-merge backend/bdd/.bdd-infisical-bootstrap-result.json /npm/bin +__pycache__ diff --git a/.infisicalignore b/.infisicalignore index ec1cbfe16..441031b33 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -54,4 +54,8 @@ 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 +docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139 +docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62 +docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61 +backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246 +backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248 \ No newline at end of file diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index ab1d6fbb7..9302578fe 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -185,6 +185,9 @@ COPY --from=backend-runner /app /backend COPY --from=frontend-runner /app ./backend/frontend-build +# Make export-assets script executable for CDN asset extraction +RUN chmod +x /backend/scripts/export-assets.sh + ARG INFISICAL_PLATFORM_VERSION ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 01c9a737b..faf489b2d 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -34,6 +34,7 @@ ENV VITE_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV VITE_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION +ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ENV VITE_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ARG CAPTCHA_SITE_KEY ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY @@ -173,6 +174,9 @@ ENV CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY COPY --from=backend-runner /app /backend COPY --from=frontend-runner /app ./backend/frontend-build +# Make export-assets script executable for CDN asset extraction +RUN chmod +x /backend/scripts/export-assets.sh + ARG INFISICAL_PLATFORM_VERSION ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index 9a2e9f90b..52fda3eca 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -3,6 +3,7 @@ import os import pathlib import typing +from copy import deepcopy import httpx from behave.runner import Context @@ -86,14 +87,13 @@ def bootstrap_infisical(context: Context): ca_slug = faker.slug() resp = client.post( - "/api/v1/pki/ca/internal", + "/api/v1/cert-manager/ca/internal", headers=headers, json={ "projectId": project["id"], "name": ca_slug, "type": "internal", "status": "active", - "enableDirectIssuance": True, "configuration": { "type": "root", "organization": "Infisican Inc", @@ -114,7 +114,7 @@ def bootstrap_infisical(context: Context): cert_template_slug = faker.slug() resp = client.post( - "/api/v2/certificate-templates", + "/api/v1/cert-manager/certificate-templates", headers=headers, json={ "projectId": project["id"], @@ -185,28 +185,33 @@ def bootstrap_infisical(context: Context): def before_all(context: Context): + base_vars = { + "BASE_URL": BASE_URL, + "PEBBLE_URL": PEBBLE_URL, + } if BOOTSTRAP_INFISICAL: details = bootstrap_infisical(context) - context.vars = { - "BASE_URL": BASE_URL, - "PEBBLE_URL": PEBBLE_URL, + vars = base_vars | { "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, + vars = base_vars | { "PROJECT_ID": PROJECT_ID, "CERT_CA_ID": CERT_CA_ID, "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, "AUTH_TOKEN": AUTH_TOKEN, } + context._initial_vars = vars context.http_client = httpx.Client(base_url=BASE_URL) +def before_scenario(context: Context, scenario: typing.Any): + context.vars = deepcopy(context._initial_vars) + + def after_scenario(context: Context, scenario: typing.Any): if hasattr(context, "web_server"): context.web_server.shutdown_and_server_close() diff --git a/backend/bdd/features/pki/acme/access-control.feature b/backend/bdd/features/pki/acme/access-control.feature index 6615d00f8..053127077 100644 --- a/backend/bdd/features/pki/acme/access-control.feature +++ b/backend/bdd/features/pki/acme/access-control.feature @@ -2,7 +2,7 @@ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -34,7 +34,7 @@ Feature: Access Control 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 "" @@ -53,7 +53,7 @@ Feature: Access Control 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 | {BASE_URL}/api/v1/cert-manager/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 | | @@ -62,7 +62,7 @@ Feature: Access Control 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -96,7 +96,7 @@ Feature: Access Control 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 + When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -110,10 +110,10 @@ Feature: Access Control """ 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" + When I send a "GET" request to "/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -133,7 +133,7 @@ Feature: Access Control 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 | {BASE_URL}/api/v1/cert-manager/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 | | @@ -143,7 +143,7 @@ Feature: Access Control 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -177,7 +177,7 @@ Feature: Access Control 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 + When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -191,10 +191,10 @@ Feature: Access Control """ 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" + When I send a "GET" request to "/api/v1/cert-manager/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 + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -214,17 +214,16 @@ Feature: Access Control 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 | {BASE_URL}/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -259,8 +258,8 @@ Feature: Access Control 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 | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/cert-manager/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 | @@ -271,3 +270,52 @@ Feature: Access Control | 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 | + + Scenario Outline: Send KID and JWK in the same time + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 + 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}", + "jwk": { + "n": "mmEWxUv2lUYDZe_M2FXJ_WDXgHoEG7PVvg-dfz1STzyMwx0qvM66KMenXSyVA0r-_Ssb6p8VexSWGOFKskM4ryKUihn2KNH5e8nXZBqzqYeKQ8vqaCdaWzTxFI1dg0xhk0CWptkZHxpRpLalztFJ1Pq7L2qvQOM2YT7wPYbwQhpaSiVNXAb1W4FwAPyC04v1mHehvST-esaDT7j_5-eU5cCcmyi4_g5nBawcinOjj5o3VCg4X8UjK--AjhAyYHx1nRMr-7xk4x-0VIpQ_OODjLB3WzN8s1YEb0Jx5Bv1JyeCw35zahqs3fAFyRje-p5ENk9NCxfz5x9ZGkszkkNt0Q", + "e": "AQAB", + "kty": "RSA" + } + }, + "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:malformed" + And the value response with jq ".detail" should be equal to "Both JWK and KID are provided in the protected header" + + Examples: Endpoints + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/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/account.feature b/backend/bdd/features/pki/acme/account.feature index 589c5ab24..c7eb25a53 100644 --- a/backend/bdd/features/pki/acme/account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -2,27 +2,46 @@ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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/(.+) + And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/(.+) + + Scenario: Create a new account with the same key pair twice + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 kid + And 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_account2 + And the value error.__class__.__name__ should be equal to "ConflictError" + And the value error.location should be equal to "{kid}" 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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}" + 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 retrieved_account + And the value retrieved_account.uri should be equal to "{account_uri}" + + # Note: This is a very special case for cert-manager. + Scenario: Create a new account with EAB then retrieve it without EAB + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 without EAB as retrieved_account + And the value error with should be absent + And the value retrieved_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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 "" @@ -38,17 +57,17 @@ Feature: Account 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 | + | url | + | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account-bad | + | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account?foo=bar | + | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account#foobar | + | {BASE_URL}/acme/new-account | + | https://example.com/api/v1/cert-manager/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 index 46cc9d4e2..757a182c8 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -2,7 +2,7 @@ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -14,7 +14,7 @@ Feature: Authorization 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].uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/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 """ diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 3c292e8ba..4c3b84ab9 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -3,7 +3,7 @@ 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 + When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -25,7 +25,7 @@ Feature: ACME Cert Profile 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 + When I send a "POST" request to "/api/v1/cert-manager/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -39,11 +39,11 @@ Feature: ACME Cert Profile """ 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" + When I send a "GET" request to "/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 index 67f73aab2..80f6fed6c 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -2,7 +2,7 @@ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -22,9 +22,31 @@ Feature: Challenge 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 challenge with retry + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 wait 45 seconds and 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -58,18 +80,17 @@ Feature: Challenge 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 """ [ + "localhost", "infisical.com" ] """ @@ -82,56 +103,19 @@ Feature: Challenge # 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" + And I wait until the status of authorization localhost_auth becomes 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" + And I post-as-get {infisical_auth} as infisical_auth_resp + And the value infisical_auth_resp 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" + And I post-as-get {order.uri} as order_resp + And the value order_resp 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 + And I get a new-nonce as nonce When I send a raw ACME request to "{order.body.finalize}" """ { @@ -153,7 +137,7 @@ Feature: Challenge 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -165,13 +149,13 @@ Feature: Challenge 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" + When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/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", + "url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order", "kid": "{acme_account.uri}" }, "payload": { @@ -185,8 +169,10 @@ Feature: Challenge 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.headers with jq ".["location"]" as order_uri And I memorize response as order And I pass all challenges with type http-01 for order in order + And I wait until the status of order order_uri becomes ready 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}" """ diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature deleted file mode 100644 index 664ff7457..000000000 --- a/backend/bdd/features/pki/acme/dicrectory.feature +++ /dev/null @@ -1,14 +0,0 @@ -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/directory.feature b/backend/bdd/features/pki/acme/directory.feature new file mode 100644 index 000000000..30a94af38 --- /dev/null +++ b/backend/bdd/features/pki/acme/directory.feature @@ -0,0 +1,17 @@ +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/cert-manager/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/cert-manager/acme/profiles/{acme_profile.id}/new-nonce", + "newAccount": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-account", + "newOrder": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order", + "meta": { + "externalAccountRequired": true + } + } + """ diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 26bfd84ad..5a2cef0cc 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -1,6 +1,7 @@ Feature: External CA - Scenario: Issue a certificate from an external CA + @cloudflare + Scenario Outline: Issue a certificate from an external CA with Cloudflare 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 @@ -87,14 +88,12 @@ Feature: External CA """ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -177,4 +176,542 @@ Feature: External CA [ "localhost" ] - """ \ No newline at end of file + """ + + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | + + @dnsme + Scenario Outline: Issue a certificate from an external CA with DNS Made Easy + Given I create a DNS Made Easy connection as dnsme + Then I memorize dnsme with jq ".appConnection.id" as app_conn_id + Given I create a external ACME CA with the following config as ext_ca + """ + { + "dnsProviderConfig": { + "provider": "dns-made-easy", + "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/cert-manager/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 + """ + + """ + # 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.dnsmadeeasy.com:443", + "method": "POST", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records", + "status": 201, + "response": { + "gtdLocation": "DEFAULT", + "failed": false, + "monitor": false, + "failover": false, + "sourceId": 895364, + "dynamicDns": false, + "hardLink": false, + "ttl": 60, + "source": 1, + "name": "_acme-challenge", + "value": "\"MOCK_HTTP_01_VALUE\"", + "id": 12345678, + "type": "TXT" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "GET", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records?type=TXT&recordName=_acme-challenge&page=0", + "status": 200, + "response": { + "totalRecords": 1, + "totalPages": 1, + "data": [ + { + "gtdLocation": "DEFAULT", + "failed": false, + "monitor": false, + "failover": false, + "sourceId": 895364, + "dynamicDns": false, + "hardLink": false, + "ttl": 60, + "source": 1, + "name": "_acme-challenge", + "value": "\"MOCK_CHALLENGE_VALUE\"", + "id": 1111111, + "type": "TXT" + } + ], + "page": 0 + }, + "responseIsBinary": false + }, + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "DELETE", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records/1111111", + "status": 200, + "response": "", + "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 + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "localhost" + ] + """ + + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | + + Scenario Outline: Issue a certificate with bad CSR names disallowed by the template + 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": [ + "example.com" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "infisical.com" + ] + } + ], + "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/cert-manager/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 + """ + + """ + Then I add subject alternative name to certificate signing request csr + """ + + """ + 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 + 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 error.typ should be equal to "urn:ietf:params:acme:error:badCSR" + And the value error.detail should be equal to "" + + Examples: + | subject | san | err_detail | + | {"COMMON_NAME": "localhost"} | [] | Invalid CSR: common_name value 'localhost' is not in allowed values list | + | {"COMMON_NAME": "localhost"} | ["infisical.com"] | Invalid CSR: common_name value 'localhost' is not in allowed values list | + | {} | ["localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list | + | {} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list | + | {"COMMON_NAME": "example.com"} | ["infisical.com", "localhost"] | Invalid CSR: dns_name SAN value 'localhost' is not in allowed values list | + + + Scenario Outline: Issue a certificate with algorithms disallowed by the template + 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": [ + "" + ], + "keyAlgorithm": [ + "" + ] + }, + "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/cert-manager/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 + """ + {} + """ + Then I add subject alternative name to certificate signing request csr + """ + [ + "localhost" + ] + """ + And I create a private key pair as cert_key + And I sign the certificate signing request csr with "" hash and 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 + 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 error.typ should be equal to "urn:ietf:params:acme:error:badCSR" + And the value error.detail should be equal to "" + + Examples: + | allowed_alg | allowed_signature | key_type | hash_type | err_detail | + | RSA-4096 | SHA512-RSA | RSA-2048 | SHA512 | Invalid CSR: Key algorithm 'RSA_2048' is not allowed by template policy | + | RSA-4096 | SHA512-RSA | RSA-3072 | SHA512 | Invalid CSR: Key algorithm 'RSA_3072' is not allowed by template policy | + | RSA-4096 | ECDSA-SHA512 | ECDSA-P256 | SHA512 | Invalid CSR: Key algorithm 'EC_prime256v1' is not allowed by template policy | + | RSA-4096 | ECDSA-SHA512 | ECDSA-P384 | SHA512 | Invalid CSR: Key algorithm 'EC_secp384r1' is not allowed by template policy | + | RSA-4096 | ECDSA-SHA512 | ECDSA-P521 | SHA512 | Invalid CSR: Key algorithm 'EC_secp521r1' is not allowed by template policy | + | RSA-2048 | SHA512-RSA | RSA-2048 | SHA384 | Invalid CSR: Signature algorithm 'RSA-SHA384' is not allowed by template policy | + | RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy | + | ECDSA-P256 | SHA512-RSA | ECDSA-P256 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy | + | ECDSA-P384 | SHA512-RSA | ECDSA-P384 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy | + | ECDSA-P521 | SHA512-RSA | ECDSA-P521 | SHA256 | Invalid CSR: Signature algorithm 'ECDSA-SHA256' is not allowed by template policy | + | RSA-2048 | SHA512-RSA | RSA-2048 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy | + | RSA-2048 | SHA512-RSA | RSA-4096 | SHA256 | Invalid CSR: Signature algorithm 'RSA-SHA256' is not allowed by template policy, Key algorithm 'RSA_4096' is not allowed by template policy | diff --git a/backend/bdd/features/pki/acme/internal-ca.feature b/backend/bdd/features/pki/acme/internal-ca.feature new file mode 100644 index 000000000..934b7bef3 --- /dev/null +++ b/backend/bdd/features/pki/acme/internal-ca.feature @@ -0,0 +1,33 @@ +Feature: Internal CA + + Scenario: CSR with SANs only + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 + """ + {} + """ + And 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 + 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 null + 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 index 9a55ae284..93fc3f981 100644 --- a/backend/bdd/features/pki/acme/nonce.feature +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -2,13 +2,13 @@ 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" + When I send a "HEAD" request to "/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -40,18 +40,18 @@ Feature: Nonce 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} | + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -65,13 +65,13 @@ Feature: Nonce 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" + When I send a raw ACME request to "/api/v1/cert-manager/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", + "url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders", "kid": "{acme_account.uri}" }, "payload": {} @@ -97,11 +97,11 @@ Feature: Nonce 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} | + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/cert-manager/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 index 19f467f00..199cd4aa6 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -2,7 +2,7 @@ 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -14,15 +14,15 @@ Feature: Order 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.uri with jq "." should match pattern {BASE_URL}/api/v1/cert-manager/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 + And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -52,7 +52,7 @@ Feature: Order 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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 @@ -67,21 +67,21 @@ Feature: 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 + And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/cert-manager/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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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" + When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/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", + "url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order", "kid": "{acme_account.uri}" }, "payload": { @@ -105,16 +105,16 @@ Feature: Order 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" + When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/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" + When I send a raw ACME request to "{BASE_URL}/api/v1/cert-manager/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", + "url": "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/new-order", "kid": "{acme_account.uri}" }, "payload": { diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 46b10c13e..c0b2fee8f 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -2,6 +2,8 @@ import json import logging import re import urllib.parse +import time +import threading import acme.client import jq @@ -18,6 +20,10 @@ 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.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric.types import ( + CertificateIssuerPrivateKeyTypes, +) from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes @@ -56,7 +62,7 @@ def step_impl(context: Context, profile_var: str): profile_slug = faker.slug() jwt_token = context.vars["AUTH_TOKEN"] response = context.http_client.post( - "/api/v1/pki/certificate-profiles", + "/api/v1/cert-manager/certificate-profiles", headers=dict(authorization="Bearer {}".format(jwt_token)), json={ "projectId": context.vars["PROJECT_ID"], @@ -74,7 +80,7 @@ def step_impl(context: Context, profile_var: str): kid = profile_id response = context.http_client.get( - f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", headers=dict(authorization="Bearer {}".format(jwt_token)), ) response.raise_for_status() @@ -147,13 +153,47 @@ def step_impl(context: Context, var_name: str): context.vars[var_name] = response +@given("I create a DNS Made Easy connection as {var_name}") +def step_impl(context: Context, var_name: str): + jwt_token = context.vars["AUTH_TOKEN"] + conn_slug = faker.slug() + with with_nocks( + context, + definitions=[ + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "GET", + "path": "/V2.0/dns/managed/", + "status": 200, + "response": {"totalRecords": 0, "totalPages": 1, "data": [], "page": 0}, + "responseIsBinary": False, + } + ], + ): + response = context.http_client.post( + "/api/v1/app-connections/dns-made-easy", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "name": conn_slug, + "description": "", + "method": "api-key-secret", + "credentials": { + "apiKey": "MOCK_API_KEY", + "secretKey": "MOCK_SECRET_KEY", + }, + }, + ) + 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", + "/api/v1/cert-manager/ca/acme", headers=dict(authorization="Bearer {}".format(jwt_token)), json={ "projectId": context.vars["PROJECT_ID"], @@ -174,7 +214,7 @@ def step_impl(context: Context, var_name: str): template_slug = faker.slug() config = replace_vars(json.loads(context.text), context.vars) response = context.http_client.post( - "/api/v2/certificate-templates", + "/api/v1/cert-manager/certificate-templates", headers=dict(authorization="Bearer {}".format(jwt_token)), json={ "projectId": context.vars["PROJECT_ID"], @@ -194,7 +234,7 @@ 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", + "/api/v1/cert-manager/certificate-profiles", headers=dict(authorization="Bearer {}".format(jwt_token)), json={ "projectId": context.vars["PROJECT_ID"], @@ -212,7 +252,7 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str): kid = profile_id response = context.http_client.get( - f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", headers=dict(authorization="Bearer {}".format(jwt_token)), ) response.raise_for_status() @@ -236,7 +276,7 @@ def step_impl(context: Context, profile_var: str): profile_slug = faker.slug() jwt_token = context.vars["AUTH_TOKEN"] response = context.http_client.post( - "/api/v1/pki/certificate-profiles", + "/api/v1/cert-manager/certificate-profiles", headers=dict(authorization="Bearer {}".format(jwt_token)), json={ "projectId": context.vars["PROJECT_ID"], @@ -254,7 +294,7 @@ def step_impl(context: Context, profile_var: str): kid = profile_id response = context.http_client.get( - f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", headers=dict(authorization="Bearer {}".format(jwt_token)), ) response.raise_for_status() @@ -387,6 +427,9 @@ def register_account_with_eab( ): acme_client = context.acme_client account_public_key = acme_client.net.key.public_key() + if not only_return_existing: + # clear the account in case if we want to register twice + acme_client.net.account = None if hasattr(context, "alt_eab_url"): eab_directory = messages.Directory.from_json( {"newAccount": context.alt_eab_url} @@ -406,8 +449,14 @@ def register_account_with_eab( only_return_existing=only_return_existing, ) try: - context.vars[account_var] = acme_client.new_account(registration) + if not only_return_existing: + context.vars[account_var] = acme_client.new_account(registration) + else: + context.vars[account_var] = acme_client.query_registration( + acme_client.net.account + ) except Exception as exp: + logger.error(f"Failed to register: {exp}", exc_info=True) context.vars["error"] = exp @@ -434,6 +483,17 @@ def step_impl(context: Context, email: str, kid: str, secret: str, account_var: ) +@then("I find the existing ACME account without EAB as {account_var}") +def step_impl(context: Context, account_var: str): + acme_client = context.acme_client + # registration = messages.RegistrationResource.from_json(dict(uri="")) + registration = acme_client.net.account + try: + context.vars[account_var] = acme_client.query_registration(registration) + except Exception as exp: + context.vars["error"] = exp + + @then("I register a new ACME account with email {email} without EAB") def step_impl(context: Context, email: str): acme_client = context.acme_client @@ -541,12 +601,57 @@ def step_impl(context: Context, csr_var: str): ) -@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, +def gen_private_key(key_type: str): + if key_type == "RSA-2048" or key_type == "RSA": + return rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + elif key_type == "RSA-3072": + return rsa.generate_private_key( + public_exponent=65537, + key_size=3072, + ) + elif key_type == "RSA-4096": + return rsa.generate_private_key( + public_exponent=65537, + key_size=4096, + ) + elif key_type == "ECDSA-P256": + return ec.generate_private_key(curve=ec.SECP256R1()) + elif key_type == "ECDSA-P384": + return ec.generate_private_key(curve=ec.SECP384R1()) + elif key_type == "ECDSA-P521": + return ec.generate_private_key(curve=ec.SECP521R1()) + else: + raise Exception(f"Unknown key type {key_type}") + + +@then("I create a {key_type} private key pair as {rsa_key_var}") +def step_impl(context: Context, key_type: str, rsa_key_var: str): + context.vars[rsa_key_var] = gen_private_key(key_type) + + +def sign_csr( + pem: x509.CertificateSigningRequestBuilder, + pk: CertificateIssuerPrivateKeyTypes, + hash_type: str = "SHA256", +): + return pem.sign(pk, getattr(hashes, hash_type)()).public_bytes( + serialization.Encoding.PEM + ) + + +@then( + 'I sign the certificate signing request {csr_var} with "{hash_type}" hash and private key {pk_var} and output it as {pem_var} in PEM format' +) +def step_impl( + context: Context, csr_var: str, hash_type: str, pk_var: str, pem_var: str +): + context.vars[pem_var] = sign_csr( + pem=context.vars[csr_var], + pk=context.vars[pk_var], + hash_type=hash_type, ) @@ -554,10 +659,9 @@ def step_impl(context: Context, rsa_key_var: str): "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) + context.vars[pem_var] = sign_csr( + pem=context.vars[csr_var], + pk=context.vars[pk_var], ) @@ -600,6 +704,19 @@ def step_impl(context: Context, var_path: str, jq_query: str): ) +@then("the value {var_path} with should be absent") +def step_impl(context: Context, var_path: str): + try: + value = eval_var(context, var_path) + except Exception as exp: + if isinstance(exp, KeyError): + return + raise + assert False, ( + f"value at {var_path!r} should be absent, but we got this instead: {value!r}" + ) + + @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( @@ -615,13 +732,14 @@ def step_impl(context: Context, var_path: str, jq_query: str, expected: str): @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): + actual_regex = replace_vars(regex, context.vars) 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}" + assert re.match(actual_regex, result), ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {actual_regex!r}" ) @@ -656,6 +774,15 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str): context.vars[var_name] = value +@then("I get a new-nonce as {var_name}") +def step_impl(context: Context, var_name: str): + acme_client = context.acme_client + nonce = acme_client.net._get_nonce( + url=None, new_nonce_url=acme_client.directory.newNonce + ) + context.vars[var_name] = json_util.encode_b64jose(nonce) + + @then("I peak and memorize the next nonce as {var_name}") def step_impl(context: Context, var_name: str): acme_client = context.acme_client @@ -729,22 +856,39 @@ def select_challenge( return challenges[0] -def serve_challenge( +def serve_challenges( context: Context, - challenge: messages.ChallengeBody, + challenges: list[messages.ChallengeBody], + wait_time: int | None = None, ): 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 - ) + resources = set() + for challenge in challenges: + response, validation = challenge.response_and_validation( + context.acme_client.net.key + ) + resources.add( + 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() + servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), resources) + if wait_time is None: + servers.serve_forever() + else: + + def wait_and_start(): + logger.info("Waiting %s seconds before we start serving.", wait_time) + time.sleep(wait_time) + logger.info("Start server now") + servers.serve_forever() + + thread = threading.Thread(target=wait_and_start) + thread.daemon = True + thread.start() context.web_server = servers @@ -797,6 +941,7 @@ def step_impl( f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}" ) + challenges = {} for domain in order.body.identifiers: logger.info( "Selecting challenge for domain %s with type %s ...", @@ -821,18 +966,28 @@ def step_impl( domain.value, challenge_type, ) - serve_challenge(context=context, challenge=challenge) + challenges[domain] = challenge + serve_challenges(context=context, challenges=list(challenges.values())) + for domain, challenge in challenges.items(): logger.info( "Notifying challenge for domain %s with type %s ...", domain, challenge_type ) notify_challenge_ready(context=context, challenge=challenge) +@then( + "I wait {wait_time} seconds and serve challenge response for {var_path} at {hostname}" +) +def step_impl(context: Context, wait_time: str, var_path: str, hostname: str): + challenge = eval_var(context, var_path, as_json=False) + serve_challenges(context=context, challenges=[challenge], wait_time=int(wait_time)) + + @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) + serve_challenges(context=context, challenges=[challenge]) @then("I tell ACME server that {var_path} is ready to be verified") @@ -841,12 +996,57 @@ def step_impl(context: Context, var_path: str): notify_challenge_ready(context=context, challenge=challenge) +@then("I wait until the status of order {order_var} becomes {status}") +def step_impl(context: Context, order_var: str, status: str): + acme_client = context.acme_client + attempt_count = 6 + while attempt_count: + order = eval_var(context, order_var, as_json=False) + response = acme_client._post_as_get( + order.uri if isinstance(order, messages.OrderResource) else order + ) + order = messages.Order.from_json(response.json()) + if order.status.name == status: + return + attempt_count -= 1 + time.sleep(10) + raise TimeoutError(f"The status of order doesn't become {status} before timeout") + + +@then("I wait until the status of authorization {auth_var} becomes {status}") +def step_impl(context: Context, auth_var: str, status: str): + acme_client = context.acme_client + attempt_count = 6 + while attempt_count: + auth = eval_var(context, auth_var, as_json=False) + response = acme_client._post_as_get( + auth.uri if isinstance(auth, messages.Authorization) else auth + ) + auth = messages.Authorization.from_json(response.json()) + if auth.status.name == status: + return + attempt_count -= 1 + time.sleep(10) + raise TimeoutError(f"The status of auth doesn't become {status} before timeout") + + +@then("I post-as-get {uri} as {resp_var}") +def step_impl(context: Context, uri: str, resp_var: str): + acme_client = context.acme_client + response = acme_client._post_as_get(replace_vars(uri, vars=context.vars)) + context.vars[resp_var] = response.json() + + @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 + try: + finalized_order = acme_client.poll_and_finalize(order) + context.vars[finalized_var] = finalized_order + except Exception as exp: + logger.error(f"Failed to finalize order: {exp}", exc_info=True) + context.vars["error"] = exp @then("I parse the full-chain certificate from order {order_var_path} as {cert_var}") diff --git a/backend/bdd/features/steps/utils.py b/backend/bdd/features/steps/utils.py index 4ee7c8921..93269d8bc 100644 --- a/backend/bdd/features/steps/utils.py +++ b/backend/bdd/features/steps/utils.py @@ -15,6 +15,7 @@ from josepy import JSONObjectWithFields ACC_KEY_BITS = 2048 ACC_KEY_PUBLIC_EXPONENT = 65537 +NOCK_API_PREFIX = "/api/__bdd_nock__" logger = logging.getLogger(__name__) faker = Faker() @@ -265,7 +266,7 @@ def x509_cert_to_dict(cert: x509.Certificate) -> dict: def define_nock(context: Context, definitions: list[dict]): jwt_token = context.vars["AUTH_TOKEN"] response = context.http_client.post( - "/api/v1/bdd-nock/define", + f"{NOCK_API_PREFIX}/define", headers=dict(authorization="Bearer {}".format(jwt_token)), json=dict(definitions=definitions), ) @@ -275,7 +276,7 @@ def define_nock(context: Context, definitions: list[dict]): def restore_nock(context: Context): jwt_token = context.vars["AUTH_TOKEN"] response = context.http_client.post( - "/api/v1/bdd-nock/restore", + f"{NOCK_API_PREFIX}/restore", headers=dict(authorization="Bearer {}".format(jwt_token)), json=dict(), ) @@ -285,7 +286,7 @@ def restore_nock(context: Context): def clean_all_nock(context: Context): jwt_token = context.vars["AUTH_TOKEN"] response = context.http_client.post( - "/api/v1/bdd-nock/clean-all", + f"{NOCK_API_PREFIX}/clean-all", headers=dict(authorization="Bearer {}".format(jwt_token)), json=dict(), ) diff --git a/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts b/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts index 6244cf735..d67dc1d5e 100644 --- a/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts +++ b/backend/e2e-test/routes/v1/secret-approval-policy.spec.ts @@ -1,7 +1,12 @@ import { seedData1 } from "@app/db/seed-data"; import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; -const createPolicy = async (dto: { name: string; secretPath: string; approvers: {type: ApproverType.User, id: string}[]; approvals: number }) => { +const createPolicy = async (dto: { + name: string; + secretPath: string; + approvers: { type: ApproverType.User; id: string }[]; + approvals: number; +}) => { const res = await testServer.inject({ method: "POST", url: `/api/v1/secret-approvals`, @@ -27,7 +32,7 @@ describe("Secret approval policy router", async () => { const policy = await createPolicy({ secretPath: "/", approvals: 1, - approvers: [{id:seedData1.id, type: ApproverType.User}], + approvers: [{ id: seedData1.id, type: ApproverType.User }], name: "test-policy" }); diff --git a/backend/nodemon.json b/backend/nodemon.json index 856f9ee51..2542bca4d 100644 --- a/backend/nodemon.json +++ b/backend/nodemon.json @@ -1,6 +1,8 @@ { - "watch": ["src"], + "watch": [ + "src" + ], "ext": ".ts,.js", "ignore": [], - "exec": "tsx ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine" -} + "exec": "tsx --tsconfig=./tsconfig.dev.json --inspect=0.0.0.0:9229 ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine" +} \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index a871c38b1..808cf3204 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -128,6 +128,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -164,6 +165,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -15634,6 +15636,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sshpk": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz", @@ -18061,6 +18090,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -18901,6 +18939,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -24996,9 +25048,9 @@ } }, "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", "license": "MIT" }, "node_modules/nanoid": { @@ -31492,6 +31544,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/backend/package.json b/backend/package.json index aa97de2ed..4f9cfdc97 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "outputPath": "binary" }, "scripts": { + "assets:export": "./scripts/export-assets.sh", "binary:build": "npm run binary:clean && npm run build:frontend && npm run build && npm run binary:babel-frontend && npm run binary:babel-backend && npm run binary:rename-imports", "binary:package": "pkg --no-bytecode --public-packages \"*\" --public --target host .", "binary:babel-backend": " babel ./dist -d ./dist", @@ -32,7 +33,7 @@ "binary:clean": "rm -rf ./dist && rm -rf ./binary", "binary:rename-imports": "ts-node ./scripts/rename-mjs.ts", "test": "echo \"Error: no test specified\" && exit 1", - "dev": "tsx watch --clear-screen=false ./src/main.ts | pino-pretty --colorize --colorizeObjects --singleLine", + "dev": "tsx watch --clear-screen=false ./src/main.ts --config tsconfig.dev.json | pino-pretty --colorize --colorizeObjects --singleLine", "dev:docker": "nodemon", "build": "tsup --sourcemap", "build:frontend": "npm run build --prefix ../frontend", @@ -110,6 +111,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -257,6 +259,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -264,4 +267,4 @@ "zod": "^3.22.4", "zod-to-json-schema": "^3.24.5" } -} +} \ No newline at end of file diff --git a/backend/scripts/create-migration.ts b/backend/scripts/create-migration.ts index 34f4aca41..3e34b9413 100644 --- a/backend/scripts/create-migration.ts +++ b/backend/scripts/create-migration.ts @@ -2,7 +2,7 @@ import { execSync } from "child_process"; import path from "path"; import promptSync from "prompt-sync"; -import slugify from "@sindresorhus/slugify" +import slugify from "@sindresorhus/slugify"; const prompt = promptSync({ sigint: true }); diff --git a/backend/scripts/export-assets.sh b/backend/scripts/export-assets.sh new file mode 100644 index 000000000..149700579 --- /dev/null +++ b/backend/scripts/export-assets.sh @@ -0,0 +1,75 @@ +#!/bin/sh +# Export frontend static assets for CDN deployment +# Usage: +# npm run assets:export - Output tar to stdout (pipe to file or aws s3) +# npm run assets:export /path - Extract assets to specified directory +# npm run assets:export -- --help - Show usage + +set -e + +ASSETS_PATH="/backend/frontend-build/assets" + +show_help() { + cat << 'EOF' +Export frontend static assets for CDN deployment. + +USAGE: + docker run --rm infisical/infisical npm run --silent assets:export [-- OPTIONS] [PATH] + +OPTIONS: + --help, -h Show this help message + +ARGUMENTS: + PATH Directory to export assets to. If not provided, outputs + a tar archive to stdout. + +NOTE: + Use --silent flag to suppress npm output when piping to stdout. + +EXAMPLES: + # Export as tar to local file + docker run --rm infisical/infisical npm run --silent assets:export > assets.tar + + # Extract to local directory + docker run --rm -v $(pwd)/cdn-assets:/output infisical/infisical npm run --silent assets:export /output + +EOF + exit 0 +} + +# Check for help flag +case "${1:-}" in + --help|-h) + show_help + ;; +esac + +# Verify assets exist +if [ ! -d "$ASSETS_PATH" ]; then + echo "Error: Assets directory not found at $ASSETS_PATH" >&2 + echo "Make sure the frontend is built and included in the image." >&2 + exit 1 +fi + +ASSET_COUNT=$(find "$ASSETS_PATH" -type f | wc -l | tr -d ' ') + +if [ $# -eq 0 ]; then + # No path provided - output tar to stdout + echo "Exporting $ASSET_COUNT assets as tar archive to stdout..." >&2 + tar -cf - -C "$(dirname "$ASSETS_PATH")" "$(basename "$ASSETS_PATH")" +else + # Path provided - extract to directory + OUTPUT_PATH="$1" + + if [ ! -d "$OUTPUT_PATH" ]; then + echo "Creating output directory: $OUTPUT_PATH" >&2 + mkdir -p "$OUTPUT_PATH" + fi + + echo "Exporting $ASSET_COUNT assets to $OUTPUT_PATH..." >&2 + cp -r "$ASSETS_PATH"/* "$OUTPUT_PATH/" + + echo "✅ Assets exported successfully!" >&2 + echo " Path: $OUTPUT_PATH" >&2 + echo " Files: $ASSET_COUNT assets" >&2 +fi diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 6ef775f90..02394de4d 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; @@ -288,6 +289,7 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateV3: TCertificateV3ServiceFactory; + certificateRequest: TCertificateRequestServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; certificateTemplateV2: TCertificateTemplateV2ServiceFactory; certificateProfile: TCertificateProfileServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 603df5f6c..4bdd3849d 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -573,6 +573,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate +} from "@app/db/schemas/certificate-requests"; import { TAccessApprovalPoliciesEnvironments, TAccessApprovalPoliciesEnvironmentsInsert, @@ -714,6 +719,11 @@ declare module "knex/types/tables" { TExternalCertificateAuthoritiesUpdate >; [TableName.Certificate]: KnexOriginal.CompositeTableType; + [TableName.CertificateRequests]: KnexOriginal.CompositeTableType< + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate + >; [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< TCertificateTemplates, TCertificateTemplatesInsert, diff --git a/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts index 7a629ca52..bb9e3ac9a 100644 --- a/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts +++ b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts @@ -14,13 +14,16 @@ export async function up(knex: Knex): Promise { if (rows.length > 0) { for (let i = 0; i < rows.length; i += BATCH_SIZE) { const batch = rows.slice(i, i + BATCH_SIZE); + const ids = batch.map((row) => row.id); // eslint-disable-next-line no-await-in-loop - await knex(TableName.SecretApprovalPolicy) - .whereIn( - "id", - batch.map((row) => row.id) - ) - .update({ shouldCheckSecretPermission: true }); + await knex.raw( + ` + UPDATE ?? + SET ?? = true + WHERE ?? IN (${ids.map(() => "?").join(",")}) + `, + [TableName.SecretApprovalPolicy, "shouldCheckSecretPermission", "id", ids] + ); } } } diff --git a/backend/src/db/migrations/20251119025017_add-unique-constraint-for-pki-acme-account-public-key-and-profile-id.ts b/backend/src/db/migrations/20251119025017_add-unique-constraint-for-pki-acme-account-public-key-and-profile-id.ts new file mode 100644 index 000000000..5bc4601e3 --- /dev/null +++ b/backend/src/db/migrations/20251119025017_add-unique-constraint-for-pki-acme-account-public-key-and-profile-id.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; +import { TableName } from "@app/db/schemas"; + +const CONSTRAINT_NAME = "unique_pki_acme_account_public_key_and_profile_id"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) { + const hasProfileId = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "profileId"); + const hasPublicKeyThumbprint = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "publicKeyThumbprint"); + + if (hasProfileId && hasPublicKeyThumbprint) { + await knex.schema.alterTable(TableName.PkiAcmeAccount, (table) => { + table.unique(["profileId", "publicKeyThumbprint"], { indexName: CONSTRAINT_NAME }); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) { + const hasProfileId = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "profileId"); + const hasPublicKeyThumbprint = await knex.schema.hasColumn(TableName.PkiAcmeAccount, "publicKeyThumbprint"); + + await knex.schema.alterTable(TableName.PkiAcmeAccount, async () => { + if (hasProfileId && hasPublicKeyThumbprint) { + await dropConstraintIfExists(TableName.PkiAcmeAccount, CONSTRAINT_NAME, knex); + } + }); + } +} diff --git a/backend/src/db/migrations/20251119213350_remove-should-check-secret-permission.ts b/backend/src/db/migrations/20251119213350_remove-should-check-secret-permission.ts new file mode 100644 index 000000000..7c1758095 --- /dev/null +++ b/backend/src/db/migrations/20251119213350_remove-should-check-secret-permission.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("shouldCheckSecretPermission"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission"))) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.boolean("shouldCheckSecretPermission").nullable(); + }); + } +} diff --git a/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts new file mode 100644 index 000000000..61dcdb12e --- /dev/null +++ b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (!hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("issuerType").notNullable().defaultTo("ca"); + }); + } + + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("caId").nullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropColumn("issuerType"); + }); + } +} diff --git a/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts b/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts new file mode 100644 index 000000000..00dcf7902 --- /dev/null +++ b/backend/src/db/migrations/20251126143442_add-notification-flag-scim-token.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent"); + if (!hasCol) { + await knex.schema.alterTable(TableName.ScimToken, (t) => { + t.boolean("expiryNotificationSent").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasCol = await knex.schema.hasColumn(TableName.ScimToken, "expiryNotificationSent"); + if (hasCol) { + await knex.schema.alterTable(TableName.ScimToken, (t) => { + t.dropColumn("expiryNotificationSent"); + }); + } +} diff --git a/backend/src/db/migrations/20251127120000_add-certificate-requests.ts b/backend/src/db/migrations/20251127120000_add-certificate-requests.ts new file mode 100644 index 000000000..32944c37d --- /dev/null +++ b/backend/src/db/migrations/20251127120000_add-certificate-requests.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.CertificateRequests))) { + await knex.schema.createTable(TableName.CertificateRequests, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("status").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("profileId").nullable(); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.uuid("caId").nullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL"); + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.text("csr").nullable(); + t.string("commonName").nullable(); + t.text("altNames").nullable(); + t.specificType("keyUsages", "text[]").nullable(); + t.specificType("extendedKeyUsages", "text[]").nullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); + t.string("keyAlgorithm").nullable(); + t.string("signatureAlgorithm").nullable(); + t.text("errorMessage").nullable(); + t.text("metadata").nullable(); + + t.index(["projectId"]); + t.index(["status"]); + t.index(["profileId"]); + t.index(["caId"]); + t.index(["certificateId"]); + t.index(["createdAt"]); + }); + } + + await createOnUpdateTrigger(knex, TableName.CertificateRequests); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateRequests); + await dropOnUpdateTrigger(knex, TableName.CertificateRequests); +} diff --git a/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts b/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts new file mode 100644 index 000000000..86ad4f5b4 --- /dev/null +++ b/backend/src/db/migrations/20251128120000_add-pki-profile-external-configs.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs"); + if (!hasExternalConfigs) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.text("externalConfigs").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs"); + if (hasExternalConfigs) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropColumn("externalConfigs"); + }); + } +} diff --git a/backend/src/db/schemas/certificate-requests.ts b/backend/src/db/schemas/certificate-requests.ts new file mode 100644 index 000000000..e01e08bbd --- /dev/null +++ b/backend/src/db/schemas/certificate-requests.ts @@ -0,0 +1,34 @@ +// 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 CertificateRequestsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + status: z.string(), + projectId: z.string(), + profileId: z.string().uuid().nullable().optional(), + caId: z.string().uuid().nullable().optional(), + certificateId: z.string().uuid().nullable().optional(), + csr: z.string().nullable().optional(), + commonName: z.string().nullable().optional(), + altNames: z.string().nullable().optional(), + keyUsages: z.string().array().nullable().optional(), + extendedKeyUsages: z.string().array().nullable().optional(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional(), + keyAlgorithm: z.string().nullable().optional(), + signatureAlgorithm: z.string().nullable().optional(), + errorMessage: z.string().nullable().optional(), + metadata: z.string().nullable().optional() +}); + +export type TCertificateRequests = z.infer; +export type TCertificateRequestsInsert = Omit, TImmutableDBKeys>; +export type TCertificateRequestsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 78dcb1980..7db6e847d 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -16,6 +16,7 @@ export * from "./certificate-authority-certs"; export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; +export * from "./certificate-requests"; export * from "./certificate-secrets"; export * from "./certificate-syncs"; export * from "./certificate-template-est-configs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 444a6bd97..040d6e278 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -21,6 +21,7 @@ export enum TableName { CertificateAuthorityCrl = "certificate_authority_crl", Certificate = "certificates", CertificateBody = "certificate_bodies", + CertificateRequests = "certificate_requests", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", PkiCertificateTemplateV2 = "pki_certificate_templates_v2", diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 04560bec6..0cf9cf160 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiCertificateProfilesSchema = z.object({ id: z.string().uuid(), projectId: z.string(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z.string(), description: z.string().nullable().optional(), @@ -19,7 +19,9 @@ export const PkiCertificateProfilesSchema = z.object({ apiConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - acmeConfigId: z.string().uuid().nullable().optional() + acmeConfigId: z.string().uuid().nullable().optional(), + issuerType: z.string().default("ca"), + externalConfigs: z.string().nullable().optional() }); export type TPkiCertificateProfiles = z.infer; diff --git a/backend/src/db/schemas/scim-tokens.ts b/backend/src/db/schemas/scim-tokens.ts index ab6e10d27..6774b6bfd 100644 --- a/backend/src/db/schemas/scim-tokens.ts +++ b/backend/src/db/schemas/scim-tokens.ts @@ -13,7 +13,8 @@ export const ScimTokensSchema = z.object({ description: z.string(), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + expiryNotificationSent: z.boolean().default(false).nullable().optional() }); export type TScimTokens = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index dbb881db3..0273e617c 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -17,8 +17,7 @@ export const SecretApprovalPoliciesSchema = z.object({ updatedAt: z.date(), enforcementLevel: z.string().default("hard"), deletedAt: z.date().nullable().optional(), - allowedSelfApprovals: z.boolean().default(true), - shouldCheckSecretPermission: z.boolean().nullable().optional() + allowedSelfApprovals: z.boolean().default(true) }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/ee/routes/v1/external-kms-router.ts b/backend/src/ee/routes/v1/external-kms-router.ts index a48e28e3d..b46b525fe 100644 --- a/backend/src/ee/routes/v1/external-kms-router.ts +++ b/backend/src/ee/routes/v1/external-kms-router.ts @@ -4,15 +4,10 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ExternalKmsAwsSchema, - ExternalKmsGcpCredentialSchema, ExternalKmsGcpSchema, ExternalKmsInputSchema, - ExternalKmsInputUpdateSchema, - KmsGcpKeyFetchAuthType, - KmsProviders, - TExternalKmsGcpCredentialSchema + ExternalKmsInputUpdateSchema } from "@app/ee/services/external-kms/providers/model"; -import { NotFoundError } from "@app/lib/errors"; 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"; @@ -293,67 +288,4 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { return { externalKms }; } }); - - server.route({ - method: "POST", - url: "/gcp/keys", - config: { - rateLimit: writeLimit - }, - schema: { - body: z.discriminatedUnion("authMethod", [ - z.object({ - authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential), - region: z.string().trim().min(1), - credential: ExternalKmsGcpCredentialSchema - }), - z.object({ - authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms), - region: z.string().trim().min(1), - kmsId: z.string().trim().min(1) - }) - ]), - response: { - 200: z.object({ - keys: z.string().array() - }) - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const { region, authMethod } = req.body; - let credentialJson: TExternalKmsGcpCredentialSchema | undefined; - - if (authMethod === KmsGcpKeyFetchAuthType.Credential) { - credentialJson = req.body.credential; - } else if (authMethod === KmsGcpKeyFetchAuthType.Kms) { - const externalKms = await server.services.externalKms.findById({ - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId, - id: req.body.kmsId - }); - - if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) { - throw new NotFoundError({ message: "KMS not found or not of type GCP" }); - } - - credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema; - } - - if (!credentialJson) { - throw new NotFoundError({ - message: "Something went wrong while fetching the GCP credential, please check inputs and try again" - }); - } - - const results = await server.services.externalKms.fetchGcpKeys({ - credential: credentialJson, - gcpRegion: region - }); - - return results; - } - }); }; diff --git a/backend/src/ee/routes/v1/external-kms-routers/aws-kms-router.ts b/backend/src/ee/routes/v1/external-kms-routers/aws-kms-router.ts new file mode 100644 index 000000000..518b7947e --- /dev/null +++ b/backend/src/ee/routes/v1/external-kms-routers/aws-kms-router.ts @@ -0,0 +1,12 @@ +import { ExternalKmsAwsSchema, KmsProviders } from "@app/ee/services/external-kms/providers/model"; + +import { registerExternalKmsEndpoints } from "./external-kms-endpoints"; + +export const registerAwsKmsRouter = async (server: FastifyZodProvider) => { + registerExternalKmsEndpoints({ + server, + provider: KmsProviders.Aws, + createSchema: ExternalKmsAwsSchema, + updateSchema: ExternalKmsAwsSchema.partial() + }); +}; diff --git a/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts b/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts new file mode 100644 index 000000000..47b4947f2 --- /dev/null +++ b/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts @@ -0,0 +1,288 @@ +import { z } from "zod"; + +import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + KmsProviders, + SanitizedExternalKmsAwsSchema, + SanitizedExternalKmsGcpSchema, + TExternalKmsInputSchema, + TExternalKmsInputUpdateSchema +} from "@app/ee/services/external-kms/providers/model"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +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 sanitizedExternalSchema = KmsKeysSchema.extend({ + externalKms: ExternalKmsSchema.pick({ + id: true, + status: true, + statusDetails: true, + provider: true + }).extend({ + configuration: z.union([SanitizedExternalKmsAwsSchema, SanitizedExternalKmsGcpSchema]), + credentialsHash: z.string().optional() + }) +}); + +export const registerExternalKmsEndpoints = < + T extends { type: KmsProviders; inputs: TExternalKmsInputSchema["inputs"] } +>({ + server, + provider, + createSchema, + updateSchema +}: { + server: FastifyZodProvider; + provider: T["type"]; + createSchema: z.ZodType; + updateSchema: z.ZodType>; +}) => { + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + id: z.string().trim().min(1) + }), + response: { + 200: sanitizedExternalSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const externalKms = await server.services.externalKms.findById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + // Validate that the KMS is of the expected provider type + if (externalKms.external.provider !== provider) { + throw new BadRequestError({ + message: `KMS provider mismatch. Expected ${provider}, got ${externalKms.external.provider}` + }); + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.GET_KMS, + metadata: { + kmsId: externalKms.id, + name: externalKms.name + } + } + }); + + const { + external: { providerInput: configuration, ...externalKmsData }, + ...rest + } = externalKms; + + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(externalKmsData.encryptedProviderInputs) + .digest("hex"); + return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + name: z.string().min(1).trim().toLowerCase(), + description: z.string().trim().optional(), + configuration: createSchema + }), + response: { + 200: sanitizedExternalSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, description, configuration } = req.body as { + name: string; + description?: string; + configuration: T["inputs"]; + }; + + const providerInput = { + type: provider, + inputs: configuration + } as TExternalKmsInputSchema; + + const externalKms = await server.services.externalKms.create({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name, + provider: providerInput, + description + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.CREATE_KMS, + metadata: { + kmsId: externalKms.id, + provider, + name, + description + } + } + }); + + const { + external: { providerInput: externalKmsConfiguration, ...externalKmsData }, + ...rest + } = externalKms; + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(externalKmsData.encryptedProviderInputs) + .digest("hex"); + return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string().trim().min(1) + }), + body: z.object({ + name: z.string().min(1).trim().toLowerCase().optional(), + description: z.string().trim().optional(), + configuration: updateSchema.optional() + }), + response: { + 200: sanitizedExternalSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { name, description, configuration } = req.body as { + name?: string; + description?: string; + configuration: Partial; + }; + + const providerInput = { + type: provider, + inputs: configuration + } as TExternalKmsInputUpdateSchema; + + const externalKms = await server.services.externalKms.updateById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + name, + provider: providerInput, + description, + id: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.UPDATE_KMS, + metadata: { + kmsId: externalKms.id, + provider, + name, + description + } + } + }); + + const { + external: { providerInput: externalKmsConfiguration, ...externalKmsData }, + ...rest + } = externalKms; + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(externalKmsData.encryptedProviderInputs) + .digest("hex"); + return { ...rest, externalKms: { ...externalKmsData, configuration: externalKmsConfiguration, credentialsHash } }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string().trim().min(1) + }), + response: { + 200: sanitizedExternalSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const externalKms = await server.services.externalKms.deleteById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + + // Validate that the KMS is of the expected provider type + if (externalKms.external.provider !== provider) { + throw new BadRequestError({ + message: `KMS provider mismatch. Expected ${provider}, got ${externalKms.external.provider}` + }); + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + event: { + type: EventType.DELETE_KMS, + metadata: { + kmsId: externalKms.id, + name: externalKms.name + } + } + }); + + const { + external: { providerInput: configuration, ...externalKmsData }, + ...rest + } = externalKms; + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(externalKmsData.encryptedProviderInputs) + .digest("hex"); + + return { ...rest, externalKms: { ...externalKmsData, configuration, credentialsHash } }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/external-kms-routers/gcp-kms-router.ts b/backend/src/ee/routes/v1/external-kms-routers/gcp-kms-router.ts new file mode 100644 index 000000000..97b600c10 --- /dev/null +++ b/backend/src/ee/routes/v1/external-kms-routers/gcp-kms-router.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; + +import { + ExternalKmsGcpCredentialSchema, + ExternalKmsGcpSchema, + KmsGcpKeyFetchAuthType, + KmsProviders, + TExternalKmsGcpCredentialSchema +} from "@app/ee/services/external-kms/providers/model"; +import { NotFoundError } from "@app/lib/errors"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerExternalKmsEndpoints } from "./external-kms-endpoints"; + +export const registerGcpKmsRouter = async (server: FastifyZodProvider) => { + registerExternalKmsEndpoints({ + server, + provider: KmsProviders.Gcp, + createSchema: ExternalKmsGcpSchema, + updateSchema: ExternalKmsGcpSchema.partial() + }); + + server.route({ + method: "POST", + url: "/keys", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential), + region: z.string().trim().min(1), + credential: ExternalKmsGcpCredentialSchema + }), + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms), + region: z.string().trim().min(1), + kmsId: z.string().trim().min(1) + }) + ]), + response: { + 200: z.object({ + keys: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { region, authMethod } = req.body; + let credentialJson: TExternalKmsGcpCredentialSchema | undefined; + + if (authMethod === KmsGcpKeyFetchAuthType.Credential && "credential" in req.body) { + credentialJson = req.body.credential; + } else if (authMethod === KmsGcpKeyFetchAuthType.Kms && "kmsId" in req.body) { + const externalKms = await server.services.externalKms.findById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.body.kmsId + }); + + if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) { + throw new NotFoundError({ message: "KMS not found or not of type GCP" }); + } + + const providerInput = externalKms.external.providerInput as { credential: TExternalKmsGcpCredentialSchema }; + credentialJson = providerInput.credential; + } + + if (!credentialJson) { + throw new NotFoundError({ + message: "Something went wrong while fetching the GCP credential, please check inputs and try again" + }); + } + + const results = await server.services.externalKms.fetchGcpKeys({ + credential: credentialJson, + gcpRegion: region + }); + + return results; + } + }); +}; diff --git a/backend/src/ee/routes/v1/external-kms-routers/index.ts b/backend/src/ee/routes/v1/external-kms-routers/index.ts new file mode 100644 index 000000000..da70b0f59 --- /dev/null +++ b/backend/src/ee/routes/v1/external-kms-routers/index.ts @@ -0,0 +1,9 @@ +import { KmsProviders } from "@app/ee/services/external-kms/providers/model"; + +import { registerAwsKmsRouter } from "./aws-kms-router"; +import { registerGcpKmsRouter } from "./gcp-kms-router"; + +export const EXTERNAL_KMS_REGISTER_ROUTER_MAP: Record Promise> = { + [KmsProviders.Aws]: registerAwsKmsRouter, + [KmsProviders.Gcp]: registerGcpKmsRouter +}; diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index ec235d34e..4696bef26 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -1,8 +1,14 @@ import { z } from "zod"; -import { GroupsSchema, OrgMembershipRole, UsersSchema } from "@app/db/schemas"; -import { EFilterReturnedUsers } from "@app/ee/services/group/group-types"; +import { GroupsSchema, OrgMembershipRole, ProjectsSchema, UsersSchema } from "@app/db/schemas"; +import { + EFilterReturnedProjects, + EFilterReturnedUsers, + EGroupProjectsOrderBy +} from "@app/ee/services/group/group-types"; import { ApiDocsTags, GROUPS } from "@app/lib/api-docs"; +import { OrderByDirection } from "@app/lib/types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -11,6 +17,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "POST", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -40,6 +49,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "GET", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -69,6 +81,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/", method: "GET", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -93,6 +108,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "PATCH", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -128,6 +146,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ url: "/:id", method: "DELETE", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -155,6 +176,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", url: "/:id/users", + config: { + rateLimit: readLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -163,7 +187,7 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(GROUPS.LIST_USERS.id) }), querystring: z.object({ - offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_USERS.offset), limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), username: z.string().trim().optional().describe(GROUPS.LIST_USERS.username), search: z.string().trim().optional().describe(GROUPS.LIST_USERS.search), @@ -203,9 +227,72 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:id/projects", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Groups], + params: z.object({ + id: z.string().trim().describe(GROUPS.LIST_PROJECTS.id) + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(GROUPS.LIST_PROJECTS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_PROJECTS.limit), + search: z.string().trim().optional().describe(GROUPS.LIST_PROJECTS.search), + filter: z.nativeEnum(EFilterReturnedProjects).optional().describe(GROUPS.LIST_PROJECTS.filterProjects), + orderBy: z + .nativeEnum(EGroupProjectsOrderBy) + .default(EGroupProjectsOrderBy.Name) + .describe(GROUPS.LIST_PROJECTS.orderBy), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(GROUPS.LIST_PROJECTS.orderDirection) + }), + response: { + 200: z.object({ + projects: ProjectsSchema.pick({ + id: true, + name: true, + slug: true, + description: true, + type: true + }) + .merge( + z.object({ + joinedGroupAt: z.date().nullable() + }) + ) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { projects, totalCount } = await server.services.group.listGroupProjects({ + id: req.params.id, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return { projects, totalCount }; + } + }); + server.route({ method: "POST", url: "/:id/users/:username", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, @@ -241,6 +328,9 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", url: "/:id/users/:username", + config: { + rateLimit: writeLimit + }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { hide: false, diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 7ff9ec09a..bc3a4f602 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -12,6 +12,8 @@ import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router" import { registerKubernetesDynamicSecretLeaseRouter } from "./dynamic-secret-lease-routers/kubernetes-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerExternalKmsRouter } from "./external-kms-router"; + +import { EXTERNAL_KMS_REGISTER_ROUTER_MAP } from "./external-kms-routers"; import { registerGatewayRouter } from "./gateway-router"; import { registerGithubOrgSyncRouter } from "./github-org-sync-router"; import { registerGroupRouter } from "./group-router"; @@ -110,7 +112,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); }, - { prefix: "/pki" } + { prefix: "/cert-manager" } ); await server.register( @@ -162,9 +164,19 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/additional-privilege" } ); - await server.register(registerExternalKmsRouter, { - prefix: "/external-kms" - }); + await server.register( + async (externalKmsRouter) => { + await externalKmsRouter.register(registerExternalKmsRouter); + + // Provider-specific endpoints + await Promise.all( + Object.entries(EXTERNAL_KMS_REGISTER_ROUTER_MAP).map(([provider, router]) => + externalKmsRouter.register(router, { prefix: `/${provider}` }) + ) + ); + }, + { prefix: "/external-kms" } + ); await server.register(registerIdentityTemplateRouter, { prefix: "/identity-templates" }); await server.register(registerProjectTemplateRouter, { prefix: "/project-templates" }); diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 60d621467..d3aadd5a4 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -9,6 +9,11 @@ import { SanitizedPostgresAccountWithResourceSchema, UpdatePostgresAccountSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateSSHAccountSchema, + SanitizedSSHAccountWithResourceSchema, + UpdateSSHAccountSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-account-endpoints"; @@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.SSH, + accountResponseSchema: SanitizedSSHAccountWithResourceSchema, + createAccountSchema: CreateSSHAccountSchema, + updateAccountSchema: UpdateSSHAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 286e0896f..74bd5eeb1 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -2,16 +2,21 @@ import { z } from "zod"; import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { PamAccountOrderBy, PamAccountView } from "@app/ee/services/pam-account/pam-account-enums"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; +import { OrderByDirection } from "@app/lib/types"; 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 SanitizedAccountSchema = z.union([ + SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, SanitizedMySQLAccountWithResourceSchema ]); @@ -26,33 +31,69 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { schema: { description: "List PAM accounts", querystring: z.object({ - projectId: z.string().uuid() + projectId: z.string().uuid(), + accountPath: z.string().trim().default("/").transform(removeTrailingSlash), + accountView: z.nativeEnum(PamAccountView).default(PamAccountView.Flat), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(100), + orderBy: z.nativeEnum(PamAccountOrderBy).default(PamAccountOrderBy.Name), + orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC), + search: z.string().trim().optional(), + filterResourceIds: z + .string() + .transform((val) => + val + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + ) + .optional() }), response: { 200: z.object({ accounts: SanitizedAccountSchema.array(), - folders: PamFoldersSchema.array() + folders: PamFoldersSchema.array(), + totalCount: z.number().default(0), + folderId: z.string().optional(), + folderPaths: z.record(z.string(), z.string()) }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const response = await server.services.pamAccount.list(req.query.projectId, req.permission); + const { projectId, accountPath, accountView, limit, offset, search, orderBy, orderDirection, filterResourceIds } = + req.query; + + const { accounts, folders, totalCount, folderId, folderPaths } = await server.services.pamAccount.list({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + accountPath, + accountView, + limit, + offset, + search, + orderBy, + orderDirection, + filterResourceIds + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: req.query.projectId, + projectId, event: { type: EventType.PAM_ACCOUNT_LIST, metadata: { - accountCount: response.accounts.length, - folderCount: response.folders.length + accountCount: accounts.length, + folderCount: folders.length } } }); - return response; + return { accounts, folders, totalCount, folderId, folderPaths }; } }); @@ -65,7 +106,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { schema: { description: "Access PAM account", body: z.object({ - accountId: z.string().uuid(), + accountPath: z.string().trim(), + projectId: z.string().uuid(), duration: z .string() .min(1) @@ -93,7 +135,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { gatewayClientPrivateKey: z.string(), gatewayServerCertificateChain: z.string(), relayHost: z.string(), - metadata: z.record(z.string(), z.string()).optional() + metadata: z.record(z.string(), z.string().optional()).optional() }) } }, @@ -110,7 +152,9 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { actorIp: req.realIp, actorName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), actorUserAgent: req.auditLogInfo.userAgent ?? "", - ...req.body + accountPath: req.body.accountPath, + projectId: req.body.projectId, + duration: req.body.duration }, req.permission ); @@ -122,7 +166,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_ACCOUNT_ACCESS, metadata: { - accountId: req.body.accountId, + accountId: response.account.id, + accountPath: req.body.accountPath, accountName: response.account.name, duration: req.body.duration ? new Date(req.body.duration).toISOString() : undefined } diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index 821532598..5dae317da 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -9,6 +9,11 @@ import { SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateSSHResourceSchema, + SanitizedSSHResourceSchema, + UpdateSSHResourceSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; @@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.SSH, + resourceResponseSchema: SanitizedSSHResourceSchema, + createResourceSchema: CreateSSHResourceSchema, + updateResourceSchema: UpdateSSHResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index 6563c86c7..3536e7a99 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -5,19 +5,30 @@ import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; +import { PamResourceOrderBy } from "@app/ee/services/pam-resource/pam-resource-enums"; import { PostgresResourceListItemSchema, SanitizedPostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + SanitizedSSHResourceSchema, + SSHResourceListItemSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; +import { OrderByDirection } from "@app/lib/types"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]); +const SanitizedResourceSchema = z.union([ + SanitizedPostgresResourceSchema, + SanitizedMySQLResourceSchema, + SanitizedSSHResourceSchema +]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, - MySQLResourceListItemSchema + MySQLResourceListItemSchema, + SSHResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { @@ -52,17 +63,46 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => { schema: { description: "List PAM resources", querystring: z.object({ - projectId: z.string().uuid() + projectId: z.string().uuid(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(100), + orderBy: z.nativeEnum(PamResourceOrderBy).default(PamResourceOrderBy.Name), + orderDirection: z.nativeEnum(OrderByDirection).default(OrderByDirection.ASC), + search: z.string().trim().optional(), + filterResourceTypes: z + .string() + .transform((val) => + val + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + ) + .optional() }), response: { 200: z.object({ - resources: SanitizedResourceSchema.array() + resources: SanitizedResourceSchema.array(), + totalCount: z.number().default(0) }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const response = await server.services.pamResource.list(req.query.projectId, req.permission); + const { projectId, limit, offset, search, orderBy, orderDirection, filterResourceTypes } = req.query; + + const { resources, totalCount } = await server.services.pamResource.list({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId, + limit, + offset, + search, + orderBy, + orderDirection, + filterResourceTypes + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -71,12 +111,12 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => { event: { type: EventType.PAM_RESOURCE_LIST, metadata: { - count: response.resources.length + count: resources.length } } }); - return response; + return { resources, totalCount }; } }); }; diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 5fe10e434..3c39a9516 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -4,12 +4,21 @@ import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; +import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; +import { + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "@app/ee/services/pam-session/pam-session-schemas"; 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 SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]); +const SessionCredentialsSchema = z.union([ + SSHSessionCredentialsSchema, + PostgresSessionCredentialsSchema, + MySQLSessionCredentialsSchema +]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { // Meant to be hit solely by gateway identities @@ -32,17 +41,15 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { credentials, projectId, account } = await server.services.pamAccount.getSessionCredentials( - req.params.sessionId, - req.permission - ); + const { credentials, projectId, account, sessionStarted } = + await server.services.pamAccount.getSessionCredentials(req.params.sessionId, req.permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, projectId, event: { - type: EventType.PAM_SESSION_START, + type: EventType.PAM_SESSION_CREDENTIALS_GET, metadata: { sessionId: req.params.sessionId, accountName: account.name @@ -50,7 +57,22 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { } }); - return { credentials }; + if (sessionStarted) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.PAM_SESSION_START, + metadata: { + sessionId: req.params.sessionId, + accountName: account.name + } + } + }); + } + + return { credentials: credentials as z.infer }; } }); @@ -67,7 +89,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index c4ccf6be5..a73f955ae 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -77,7 +77,8 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { done(error, undefined); } }); - // GET /api/v1/pki/acme/profiles//directory + + // GET /api/v1/cert-manager/acme/profiles//directory // Directory (RFC 8555 Section 7.1.1) server.route({ method: "GET", @@ -99,7 +100,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId) }); - // HEAD /api/v1/pki/acme/profiles//new-nonce + // HEAD /api/v1/cert-manager/acme/profiles//new-nonce // New Nonce (RFC 8555 Section 7.2) server.route({ method: "HEAD", @@ -126,7 +127,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//new-account + // POST /api/v1/cert-manager/acme/profiles//new-account // New Account (RFC 8555 Section 7.3) server.route({ method: "POST", @@ -163,7 +164,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//accounts/ + // POST /api/v1/cert-manager/acme/profiles//accounts/ // Account Deactivation (RFC 8555 Section 7.3.6) server.route({ method: "POST", @@ -200,7 +201,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//new-order + // POST /api/v1/cert-manager/acme/profiles//new-order // New Certificate Order (RFC 8555 Section 7.4) server.route({ method: "POST", @@ -235,7 +236,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//orders/ + // POST /api/v1/cert-manager/acme/profiles//orders/ // Get Order (RFC 8555 Section 7.1.3) server.route({ method: "POST", @@ -271,7 +272,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//orders//finalize + // POST /api/v1/cert-manager/acme/profiles//orders//finalize // Applying for Certificate Issuance (RFC 8555 Section 7.4) server.route({ method: "POST", @@ -308,7 +309,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { ); } }); - // POST /api/v1/pki/acme/profiles//accounts//orders + // POST /api/v1/cert-manager/acme/profiles//accounts//orders // List Orders (RFC 8555 Section 7.1.2.1) server.route({ method: "POST", @@ -344,7 +345,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//orders//certificate + // POST /api/v1/cert-manager/acme/profiles//orders//certificate // Download Certificate (RFC 8555 Section 7.4.2) server.route({ method: "POST", @@ -377,7 +378,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//authorizations/ + // POST /api/v1/cert-manager/acme/profiles//authorizations/ // Identifier Authorization (RFC 8555 Section 7.5) server.route({ method: "POST", @@ -411,7 +412,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }); - // POST /api/v1/pki/acme/profiles//authorizations//challenges/ + // POST /api/v1/cert-manager/acme/profiles//authorizations//challenges/ // Respond to Challenge (RFC 8555 Section 7.5.1) server.route({ method: "POST", diff --git a/backend/src/ee/routes/v1/project-template-router.ts b/backend/src/ee/routes/v1/project-template-router.ts index c157b628b..81fd79c82 100644 --- a/backend/src/ee/routes/v1/project-template-router.ts +++ b/backend/src/ee/routes/v1/project-template-router.ts @@ -72,7 +72,6 @@ const ProjectTemplateEnvironmentsSchema = z position: z.number().min(1) }) .array() - .min(1) .superRefine((environments, ctx) => { if (Buffer.byteLength(JSON.stringify(environments)) > MAX_JSON_SIZE_LIMIT_IN_BYTES) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Size limit exceeded" }); @@ -198,7 +197,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) description: z.string().max(256).trim().optional().describe(ProjectTemplates.CREATE.description), roles: ProjectTemplateRolesSchema.default([]).describe(ProjectTemplates.CREATE.roles), type: z.nativeEnum(ProjectType).describe(ProjectTemplates.CREATE.type), - environments: ProjectTemplateEnvironmentsSchema.describe(ProjectTemplates.CREATE.environments).optional() + environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.CREATE.environments) }), response: { 200: z.object({ @@ -243,7 +242,7 @@ export const registerProjectTemplateRouter = async (server: FastifyZodProvider) .describe(ProjectTemplates.UPDATE.name), description: z.string().max(256).trim().optional().describe(ProjectTemplates.UPDATE.description), roles: ProjectTemplateRolesSchema.optional().describe(ProjectTemplates.UPDATE.roles), - environments: ProjectTemplateEnvironmentsSchema.optional().describe(ProjectTemplates.UPDATE.environments) + environments: ProjectTemplateEnvironmentsSchema.nullish().describe(ProjectTemplates.UPDATE.environments) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 52e4f8e1f..756a980eb 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -57,7 +57,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { body: z.object({ organizationId: z.string().trim(), description: z.string().trim().default(""), - ttlDays: z.number().min(0).default(0) + ttlDays: z.number().min(0).max(730).default(0) }), response: { 200: z.object({ 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 bd5bacc5f..925b92fda 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -305,8 +305,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean(), - shouldCheckSecretPermission: z.boolean().nullable().optional() + allowedSelfApprovals: z.boolean() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index f8ac34b4b..23ba27b8a 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -84,7 +84,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.body.identityId, - projectMembershipId: req.body.projectId, projectId: req.body.projectId, slug: privilege.name } @@ -158,6 +157,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, data: { ...req.body, + name: req.body.slug, ...req.body.type, permissions: req.body.permissions || undefined } @@ -167,7 +167,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -221,7 +220,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -275,7 +273,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: privilegeDoc.actorIdentityId as string, - projectMembershipId: privilegeDoc.projectId as string, projectId: privilegeDoc.projectId as string, slug: privilege.name } @@ -338,7 +335,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, projectId, slug: privilege.name } @@ -390,7 +386,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privileges: privileges.map((privilege) => ({ ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, projectId: req.query.projectId, slug: privilege.name })) 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 4b2608c24..6d97e3b9e 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,7 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const projectPath = `/projects/secret-management/${project.id}`; + const projectPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}`; const approvalPath = `${projectPath}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; @@ -399,7 +399,7 @@ export const accessApprovalRequestServiceFactory = ({ const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; - const projectPath = `/projects/secret-management/${project.id}`; + const projectPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}`; const approvalPath = `${projectPath}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; @@ -766,7 +766,7 @@ export const accessApprovalRequestServiceFactory = ({ .map((appUser) => appUser.email) .filter((email): email is string => !!email); - const approvalPath = `/projects/secret-management/${project.id}/approval`; + const approvalPath = `/organizations/${project.orgId}/projects/secret-management/${project.id}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await notificationService.createUserNotifications( diff --git a/backend/src/ee/services/app-connections/chef/chef-connection-fns.ts b/backend/src/ee/services/app-connections/chef/chef-connection-fns.ts index 6cef8373f..48b2508fc 100644 --- a/backend/src/ee/services/app-connections/chef/chef-connection-fns.ts +++ b/backend/src/ee/services/app-connections/chef/chef-connection-fns.ts @@ -27,6 +27,17 @@ export const getChefServerUrl = async (serverUrl?: string) => { return chefServerUrl; }; +const buildSecureUrl = (baseUrl: string, path: string): string => { + try { + const url = new URL(path, baseUrl); + return url.toString(); + } catch (error) { + throw new BadRequestError({ + message: "Invalid URL construction parameters" + }); + } +}; + // Helper to ensure private key is in proper PEM format const formatPrivateKey = (key: string): string => { let formattedKey = key.trim(); @@ -138,7 +149,8 @@ export const validateChefConnectionCredentials = async (config: TChefConnectionC const headers = getChefAuthHeaders("GET", path, "", inputCredentials.userName, inputCredentials.privateKey); - await request.get(`${hostServerUrl}${path}`, { + const secureUrl = buildSecureUrl(hostServerUrl, path); + await request.get(secureUrl, { headers }); } catch (error: unknown) { @@ -168,7 +180,8 @@ export const listChefDataBags = async (appConnection: TChefConnection): Promise< const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); - const res = await request.get>(`${hostServerUrl}${path}`, { + const secureUrl = buildSecureUrl(hostServerUrl, path); + const res = await request.get>(secureUrl, { headers }); @@ -203,7 +216,8 @@ export const listChefDataBagItems = async ( const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); - const res = await request.get>(`${hostServerUrl}${path}`, { + const secureUrl = buildSecureUrl(hostServerUrl, path); + const res = await request.get>(secureUrl, { headers }); @@ -238,7 +252,8 @@ export const getChefDataBagItem = async ({ const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); - const res = await request.get(`${hostServerUrl}${path}`, { + const secureUrl = buildSecureUrl(hostServerUrl, path); + const res = await request.get(secureUrl, { headers }); @@ -255,6 +270,38 @@ export const getChefDataBagItem = async ({ } }; +export const createChefDataBagItem = async ({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + data +}: Omit): Promise => { + try { + const path = `/organizations/${orgName}/data/${dataBagName}`; + const body = JSON.stringify(data); + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("POST", path, body, userName, privateKey); + + const secureUrl = buildSecureUrl(hostServerUrl, path); + await request.post(secureUrl, data, { + headers + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to create Chef data bag item: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to create Chef data bag item" + }); + } +}; + export const updateChefDataBagItem = async ({ serverUrl, userName, @@ -272,7 +319,8 @@ export const updateChefDataBagItem = async ({ const headers = getChefAuthHeaders("PUT", path, body, userName, privateKey); - await request.put(`${hostServerUrl}${path}`, data, { + const secureUrl = buildSecureUrl(hostServerUrl, path); + await request.put(secureUrl, data, { headers }); } catch (error) { @@ -286,3 +334,35 @@ export const updateChefDataBagItem = async ({ }); } }; + +export const removeChefDataBagItem = async ({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName +}: Omit): Promise => { + try { + const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("DELETE", path, body, userName, privateKey); + + const secureUrl = buildSecureUrl(hostServerUrl, path); + await request.delete(secureUrl, { + headers + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to remove Chef data bag item: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to remove Chef data bag item" + }); + } +}; 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 de3ce9af6..504339d18 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -186,6 +186,7 @@ export enum EventType { CREATE_TOKEN_IDENTITY_TOKEN_AUTH = "create-token-identity-token-auth", UPDATE_TOKEN_IDENTITY_TOKEN_AUTH = "update-token-identity-token-auth", GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", + GET_TOKEN_IDENTITY_TOKEN_AUTH = "get-token-identity-token-auth", ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", @@ -387,6 +388,9 @@ export enum EventType { 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", + CREATE_CERTIFICATE_REQUEST = "create-certificate-request", + GET_CERTIFICATE_REQUEST = "get-certificate-request", + GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -535,6 +539,7 @@ export enum EventType { DASHBOARD_GET_SECRET_VALUE = "dashboard-get-secret-value", DASHBOARD_GET_SECRET_VERSION_VALUE = "dashboard-get-secret-version-value", + PAM_SESSION_CREDENTIALS_GET = "pam-session-credentials-get", PAM_SESSION_START = "pam-session-start", PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update", PAM_SESSION_END = "pam-session-end", @@ -1029,6 +1034,15 @@ interface GetTokensIdentityTokenAuthEvent { }; } +interface GetTokenIdentityTokenAuthEvent { + type: EventType.GET_TOKEN_IDENTITY_TOKEN_AUTH; + metadata: { + identityId: string; + identityName: string; + tokenId: string; + }; +} + interface AddIdentityTokenAuthEvent { type: EventType.ADD_IDENTITY_TOKEN_AUTH; metadata: { @@ -2776,6 +2790,7 @@ interface CreateCertificateProfile { name: string; projectId: string; enrollmentType: string; + issuerType: string; }; } @@ -2834,7 +2849,6 @@ interface OrderCertificateFromProfile { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; metadata: { certificateProfileId: string; - orderId: string; profileName: string; }; } @@ -3978,6 +3992,14 @@ interface OrgRoleDeleteEvent { }; } +interface PamSessionCredentialsGetEvent { + type: EventType.PAM_SESSION_CREDENTIALS_GET; + metadata: { + sessionId: string; + accountName: string; + }; +} + interface PamSessionStartEvent { type: EventType.PAM_SESSION_START; metadata: { @@ -4054,6 +4076,7 @@ interface PamAccountAccessEvent { type: EventType.PAM_ACCOUNT_ACCESS; metadata: { accountId: string; + accountPath: string; accountName: string; duration?: string; }; @@ -4176,6 +4199,31 @@ interface DisableCertificateRenewalConfigEvent { }; } +interface CreateCertificateRequestEvent { + type: EventType.CREATE_CERTIFICATE_REQUEST; + metadata: { + certificateRequestId: string; + profileId?: string; + caId?: string; + commonName?: string; + }; +} + +interface GetCertificateRequestEvent { + type: EventType.GET_CERTIFICATE_REQUEST; + metadata: { + certificateRequestId: string; + }; +} + +interface GetCertificateFromRequestEvent { + type: EventType.GET_CERTIFICATE_FROM_REQUEST; + metadata: { + certificateRequestId: string; + certificateId?: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4214,6 +4262,7 @@ export type Event = | CreateTokenIdentityTokenAuthEvent | UpdateTokenIdentityTokenAuthEvent | GetTokensIdentityTokenAuthEvent + | GetTokenIdentityTokenAuthEvent | AddIdentityTokenAuthEvent | UpdateIdentityTokenAuthEvent | GetIdentityTokenAuthEvent @@ -4531,6 +4580,7 @@ export type Event = | OrgRoleCreateEvent | OrgRoleUpdateEvent | OrgRoleDeleteEvent + | PamSessionCredentialsGetEvent | PamSessionStartEvent | PamSessionLogsUpdateEvent | PamSessionEndEvent @@ -4553,6 +4603,9 @@ export type Event = | PamResourceDeleteEvent | UpdateCertificateRenewalConfigEvent | DisableCertificateRenewalConfigEvent + | CreateCertificateRequestEvent + | GetCertificateRequestEvent + | GetCertificateFromRequestEvent | AutomatedRenewCertificate | AutomatedRenewCertificateFailed | UserLoginEvent diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts index 5ead798fa..81657e15d 100644 --- a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -4,7 +4,10 @@ import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { + ProjectPermissionCertificateAuthorityActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; import { NotFoundError } from "@app/lib/errors"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { expandInternalCa } from "@app/services/certificate-authority/certificate-authority-fns"; @@ -83,7 +86,7 @@ export const certificateAuthorityCrlServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionCertificateAuthorityActions.Read, ProjectPermissionSub.CertificateAuthorities ); diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index 93c3dd147..d62a1eeb2 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -1,10 +1,18 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { getConfig } from "@app/lib/config/env"; +import { applyJitter } from "@app/lib/delay"; import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; @@ -15,7 +23,12 @@ import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types"; type TDynamicSecretLeaseQueueServiceFactoryDep = { queueService: TQueueServiceFactory; dynamicSecretLeaseDAL: Pick; - dynamicSecretDAL: Pick; + smtpService: Pick; + userDAL: Pick; + identityDAL: TIdentityDALFactory; + dynamicSecretDAL: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; dynamicSecretProviders: Record; kmsService: Pick; folderDAL: Pick; @@ -23,18 +36,24 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = { export type TDynamicSecretLeaseQueueServiceFactory = { pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise; - setLeaseRevocation: (leaseId: string, expiryAt: Date) => Promise; + setLeaseRevocation: (leaseId: string, dynamicSecretId: string, expiryAt: Date) => Promise; unsetLeaseRevocation: (leaseId: string) => Promise; + queueFailedRevocation: (leaseId: string, dynamicSecretId: string) => Promise; init: () => Promise; }; +const MAX_REVOCATION_RETRY_COUNT = 10; + export const dynamicSecretLeaseQueueServiceFactory = ({ queueService, dynamicSecretDAL, dynamicSecretProviders, dynamicSecretLeaseDAL, kmsService, - folderDAL + folderDAL, + projectMembershipDAL, + projectDAL, + smtpService }: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => { const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { await queueService.queuePg( @@ -48,10 +67,10 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ ); }; - const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => { + const setLeaseRevocation = async (leaseId: string, dynamicSecretId: string, expiryAt: Date) => { await queueService.queuePg( QueueJobs.DynamicSecretRevocation, - { leaseId }, + { leaseId, dynamicSecretId }, { id: leaseId, singletonKey: leaseId, @@ -68,10 +87,53 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId); }; + const queueFailedRevocation = async (leaseId: string, dynamicSecretId: string) => { + const appConfig = getConfig(); + + const retryDelaySeconds = appConfig.isDevelopmentMode ? 1 : Math.floor(applyJitter(3_600_000 * 4) / 1000); // retry every 4 hours with 20% +- jitter (convert ms to seconds for pgboss) + + await queueService.queuePg( + QueueJobs.DynamicSecretRevocation, + { leaseId, isRetry: true, dynamicSecretId }, + { + singletonKey: `${leaseId}-retry`, // avoid conflicts with scheduled revocation + retryDelay: retryDelaySeconds, + retryLimit: MAX_REVOCATION_RETRY_COUNT, // we dont want it to ever hit the limit, we want the expireInHours to take effect. + expireInHours: 23 // if we set it to 24 hours, pgboss will complain that the expireIn is too high + } + ); + }; + + const $queueDynamicSecretLeaseRevocationFailedEmail = async (leaseId: string, dynamicSecretId: string) => { + const appConfig = getConfig(); + + const delay = appConfig.isDevelopmentMode ? 1_000 * 60 : 1_000 * 60 * 15; // 1 minute in development, 15 minutes in production + + await queueService.queue( + QueueName.DynamicSecretLeaseRevocationFailedEmail, + QueueJobs.DynamicSecretLeaseRevocationFailedEmail, + { + leaseId + }, + { + jobId: `dynamic-secret-lease-revocation-failed-email-${dynamicSecretId}`, + delay, + attempts: 3, + backoff: { + type: "exponential", + delay: 1000 * 60 // 1 minute + }, + removeOnComplete: true, + removeOnFail: true + } + ); + }; + const $dynamicSecretQueueJob = async ( jobName: string, jobId: string, - data: { leaseId: string } | { dynamicSecretCfgId: string } + data: { leaseId: string; dynamicSecretId: string; isRetry?: boolean } | { dynamicSecretCfgId: string }, + retryCount?: number ): Promise => { try { if (jobName === QueueJobs.DynamicSecretRevocation) { @@ -79,7 +141,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ logger.info("Dynamic secret lease revocation started: ", leaseId, jobId); const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); - if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + if (!dynamicSecretLease) { + throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + } const folder = await folderDAL.findById(dynamicSecretLease.dynamicSecret.folderId); if (!folder) @@ -150,7 +214,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ } logger.info("Finished dynamic secret job", jobId); } catch (error) { - logger.error(error); + logger.error(error, "Failed to delete dynamic secret"); if (jobName === QueueJobs.DynamicSecretPruning) { const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string }; @@ -161,20 +225,97 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ } if (jobName === QueueJobs.DynamicSecretRevocation) { - const { leaseId } = data as { leaseId: string }; + const { leaseId, isRetry, dynamicSecretId } = data as { + leaseId: string; + isRetry?: boolean; + dynamicSecretId: string; + }; await dynamicSecretLeaseDAL.updateById(leaseId, { status: DynamicSecretStatus.FailedDeletion, - statusDetails: (error as Error)?.message?.slice(0, 255) + statusDetails: `${(error as Error)?.message?.slice(0, 255)} - Retrying automatically` }); + + // only add to retry queue if this is not a retry, and if the error is not a DisableRotationErrors error + if (!isRetry && !(error instanceof DisableRotationErrors)) { + // if revocation fails, we should stop the job and queue a new job to retry the revocation at a later time. + await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId); + await queueFailedRevocation(leaseId, dynamicSecretId); + + // if its the last attempt, and the error isn't a DisableRotationErrors error, send an email to the project admins (debounced) + } else if (isRetry && !(error instanceof DisableRotationErrors)) { + if (retryCount && retryCount === MAX_REVOCATION_RETRY_COUNT) { + // if all retries fail, we should also stop the automatic revocation job. + // the ID of the revocation job is set to the leaseId, so we can use that to stop the job + + // we dont have to stop the retry job, because if we hit this point, its the last attempt and the retry job will be stopped by pgboss itself after this point, + await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId); + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, leaseId); + + await $queueDynamicSecretLeaseRevocationFailedEmail(leaseId, dynamicSecretId); + } + } } if (error instanceof DisableRotationErrors) { if (jobId) { await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId); await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); } + } else { + // propagate to next part + throw error; + } + } + }; + + // send alert email once all revocation attempts have failed + const $dynamicSecretLeaseRevocationFailedEmailJob = async (jobId: string, data: { leaseId: string }) => { + try { + const appCfg = getConfig(); + + const { leaseId } = data; + logger.info( + { leaseId, jobId }, + "Dynamic secret revocation failed. Notifying project admins about failed revocation." + ); + + const lease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!lease) { + throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + } + + const folder = await folderDAL.findById(lease.dynamicSecret.folderId); + if (!folder) throw new NotFoundError({ message: `Failed to find folder with ${lease.dynamicSecret.folderId}` }); + + const project = await projectDAL.findById(folder.projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(project.id); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.DynamicSecretLeaseRevocationFailed, + subjectLine: "Dynamic Secret Lease Revocation Failed", + substitutions: { + dynamicSecretLeaseUrl: `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/secrets/${folder.environment.envSlug}?dynamicSecretId=${lease.dynamicSecret.id}&filterBy=dynamic&search=${lease.dynamicSecret.name}`, + dynamicSecretName: lease.dynamicSecret.name, + projectName: project.name, + environmentSlug: folder.environment.envSlug, + errorMessage: lease.statusDetails || "An unknown error occurred" + } + }); + } catch (error) { + logger.error(error, "Failed to send dynamic secret lease revocation failed email"); + if (error instanceof DisableRotationErrors) { + if (jobId) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId); + await queueService.stopJobById(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId); + } + } else { + throw error; } - // propogate to next part - throw error; } }; @@ -182,14 +323,21 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await $dynamicSecretQueueJob(job.name, job.id as string, job.data); }); + // we use redis for sending the email because: + // 1. we are insensitive to losing the jobs in queue in case of a disaster event + // 2. pgboss does not support exclusive job keys on v0.10.x, and upgrading to v0.11.x which supports exclusive jobs comes with a lot of breaking changes, and we would need to manually migrate our existing jobs to the new version + queueService.start(QueueName.DynamicSecretLeaseRevocationFailedEmail, async (job) => { + await $dynamicSecretLeaseRevocationFailedEmailJob(job.id as string, job.data); + }); + const init = async () => { await queueService.startPg( QueueJobs.DynamicSecretRevocation, async ([job]) => { - await $dynamicSecretQueueJob(job.name, job.id, job.data); + await $dynamicSecretQueueJob(job.name, job.id, job.data, job.retryCount); }, { - workerCount: 5, + workerCount: 10, pollingIntervalSeconds: 1 } ); @@ -210,6 +358,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ pruneDynamicSecret, setLeaseRevocation, unsetLeaseRevocation, + queueFailedRevocation, init }; }; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index cf37626c7..ea5efd502 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -178,7 +178,7 @@ export const dynamicSecretLeaseServiceFactory = ({ config }); - await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt); return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; }; @@ -272,7 +272,7 @@ export const dynamicSecretLeaseServiceFactory = ({ ); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); - await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt); const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { expireAt, externalEntityId: entityId @@ -358,11 +358,13 @@ export const dynamicSecretLeaseServiceFactory = ({ if ((revokeResponse as { error?: Error })?.error) { const { error } = revokeResponse as { error?: Error }; logger.error(error?.message, "Failed to revoke lease"); - const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { status: DynamicSecretLeaseStatus.FailedDeletion, statusDetails: error?.message?.slice(0, 255) }); - return deletedDynamicSecretLease; + // queue a job to retry the revocation at a later time + await dynamicSecretQueueService.queueFailedRevocation(dynamicSecretLease.id, dynamicSecretCfg.id); + return updatedDynamicSecretLease; } await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index 9614f3298..eb595ee02 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -24,7 +24,13 @@ import { } from "./external-kms-types"; import { AwsKmsProviderFactory } from "./providers/aws-kms"; import { GcpKmsProviderFactory } from "./providers/gcp-kms"; -import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model"; +import { + ExternalKmsAwsSchema, + ExternalKmsGcpSchema, + KmsProviders, + TExternalKmsAwsSchema, + TExternalKmsGcpSchema +} from "./providers/model"; type TExternalKmsServiceFactoryDep = { externalKmsDAL: TExternalKmsDALFactory; @@ -72,6 +78,7 @@ export const externalKmsServiceFactory = ({ const kmsName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); let sanitizedProviderInput = ""; + let sanitizedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema; switch (provider.type) { case KmsProviders.Aws: { @@ -88,9 +95,18 @@ export const externalKmsServiceFactory = ({ try { // if missing kms key this generate a new kms key id and returns new provider input const newProviderInput = await externalKms.generateInputKmsKey(); + sanitizedProviderInputObject = newProviderInput; sanitizedProviderInput = JSON.stringify(newProviderInput); await externalKms.validateConnection(); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: error instanceof Error ? `AWS error: ${error.message}` : "Failed to validate AWS connection" + }); } finally { await externalKms.cleanup(); } @@ -101,7 +117,16 @@ export const externalKmsServiceFactory = ({ const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs }); try { await externalKms.validateConnection(); + sanitizedProviderInputObject = provider.inputs; sanitizedProviderInput = JSON.stringify(provider.inputs); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to validate GCP connection" + }); } finally { await externalKms.cleanup(); } @@ -139,7 +164,10 @@ export const externalKmsServiceFactory = ({ }, tx ); - return { ...kms, external: externalKmsCfg }; + return { + ...kms, + external: { ...externalKmsCfg, providerInput: sanitizedProviderInputObject } + }; }); return externalKms; @@ -179,6 +207,7 @@ export const externalKmsServiceFactory = ({ if (!externalKmsDoc) throw new NotFoundError({ message: `External KMS with ID '${kmsId}' not found` }); let sanitizedProviderInput = ""; + let sanitizedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema; const { encryptor: orgDataKeyEncryptor, decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -199,7 +228,16 @@ export const externalKmsServiceFactory = ({ const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput }); try { await externalKms.validateConnection(); + sanitizedProviderInputObject = updatedProviderInput; sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: error instanceof Error ? `AWS error: ${error.message}` : "Failed to validate AWS connection" + }); } finally { await externalKms.cleanup(); } @@ -214,7 +252,16 @@ export const externalKmsServiceFactory = ({ const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput }); try { await externalKms.validateConnection(); + sanitizedProviderInputObject = updatedProviderInput; sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to validate GCP connection" + }); } finally { await externalKms.cleanup(); } @@ -234,14 +281,17 @@ export const externalKmsServiceFactory = ({ } const externalKms = await externalKmsDAL.transaction(async (tx) => { - const kms = await kmsDAL.updateById( - kmsDoc.id, - { - description, - name: kmsName - }, - tx - ); + let kms = kmsDoc; + if (kmsName || description) { + kms = await kmsDAL.updateById( + kmsDoc.id, + { + description, + name: kmsName + }, + tx + ); + } if (encryptedProviderInputs) { const externalKmsCfg = await externalKmsDAL.updateById( externalKmsDoc.id, @@ -250,9 +300,9 @@ export const externalKmsServiceFactory = ({ }, tx ); - return { ...kms, external: externalKmsCfg }; + return { ...kms, external: { ...externalKmsCfg, providerInput: sanitizedProviderInputObject } }; } - return { ...kms, external: externalKmsDoc }; + return { ...kms, external: { ...externalKmsDoc, providerInput: sanitizedProviderInputObject } }; }); return externalKms; @@ -273,9 +323,40 @@ export const externalKmsServiceFactory = ({ const externalKmsDoc = await externalKmsDAL.findOne({ kmsKeyId: kmsDoc.id }); if (!externalKmsDoc) throw new NotFoundError({ message: `External KMS with ID '${kmsId}' not found` }); + let decryptedProviderInputObject: TExternalKmsAwsSchema | TExternalKmsGcpSchema; + + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: actorOrgId + }); + + const decryptedProviderInputBlob = orgDataKeyDecryptor({ + cipherTextBlob: externalKmsDoc.encryptedProviderInputs + }); + + switch (externalKmsDoc.provider) { + case KmsProviders.Aws: { + const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + decryptedProviderInputObject = decryptedProviderInput; + break; + } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + + decryptedProviderInputObject = decryptedProviderInput; + break; + } + default: + break; + } + const externalKms = await externalKmsDAL.transaction(async (tx) => { const kms = await kmsDAL.deleteById(kmsDoc.id, tx); - return { ...kms, external: externalKmsDoc }; + return { ...kms, external: { ...externalKmsDoc, providerInput: decryptedProviderInputObject } }; }); return externalKms; @@ -393,6 +474,14 @@ export const externalKmsServiceFactory = ({ const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } }); try { return await externalKms.getKeysList(); + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: error instanceof Error ? `GCP error: ${error.message}` : "Failed to fetch GCP keys" + }); } finally { await externalKms.cleanup(); } diff --git a/backend/src/ee/services/external-kms/providers/aws-kms.ts b/backend/src/ee/services/external-kms/providers/aws-kms.ts index 2c248992f..82e95f360 100644 --- a/backend/src/ee/services/external-kms/providers/aws-kms.ts +++ b/backend/src/ee/services/external-kms/providers/aws-kms.ts @@ -3,6 +3,7 @@ import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; import { CustomAWSHasher } from "@app/lib/aws/hashing"; import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; import { ExternalKmsAwsSchema, KmsAwsCredentialType, TExternalKmsAwsSchema, TExternalKmsProviderFns } from "./model"; @@ -22,7 +23,7 @@ const getAwsKmsClient = async (providerInputs: TExternalKmsAwsSchema) => { }); const response = await stsClient.send(command); if (!response.Credentials?.AccessKeyId || !response.Credentials?.SecretAccessKey) - throw new Error("Failed to assume role"); + throw new BadRequestError({ message: "Failed to assume role" }); const kmsClient = new KMSClient({ region: providerInputs.awsRegion, @@ -67,7 +68,7 @@ export const AwsKmsProviderFactory = async ({ inputs }: AwsKmsProviderArgs): Pro const command = new CreateKeyCommand({ Tags: [{ TagKey: "author", TagValue: "infisical" }] }); const kmsKey = await awsClient.send(command); - if (!kmsKey.KeyMetadata?.KeyId) throw new Error("Failed to generate kms key"); + if (!kmsKey.KeyMetadata?.KeyId) throw new BadRequestError({ message: "Failed to generate kms key" }); const updatedProviderInputs = await ExternalKmsAwsSchema.parseAsync({ ...providerInputs, diff --git a/backend/src/ee/services/external-kms/providers/model.ts b/backend/src/ee/services/external-kms/providers/model.ts index 6cb78a34e..08a9a3fc7 100644 --- a/backend/src/ee/services/external-kms/providers/model.ts +++ b/backend/src/ee/services/external-kms/providers/model.ts @@ -19,27 +19,31 @@ export enum KmsGcpKeyFetchAuthType { Kms = "kmsId" } +const AwsConnectionAssumeRoleCredentialsSchema = z.object({ + assumeRoleArn: z.string().trim().min(1).describe("AWS user role to be assumed by infisical"), + externalId: z + .string() + .trim() + .min(1) + .optional() + .describe("AWS assume role external id for further security in authentication") +}); + +const AwsConnectionAccessTokenCredentialsSchema = z.object({ + accessKey: z.string().trim().min(1).describe("AWS user account access key"), + secretKey: z.string().trim().min(1).describe("AWS user account secret key") +}); + export const ExternalKmsAwsSchema = z.object({ credential: z .discriminatedUnion("type", [ z.object({ type: z.literal(KmsAwsCredentialType.AccessKey), - data: z.object({ - accessKey: z.string().trim().min(1).describe("AWS user account access key"), - secretKey: z.string().trim().min(1).describe("AWS user account secret key") - }) + data: AwsConnectionAccessTokenCredentialsSchema }), z.object({ type: z.literal(KmsAwsCredentialType.AssumeRole), - data: z.object({ - assumeRoleArn: z.string().trim().min(1).describe("AWS user role to be assumed by infisical"), - externalId: z - .string() - .trim() - .min(1) - .optional() - .describe("AWS assume role external id for furthur security in authentication") - }) + data: AwsConnectionAssumeRoleCredentialsSchema }) ]) .describe("AWS credential information to connect"), @@ -52,6 +56,22 @@ export const ExternalKmsAwsSchema = z.object({ }); export type TExternalKmsAwsSchema = z.infer; +export const SanitizedExternalKmsAwsSchema = ExternalKmsAwsSchema.extend({ + credential: z.discriminatedUnion("type", [ + z.object({ + type: z.literal(KmsAwsCredentialType.AccessKey), + data: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKey: true }) + }), + z.object({ + type: z.literal(KmsAwsCredentialType.AssumeRole), + data: AwsConnectionAssumeRoleCredentialsSchema.pick({ + assumeRoleArn: true, + externalId: true + }) + }) + ]) +}); + export const ExternalKmsGcpCredentialSchema = z.object({ type: z.literal(KmsGcpCredentialType.ServiceAccount), project_id: z.string().min(1), @@ -75,6 +95,8 @@ export const ExternalKmsGcpSchema = z.object({ }); export type TExternalKmsGcpSchema = z.infer; +export const SanitizedExternalKmsGcpSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true }); + const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({ credential: ExternalKmsGcpCredentialSchema }); diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 6fb02207d..ced8410b7 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -4,8 +4,9 @@ import { TDbClient } from "@app/db"; import { AccessScope, TableName, TGroups } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; -import { EFilterReturnedUsers } from "./group-types"; +import { EFilterReturnedProjects, EFilterReturnedUsers, EGroupProjectsOrderBy } from "./group-types"; export type TGroupDALFactory = ReturnType; @@ -166,6 +167,89 @@ export const groupDALFactory = (db: TDbClient) => { } }; + const findAllGroupProjects = async ({ + orgId, + groupId, + offset, + limit, + search, + filter, + orderBy, + orderDirection + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + search?: string; + filter?: EFilterReturnedProjects; + orderBy?: EGroupProjectsOrderBy; + orderDirection?: OrderByDirection; + }) => { + try { + const query = db + .replicaNode()(TableName.Project) + .where(`${TableName.Project}.orgId`, orgId) + .leftJoin(TableName.Membership, (bd) => { + bd.on(`${TableName.Project}.id`, "=", `${TableName.Membership}.scopeProjectId`) + .andOn(`${TableName.Membership}.actorGroupId`, "=", db.raw("?", [groupId])) + .andOn(`${TableName.Membership}.scope`, "=", db.raw("?", [AccessScope.Project])); + }) + .select( + db.ref("id").withSchema(TableName.Project), + db.ref("name").withSchema(TableName.Project), + db.ref("slug").withSchema(TableName.Project), + db.ref("description").withSchema(TableName.Project), + db.ref("type").withSchema(TableName.Project), + db.ref("createdAt").withSchema(TableName.Membership).as("joinedGroupAt"), + db.raw(`count(*) OVER() as "totalCount"`) + ) + .offset(offset ?? 0); + + if (orderBy) { + void query.orderByRaw( + `LOWER(${TableName.Project}.??) ${orderDirection === OrderByDirection.ASC ? "asc" : "desc"}`, + [orderBy] + ); + } + + if (limit) { + void query.limit(limit); + } + + if (search) { + void query.andWhereRaw( + `CONCAT_WS(' ', "${TableName.Project}"."name", "${TableName.Project}"."slug", "${TableName.Project}"."description") ilike ?`, + [`%${search}%`] + ); + } + + switch (filter) { + case EFilterReturnedProjects.ASSIGNED_PROJECTS: + void query.whereNotNull(`${TableName.Membership}.id`); + break; + case EFilterReturnedProjects.UNASSIGNED_PROJECTS: + void query.whereNull(`${TableName.Membership}.id`); + break; + default: + break; + } + + const projects = await query; + + return { + projects: projects.map(({ joinedGroupAt, ...project }) => ({ + ...project, + joinedGroupAt + })), + // @ts-expect-error col select is raw and not strongly typed + totalCount: Number(projects?.[0]?.totalCount ?? 0) + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find all group projects" }); + } + }; + const findGroupsByProjectId = async (projectId: string, tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.Groups) @@ -230,6 +314,7 @@ export const groupDALFactory = (db: TDbClient) => { findGroups, findByOrgId, findAllGroupPossibleMembers, + findAllGroupProjects, findGroupsByProjectId, findById, findOne diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 956d7853a..1a6a046a6 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -24,6 +24,7 @@ import { TCreateGroupDTO, TDeleteGroupDTO, TGetGroupByIdDTO, + TListGroupProjectsDTO, TListGroupUsersDTO, TRemoveUserFromGroupDTO, TUpdateGroupDTO @@ -34,7 +35,14 @@ type TGroupServiceFactoryDep = { userDAL: Pick; groupDAL: Pick< TGroupDALFactory, - "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" + | "create" + | "findOne" + | "update" + | "delete" + | "findAllGroupPossibleMembers" + | "findById" + | "transaction" + | "findAllGroupProjects" >; membershipGroupDAL: Pick; membershipRoleDAL: Pick; @@ -367,6 +375,55 @@ export const groupServiceFactory = ({ return { users: members, totalCount }; }; + const listGroupProjects = async ({ + id, + offset, + limit, + search, + filter, + orderBy, + orderDirection, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupProjectsDTO) => { + if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); + + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: actorOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); + + const group = await groupDAL.findOne({ + orgId: actorOrgId, + id + }); + + if (!group) + throw new NotFoundError({ + message: `Failed to find group with ID ${id}` + }); + + const { projects, totalCount } = await groupDAL.findAllGroupProjects({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + search, + filter, + orderBy, + orderDirection + }); + + return { projects, totalCount }; + }; + const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); @@ -535,6 +592,7 @@ export const groupServiceFactory = ({ updateGroup, deleteGroup, listGroupUsers, + listGroupProjects, addUserToGroup, removeUserFromGroup, getGroupById diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 4b0742201..335b6d72b 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -2,7 +2,7 @@ import { Knex } from "knex"; import { TGroups } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { TGenericPermission } from "@app/lib/types"; +import { OrderByDirection, TGenericPermission } from "@app/lib/types"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -42,6 +42,16 @@ export type TListGroupUsersDTO = { filter?: EFilterReturnedUsers; } & TGenericPermission; +export type TListGroupProjectsDTO = { + id: string; + offset: number; + limit: number; + search?: string; + filter?: EFilterReturnedProjects; + orderBy?: EGroupProjectsOrderBy; + orderDirection?: OrderByDirection; +} & TGenericPermission; + export type TListProjectGroupUsersDTO = TListGroupUsersDTO & { projectId: string; }; @@ -111,3 +121,12 @@ export enum EFilterReturnedUsers { EXISTING_MEMBERS = "existingMembers", NON_MEMBERS = "nonMembers" } + +export enum EFilterReturnedProjects { + ASSIGNED_PROJECTS = "assignedProjects", + UNASSIGNED_PROJECTS = "unassignedProjects" +} + +export enum EGroupProjectsOrderBy { + Name = "name" +} diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index d303859bb..2f29e4812 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -39,3 +39,9 @@ export const getDefaultOnPremFeatures = () => { }; export const setupLicenseRequestWithStore = () => {}; + +export const getLicenseKeyConfig = () => { + return { + isValid: false + }; +}; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 14b7bcfbd..09ff9e108 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -1,13 +1,56 @@ import axios, { AxiosError } from "axios"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { getConfig } from "@app/lib/config/env"; +import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { UserAliasType } from "@app/services/user-alias/user-alias-types"; -import { TFeatureSet } from "./license-types"; +import { LicenseType, TFeatureSet, TLicenseKeyConfig, TOfflineLicenseContents } from "./license-types"; + +export const isOfflineLicenseKey = (licenseKey: string): boolean => { + try { + const contents = JSON.parse(Buffer.from(licenseKey, "base64").toString("utf8")) as TOfflineLicenseContents; + + return "signature" in contents && "license" in contents; + } catch (error) { + return false; + } +}; + +export const getLicenseKeyConfig = ( + config?: Pick +): TLicenseKeyConfig => { + const cfg = config || getConfig(); + + if (!cfg) { + return { isValid: false }; + } + + const licenseKey = cfg.LICENSE_KEY; + + if (licenseKey) { + if (isOfflineLicenseKey(licenseKey)) { + return { isValid: true, licenseKey, type: LicenseType.Offline }; + } + + return { isValid: true, licenseKey, type: LicenseType.Online }; + } + + const offlineLicenseKey = cfg.LICENSE_KEY_OFFLINE; + + // backwards compatibility + if (offlineLicenseKey) { + if (isOfflineLicenseKey(offlineLicenseKey)) { + return { isValid: true, licenseKey: offlineLicenseKey, type: LicenseType.Offline }; + } + + return { isValid: false }; + } + + return { isValid: false }; +}; export const getDefaultOnPremFeatures = (): TFeatureSet => ({ _id: null, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index bbd6147ed..e34f9273f 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -22,9 +22,10 @@ import { OrgPermissionBillingActions, OrgPermissionSubjects } from "../permissio import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { BillingPlanRows, BillingPlanTableHead } from "./licence-enums"; import { TLicenseDALFactory } from "./license-dal"; -import { getDefaultOnPremFeatures, setupLicenseRequestWithStore } from "./license-fns"; +import { getDefaultOnPremFeatures, getLicenseKeyConfig, setupLicenseRequestWithStore } from "./license-fns"; import { InstanceType, + LicenseType, TAddOrgPmtMethodDTO, TAddOrgTaxIdDTO, TCreateOrgPortalSession, @@ -77,6 +78,7 @@ export const licenseServiceFactory = ({ let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let selfHostedLicense: TOfflineLicense | null = null; + const licenseKeyConfig = getLicenseKeyConfig(envConfig); const licenseServerCloudApi = setupLicenseRequestWithStore( envConfig.LICENSE_SERVER_URL || "", @@ -85,10 +87,13 @@ export const licenseServiceFactory = ({ envConfig.INTERNAL_REGION ); + const onlineLicenseKey = + licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Online ? licenseKeyConfig.licenseKey : ""; + const licenseServerOnPremApi = setupLicenseRequestWithStore( envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, - envConfig.LICENSE_KEY || "", + onlineLicenseKey, envConfig.INTERNAL_REGION ); @@ -131,7 +136,7 @@ export const licenseServiceFactory = ({ return; } - if (envConfig.LICENSE_KEY) { + if (licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Online) { const token = await licenseServerOnPremApi.refreshLicense(); if (token) { await syncLicenseKeyOnPremFeatures(true); @@ -142,10 +147,10 @@ export const licenseServiceFactory = ({ return; } - if (envConfig.LICENSE_KEY_OFFLINE) { + if (licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Offline) { let isValidOfflineLicense = true; const contents: TOfflineLicenseContents = JSON.parse( - Buffer.from(envConfig.LICENSE_KEY_OFFLINE, "base64").toString("utf8") + Buffer.from(licenseKeyConfig.licenseKey, "base64").toString("utf8") ); const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature); @@ -184,7 +189,7 @@ export const licenseServiceFactory = ({ }; const initializeBackgroundSync = async () => { - if (envConfig.LICENSE_KEY) { + if (licenseKeyConfig?.isValid && licenseKeyConfig?.type === LicenseType.Online) { logger.info("Setting up background sync process for refresh onPremFeatures"); const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures); job.start(); @@ -445,8 +450,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${envConfig.SITE_URL}/organization/billing`, - cancel_url: `${envConfig.SITE_URL}/organization/billing` + success_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing`, + cancel_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing` } ); @@ -459,7 +464,7 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${envConfig.SITE_URL}/organization/billing` + return_url: `${envConfig.SITE_URL}/organizations/${orgId}/billing` } ); diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 5157b0730..8897eaabc 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -136,3 +136,18 @@ export type TDelOrgTaxIdDTO = TOrgPermission & { taxId: string }; export type TOrgInvoiceDTO = TOrgPermission; export type TOrgLicensesDTO = TOrgPermission; + +export enum LicenseType { + Offline = "offline", + Online = "online" +} + +export type TLicenseKeyConfig = + | { + isValid: false; + } + | { + isValid: true; + licenseKey: string; + type: LicenseType; + }; diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts index 6ef7df76e..5fa242627 100644 --- a/backend/src/ee/services/pam-account/pam-account-dal.ts +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -1,46 +1,109 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName, TPamAccounts } from "@app/db/schemas"; -import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; + +import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums"; export type TPamAccountDALFactory = ReturnType; -type PamAccountFindFilter = Parameters>[0]; - export const pamAccountDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.PamAccount); - const findWithResourceDetails = async (filter: PamAccountFindFilter, tx?: Knex) => { - const query = (tx || db.replicaNode())(TableName.PamAccount) - .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) - .select(selectAllTableCols(TableName.PamAccount)) - .select( + const findByProjectIdWithResourceDetails = async ( + { + projectId, + folderId, + accountView = PamAccountView.Nested, + search, + limit, + offset = 0, + orderBy = PamAccountOrderBy.Name, + orderDirection = OrderByDirection.ASC, + filterResourceIds + }: { + projectId: string; + folderId?: string | null; + accountView?: PamAccountView; + search?: string; + limit?: number; + offset?: number; + orderBy?: PamAccountOrderBy; + orderDirection?: OrderByDirection; + filterResourceIds?: string[]; + }, + tx?: Knex + ) => { + try { + const dbInstance = tx || db.replicaNode(); + const query = dbInstance(TableName.PamAccount) + .leftJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .where(`${TableName.PamAccount}.projectId`, projectId); + + if (accountView === PamAccountView.Nested) { + if (folderId) { + void query.where(`${TableName.PamAccount}.folderId`, folderId); + } else { + void query.whereNull(`${TableName.PamAccount}.folderId`); + } + } + + if (search) { + // escape special characters (`%`, `_`) and the escape character itself (`\`) + const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escapedSearch}%`; + void query.where((q) => { + void q + .whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "name", pattern]) + .orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern]) + .orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamAccount, "description", pattern]); + }); + } + + if (filterResourceIds && filterResourceIds.length) { + void query.whereIn(`${TableName.PamAccount}.resourceId`, filterResourceIds); + } + + const countQuery = query.clone().count("*", { as: "count" }).first(); + + void query.select(selectAllTableCols(TableName.PamAccount)).select( // resource db.ref("name").withSchema(TableName.PamResource).as("resourceName"), db.ref("resourceType").withSchema(TableName.PamResource), db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource) ); - if (filter) { - /* eslint-disable @typescript-eslint/no-misused-promises */ - void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PamAccount, filter))); + const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"; + + void query.orderByRaw(`${TableName.PamAccount}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]); + + if (typeof limit === "number") { + void query.limit(limit).offset(offset); + } + + const [results, countResult] = await Promise.all([query, countQuery]); + const totalCount = Number(countResult?.count || 0); + + const accounts = results.map( + // @ts-expect-error resourceName, resourceType, encryptedRotationAccountCredentials are from joined table + ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({ + ...account, + resourceId, + resource: { + id: resourceId, + name: resourceName as string, + resourceType, + encryptedRotationAccountCredentials + } + }) + ); + return { accounts, totalCount }; + } catch (error) { + throw new DatabaseError({ error, name: "Find PAM accounts with resource details" }); } - - const accounts = await query; - - return accounts.map( - ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({ - ...account, - resourceId, - resource: { - id: resourceId, - name: resourceName, - resourceType, - encryptedRotationAccountCredentials - } - }) - ); }; const findAccountsDueForRotation = async (tx?: Knex) => { @@ -59,5 +122,9 @@ export const pamAccountDALFactory = (db: TDbClient) => { return accounts; }; - return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; + return { + ...orm, + findByProjectIdWithResourceDetails, + findAccountsDueForRotation + }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-enums.ts b/backend/src/ee/services/pam-account/pam-account-enums.ts new file mode 100644 index 000000000..92b95df94 --- /dev/null +++ b/backend/src/ee/services/pam-account/pam-account-enums.ts @@ -0,0 +1,8 @@ +export enum PamAccountOrderBy { + Name = "name" +} + +export enum PamAccountView { + Flat = "flat", + Nested = "nested" +} diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 2f66d28d7..019df00ec 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamResources } from "@app/db/schemas"; +import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas"; import { PAM_RESOURCE_FACTORY_MAP } from "@app/ee/services/pam-resource/pam-resource-factory"; import { decryptResource, decryptResourceConnectionDetails } from "@app/ee/services/pam-resource/pam-resource-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; @@ -27,12 +27,14 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; import { PamResource } from "../pam-resource/pam-resource-enums"; import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; +import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types"; import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; import { PamSessionStatus } from "../pam-session/pam-session-enums"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPamAccountDALFactory } from "./pam-account-dal"; +import { PamAccountView } from "./pam-account-enums"; import { decryptAccount, decryptAccountCredentials, encryptAccountCredentials } from "./pam-account-fns"; -import { TAccessAccountDTO, TCreateAccountDTO, TUpdateAccountDTO } from "./pam-account-types"; +import { TAccessAccountDTO, TCreateAccountDTO, TListAccountsDTO, TUpdateAccountDTO } from "./pam-account-types"; type TPamAccountServiceFactoryDep = { pamResourceDAL: TPamResourceDALFactory; @@ -251,17 +253,17 @@ export const pamAccountServiceFactory = ({ gatewayV2Service ); - // Logic to prevent overwriting unedited censored values - const finalCredentials = { ...credentials }; - if (credentials.password === "__INFISICAL_UNCHANGED__") { - const decryptedCredentials = await decryptAccountCredentials({ - encryptedCredentials: account.encryptedCredentials, - projectId: account.projectId, - kmsService - }); + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); - finalCredentials.password = decryptedCredentials.password; - } + // Logic to prevent overwriting unedited censored values + const finalCredentials = await factory.handleOverwritePreventionForCensoredValues( + credentials, + decryptedCredentials + ); const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); const encryptedCredentials = await encryptAccountCredentials({ @@ -334,21 +336,96 @@ export const pamAccountServiceFactory = ({ }; }; - const list = async (projectId: string, actor: OrgServiceActor) => { + const list = async ({ + projectId, + accountPath, + accountView, + actor, + actorId, + actorAuthMethod, + actorOrgId, + ...params + }: TListAccountsDTO) => { const { permission } = await permissionService.getProjectPermission({ - actor: actor.type, - actorAuthMethod: actor.authMethod, - actorId: actor.id, - actorOrgId: actor.orgId, + actor, + actorId, projectId, + actorAuthMethod, + actorOrgId, actionProjectType: ActionProjectType.PAM }); - const accountsWithResourceDetails = await pamAccountDAL.findWithResourceDetails({ projectId }); + const limit = params.limit || 20; + const offset = params.offset || 0; const canReadFolders = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.PamFolders); - const folders = canReadFolders ? await pamFolderDAL.find({ projectId }) : []; + const folder = accountPath === "/" ? null : await pamFolderDAL.findByPath(projectId, accountPath); + if (accountPath !== "/" && !folder) { + return { accounts: [], folders: [], totalCount: 0, folderPaths: {} }; + } + const folderId = folder?.id; + + let totalFolderCount = 0; + if (canReadFolders && accountView === PamAccountView.Nested) { + const { totalCount } = await pamFolderDAL.findByProjectId({ + projectId, + parentId: folderId, + search: params.search + }); + totalFolderCount = totalCount; + } + + let folders: TPamFolders[] = []; + if (canReadFolders && accountView === PamAccountView.Nested && offset < totalFolderCount) { + const folderLimit = Math.min(limit, totalFolderCount - offset); + const { folders: foldersResp } = await pamFolderDAL.findByProjectId({ + projectId, + parentId: folderId, + limit: folderLimit, + offset, + search: params.search, + orderBy: params.orderBy, + orderDirection: params.orderDirection + }); + + folders = foldersResp; + } + + let accountsWithResourceDetails: Awaited< + ReturnType + >["accounts"] = []; + let totalAccountCount = 0; + + const accountsToFetch = limit - folders.length; + if (accountsToFetch > 0) { + const accountOffset = Math.max(0, offset - totalFolderCount); + const { accounts, totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({ + projectId, + folderId, + accountView, + offset: accountOffset, + limit: accountsToFetch, + search: params.search, + orderBy: params.orderBy, + orderDirection: params.orderDirection, + filterResourceIds: params.filterResourceIds + }); + accountsWithResourceDetails = accounts; + totalAccountCount = totalCount; + } else { + // if no accounts are to be fetched for the current page, we still need the total count for pagination + const { totalCount } = await pamAccountDAL.findByProjectIdWithResourceDetails({ + projectId, + folderId, + accountView, + search: params.search, + filterResourceIds: params.filterResourceIds + }); + totalAccountCount = totalCount; + } + + const totalCount = totalFolderCount + totalAccountCount; const decryptedAndPermittedAccounts: Array< TPamAccounts & { @@ -359,12 +436,6 @@ export const pamAccountServiceFactory = ({ > = []; for await (const account of accountsWithResourceDetails) { - const accountPath = await getFullPamFolderPath({ - pamFolderDAL, - folderId: account.folderId, - projectId: account.projectId - }); - // Check permission for each individual account if ( permission.can( @@ -391,14 +462,32 @@ export const pamAccountServiceFactory = ({ } } + const folderPaths: Record = {}; + const accountFolderIds = [ + ...new Set(decryptedAndPermittedAccounts.flatMap((a) => (a.folderId ? [a.folderId] : []))) + ]; + + await Promise.all( + accountFolderIds.map(async (fId) => { + folderPaths[fId] = await getFullPamFolderPath({ + pamFolderDAL, + folderId: fId, + projectId + }); + }) + ); + return { accounts: decryptedAndPermittedAccounts, - folders + folders, + totalCount, + folderId, + folderPaths }; }; const access = async ( - { accountId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, + { accountPath, projectId, actorEmail, actorIp, actorName, actorUserAgent, duration }: TAccessAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -408,8 +497,36 @@ export const pamAccountServiceFactory = ({ }); } - const account = await pamAccountDAL.findById(accountId); - if (!account) throw new NotFoundError({ message: `Account with ID '${accountId}' not found` }); + const pathSegments: string[] = accountPath.split("/").filter(Boolean); + if (pathSegments.length === 0) { + throw new BadRequestError({ message: "Invalid accountPath. Path must contain at least the account name." }); + } + + const accountName: string = pathSegments[pathSegments.length - 1] ?? ""; + const folderPathSegments: string[] = pathSegments.slice(0, -1); + + const folderPath: string = folderPathSegments.length > 0 ? `/${folderPathSegments.join("/")}` : "/"; + + let folderId: string | null = null; + if (folderPath !== "/") { + const folder = await pamFolderDAL.findByPath(projectId, folderPath); + if (!folder) { + throw new NotFoundError({ message: `Folder at path '${folderPath}' not found` }); + } + folderId = folder.id; + } + + const account = await pamAccountDAL.findOne({ + projectId, + folderId, + name: accountName + }); + + if (!account) { + throw new NotFoundError({ + message: `Account with name '${accountName}' not found at path '${accountPath}'` + }); + } const resource = await pamResourceDAL.findById(account.resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); @@ -419,22 +536,16 @@ export const pamAccountServiceFactory = ({ actorAuthMethod: actor.authMethod, actorId: actor.id, actorOrgId: actor.orgId, - projectId: account.projectId, + projectId, actionProjectType: ActionProjectType.PAM }); - const accountPath = await getFullPamFolderPath({ - pamFolderDAL, - folderId: account.folderId, - projectId: account.projectId - }); - ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPamAccountActions.Access, subject(ProjectPermissionSub.PamAccounts, { resourceName: resource.name, accountName: account.name, - accountPath + accountPath: folderPath }) ); @@ -444,7 +555,7 @@ export const pamAccountServiceFactory = ({ actorIp, actorName, actorUserAgent, - projectId: account.projectId, + projectId, resourceName: resource.name, resourceType: resource.resourceType, status: PamSessionStatus.Starting, @@ -453,11 +564,7 @@ export const pamAccountServiceFactory = ({ expiresAt: new Date(Date.now() + duration) }); - const { connectionDetails, gatewayId, resourceType } = await decryptResource( - resource, - account.projectId, - kmsService - ); + const { connectionDetails, gatewayId, resourceType } = await decryptResource(resource, projectId, kmsService); const user = await userDAL.findById(actor.id); if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); @@ -486,23 +593,36 @@ export const pamAccountServiceFactory = ({ case PamResource.Postgres: case PamResource.MySQL: { - const connectionCredentials = await decryptResourceConnectionDetails({ + const connectionCredentials = (await decryptResourceConnectionDetails({ encryptedConnectionDetails: resource.encryptedConnectionDetails, kmsService, - projectId: account.projectId - }); + projectId + })) as TSqlResourceConnectionDetails; const credentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, - projectId: account.projectId + projectId }); metadata = { username: credentials.username, database: connectionCredentials.database, accountName: account.name, - accountPath + accountPath: folderPath + }; + } + break; + case PamResource.SSH: + { + const credentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + kmsService, + projectId + }); + + metadata = { + username: credentials.username }; } break; @@ -520,7 +640,7 @@ export const pamAccountServiceFactory = ({ gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, relayHost: gatewayConnectionDetails.relayHost, - projectId: account.projectId, + projectId, account, metadata }; @@ -566,11 +686,6 @@ export const pamAccountServiceFactory = ({ throw new BadRequestError({ message: "Session has ended or expired" }); } - // Verify that the session has not already had credentials fetched - if (session.status !== PamSessionStatus.Starting) { - throw new BadRequestError({ message: "Session has already been started" }); - } - const account = await pamAccountDAL.findById(session.accountId); if (!account) throw new NotFoundError({ message: `Account with ID '${session.accountId}' not found` }); @@ -587,11 +702,16 @@ export const pamAccountServiceFactory = ({ const decryptedResource = await decryptResource(resource, session.projectId, kmsService); + let sessionStarted = false; + // Mark session as started - await pamSessionDAL.updateById(sessionId, { - status: PamSessionStatus.Active, - startedAt: new Date() - }); + if (session.status === PamSessionStatus.Starting) { + await pamSessionDAL.updateById(sessionId, { + status: PamSessionStatus.Active, + startedAt: new Date() + }); + sessionStarted = true; + } return { credentials: { @@ -599,7 +719,8 @@ export const pamAccountServiceFactory = ({ ...decryptedAccount.credentials }, projectId: project.id, - account + account, + sessionStarted }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 4bbccc6fa..ac799d869 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -1,4 +1,7 @@ +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; + import { TPamAccount } from "../pam-resource/pam-resource-types"; +import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums"; // DTOs export type TCreateAccountDTO = Pick< @@ -11,10 +14,22 @@ export type TUpdateAccountDTO = Partial; export const pamFolderDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.PamFolder); - return { ...orm }; + + const findByProjectId = async ( + { + projectId, + parentId, + search, + limit, + offset = 0, + orderBy = PamAccountOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: { + projectId: string; + parentId?: string | null; + search?: string; + limit?: number; + offset?: number; + orderBy?: PamAccountOrderBy; + orderDirection?: OrderByDirection; + }, + tx?: Knex + ) => { + try { + const dbInstance = tx || db.replicaNode(); + const query = dbInstance(TableName.PamFolder).where(`${TableName.PamFolder}.projectId`, projectId); + + if (parentId) { + void query.where(`${TableName.PamFolder}.parentId`, parentId); + } else { + void query.whereNull(`${TableName.PamFolder}.parentId`); + } + + if (search) { + // escape special characters (`%`, `_`) and the escape character itself (`\`) + const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + void query.whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamFolder, "name", `%${escapedSearch}%`]); + } + + const countQuery = query.clone().count("*", { as: "count" }).first(); + + void query.select(selectAllTableCols(TableName.PamFolder)); + const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"; + + void query.orderByRaw(`${TableName.PamFolder}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]); + + if (typeof limit === "number") { + void query.limit(limit).offset(offset); + } + + const [folders, countResult] = await Promise.all([query, countQuery]); + const totalCount = Number(countResult?.count || 0); + + return { folders, totalCount }; + } catch (error) { + throw new DatabaseError({ error, name: "Find PAM folders" }); + } + }; + + const findByPath = async (projectId: string, path: string, tx?: Knex) => { + try { + const dbInstance = tx || db.replicaNode(); + const pathSegments = path.split("/").filter(Boolean); + + let parentId: string | null = null; + let currentFolder: Awaited> | undefined; + + for await (const segment of pathSegments) { + const query = dbInstance(TableName.PamFolder) + .where(`${TableName.PamFolder}.projectId`, projectId) + .where(`${TableName.PamFolder}.name`, segment); + + if (parentId) { + void query.where(`${TableName.PamFolder}.parentId`, parentId); + } else { + void query.whereNull(`${TableName.PamFolder}.parentId`); + } + + currentFolder = await query.first(); + + if (!currentFolder) { + return undefined; + } + + parentId = currentFolder.id; + } + + return currentFolder; + } catch (error) { + throw new DatabaseError({ error, name: "Find PAM folder by path" }); + } + }; + + return { ...orm, findByProjectId, findByPath }; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-dal.ts b/backend/src/ee/services/pam-resource/pam-resource-dal.ts index 1a408ca27..9e5cbc985 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-dal.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-dal.ts @@ -2,7 +2,11 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { OrderByDirection } from "@app/lib/types"; + +import { PamResourceOrderBy } from "./pam-resource-enums"; export type TPamResourceDALFactory = ReturnType; export const pamResourceDALFactory = (db: TDbClient) => { @@ -20,5 +24,65 @@ export const pamResourceDALFactory = (db: TDbClient) => { return doc; }; - return { ...orm, findById }; + const findByProjectId = async ( + { + projectId, + search, + limit, + offset = 0, + orderBy = PamResourceOrderBy.Name, + orderDirection = OrderByDirection.ASC, + filterResourceTypes + }: { + projectId: string; + search?: string; + limit?: number; + offset?: number; + orderBy?: PamResourceOrderBy; + orderDirection?: OrderByDirection; + filterResourceTypes?: string[]; + }, + tx?: Knex + ) => { + try { + const dbInstance = tx || db.replicaNode(); + const query = dbInstance(TableName.PamResource).where(`${TableName.PamResource}.projectId`, projectId); + + if (search) { + // escape special characters (`%`, `_`) and the escape character itself (`\`) + const escapedSearch = search.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); + const pattern = `%${escapedSearch}%`; + void query.where((q) => { + void q + .whereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "name", pattern]) + .orWhereRaw(`??.?? ILIKE ? ESCAPE '\\'`, [TableName.PamResource, "resourceType", pattern]); + }); + } + + if (filterResourceTypes && filterResourceTypes.length) { + void query.whereIn(`${TableName.PamResource}.resourceType`, filterResourceTypes); + } + + const countQuery = query.clone().count("*", { as: "count" }).first(); + + void query.select(selectAllTableCols(TableName.PamResource)); + + const direction = orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"; + + void query.orderByRaw(`${TableName.PamResource}.?? COLLATE "en-x-icu" ${direction}`, [orderBy]); + + if (typeof limit === "number") { + void query.limit(limit).offset(offset); + } + + const [resources, countResult] = await Promise.all([query, countQuery]); + const totalCount = Number(countResult?.count || 0); + + return { resources, totalCount }; + } catch (error) { + throw new DatabaseError({ error, name: "Find PAM resources" }); + } + }; + + return { ...orm, findById, findByProjectId }; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index dff1cc650..e4ec043e1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,4 +1,9 @@ export enum PamResource { Postgres = "postgres", - MySQL = "mysql" + MySQL = "mysql", + SSH = "ssh" +} + +export enum PamResourceOrderBy { + Name = "name" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 151fa7ea1..e2d0a50f8 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,10 +1,12 @@ import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; +import { sshResourceFactory } from "./ssh/ssh-resource-factory"; type TPamResourceFactoryImplementation = TPamResourceFactory; export const PAM_RESOURCE_FACTORY_MAP: Record = { [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, - [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation + [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation, + [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index d97905dbe..0ebca02b5 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -20,7 +20,7 @@ import { encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; -import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; +import { TCreateResourceDTO, TListResourcesDTO, TUpdateResourceDTO } from "./pam-resource-types"; type TPamResourceServiceFactoryDep = { pamResourceDAL: TPamResourceDALFactory; @@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({ gatewayV2Service ); - // Logic to prevent overwriting unedited censored values - const finalCredentials = { ...rotationAccountCredentials }; - if ( - resource.encryptedRotationAccountCredentials && - rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" - ) { + let finalCredentials = { ...rotationAccountCredentials }; + if (resource.encryptedRotationAccountCredentials) { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: resource.encryptedRotationAccountCredentials, projectId: resource.projectId, kmsService }); - finalCredentials.password = decryptedCredentials.password; + finalCredentials = await factory.handleOverwritePreventionForCensoredValues( + rotationAccountCredentials, + decryptedCredentials + ); } try { @@ -268,22 +267,23 @@ export const pamResourceServiceFactory = ({ } }; - const list = async (projectId: string, actor: OrgServiceActor) => { + const list = async ({ projectId, actor, actorId, actorAuthMethod, actorOrgId, ...params }: TListResourcesDTO) => { const { permission } = await permissionService.getProjectPermission({ - actor: actor.type, - actorAuthMethod: actor.authMethod, - actorId: actor.id, - actorOrgId: actor.orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId, projectId, actionProjectType: ActionProjectType.PAM }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PamResources); - const resources = await pamResourceDAL.find({ projectId }); + const { resources, totalCount } = await pamResourceDAL.findByProjectId({ projectId, ...params }); return { - resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))) + resources: await Promise.all(resources.map((resource) => decryptResource(resource, projectId, kmsService))), + totalCount }; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 1ca9db3e2..9da094801 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,3 +1,5 @@ +import { OrderByDirection, TProjectPermission } from "@app/lib/types"; + import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TMySQLAccount, @@ -5,22 +7,31 @@ import { TMySQLResource, TMySQLResourceConnectionDetails } from "./mysql/mysql-resource-types"; -import { PamResource } from "./pam-resource-enums"; +import { PamResource, PamResourceOrderBy } from "./pam-resource-enums"; import { TPostgresAccount, TPostgresAccountCredentials, TPostgresResource, TPostgresResourceConnectionDetails } from "./postgres/postgres-resource-types"; +import { + TSSHAccount, + TSSHAccountCredentials, + TSSHResource, + TSSHResourceConnectionDetails +} from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource; -export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResourceConnectionDetails = + | TPostgresResourceConnectionDetails + | TMySQLResourceConnectionDetails + | TSSHResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; +export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< @@ -32,6 +43,15 @@ export type TUpdateResourceDTO = Partial = () => Promise; export type TPamResourceFactoryValidateAccountCredentials = ( @@ -51,4 +71,5 @@ export type TPamResourceFactory; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; + handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise; }; 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 7dd7948ef..b3128c422 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 @@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory { + if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + password: currentCredentials.password + }; + } + + return updatedAccountCredentials; + }; + return { validateConnection, validateAccountCredentials, - rotateAccountCredentials + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues }; }; diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts new file mode 100644 index 000000000..9b6ed1f15 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts @@ -0,0 +1,5 @@ +export enum SSHAuthMethod { + Password = "password", + PublicKey = "public-key", + Certificate = "certificate" +} diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts new file mode 100644 index 000000000..b90aa00c6 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -0,0 +1,265 @@ +import { Client } from "ssh2"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; + +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { PamResource } from "../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { SSHAuthMethod } from "./ssh-resource-enums"; +import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TSSHResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (proxyPort: number) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + return operation(proxyPort); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + +export const sshResourceFactory: TPamResourceFactory = ( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service +) => { + const validateConnection = async () => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + let handshakeComplete = false; + + client.on("error", (err) => { + logger.info( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH client error event received" + ); + // If we got an authentication error, it means we successfully reached the SSH server + // and completed the SSH handshake - that's good enough for connection validation + if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) { + logger.info( + { handshakeComplete, errorMessage: err.message }, + "[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)" + ); + client.end(); + resolve(); + } else { + logger.error( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH connection validation failed" + ); + reject(err); + } + }); + + client.on("handshake", () => { + // SSH handshake completed - the server is reachable and responding + logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true"); + handshakeComplete = true; + client.end(); + resolve(); + }); + + client.on("timeout", () => { + logger.error("[SSH Resource Factory] SSH connection timeout"); + reject(new Error("Connection timeout")); + }); + + // Attempt connection with a dummy username (we don't care about auth success) + // The goal is just to verify SSH server is reachable and responding + client.connect({ + host: "localhost", + port: proxyPort, + username: "infisical-connection-test", + password: "infisical-connection-test-password", + readyTimeout: EXTERNAL_REQUEST_TIMEOUT, + tryKeyboard: false, + // We want to fail fast on auth, we're just testing reachability + authHandler: () => { + // If authHandler is called, SSH handshake succeeded + handshakeComplete = true; + return false; // Don't continue with auth + } + }); + }); + }); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + + client.on("ready", () => { + logger.info( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication successful" + ); + client.end(); + resolve(); + }); + + client.on("error", (err) => { + logger.error( + { error: err.message, username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication failed" + ); + reject(err); + }); + + client.on("timeout", () => { + logger.error( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication timeout" + ); + reject(new Error("Connection timeout")); + }); + + // Build connection config based on auth method + const baseConfig = { + host: "localhost", + port: proxyPort, + username: credentials.username, + readyTimeout: EXTERNAL_REQUEST_TIMEOUT + }; + + switch (credentials.authMethod) { + case SSHAuthMethod.Password: + client.connect({ + ...baseConfig, + password: credentials.password, + tryKeyboard: false + }); + break; + case SSHAuthMethod.PublicKey: + client.connect({ + ...baseConfig, + privateKey: credentials.privateKey, + tryKeyboard: false + }); + break; + default: + reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`)); + } + }); + }); + return credentials; + } catch (error) { + if (error instanceof Error) { + // Check for common authentication failure messages + if ( + error.message.includes("authentication") || + error.message.includes("All configured authentication methods failed") || + error.message.includes("publickey") + ) { + throw new BadRequestError({ + message: "Account credentials invalid." + }); + } + + if (error.message === "Connection timeout") { + throw new BadRequestError({ + message: "Connection timeout. Verify that the SSH server is reachable" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + rotationAccountCredentials + ) => { + return rotationAccountCredentials; + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TSSHAccountCredentials, + currentCredentials: TSSHAccountCredentials + ) => { + if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) { + return updatedAccountCredentials; + } + + if ( + updatedAccountCredentials.authMethod === SSHAuthMethod.Password && + currentCredentials.authMethod === SSHAuthMethod.Password + ) { + if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + password: currentCredentials.password + }; + } + } + + if ( + updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey && + currentCredentials.authMethod === SSHAuthMethod.PublicKey + ) { + if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + privateKey: currentCredentials.privateKey + }; + } + } + + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts new file mode 100644 index 000000000..97d462369 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { SSHAuthMethod } from "./ssh-resource-enums"; + +export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) }); + +export const SSHResourceListItemSchema = z.object({ + name: z.literal("SSH"), + resource: z.literal(PamResource.SSH) +}); + +export const SSHResourceConnectionDetailsSchema = z.object({ + host: z.string().trim().max(255), + port: z.number() +}); + +export const SSHPasswordCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string().trim().max(255), + password: z.string().trim().max(255) +}); + +export const SSHPublicKeyCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string().trim().max(255), + privateKey: z.string().trim().max(5000) +}); + +export const SSHCertificateCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string().trim().max(255) +}); + +export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + SSHPasswordCredentialsSchema, + SSHPublicKeyCredentialsSchema, + SSHCertificateCredentialsSchema +]); + +export const SSHResourceSchema = BaseSSHResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: z + .discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string() + }) + ]) + .nullable() + .optional() +}); + +export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const SSHAccountSchema = BasePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema +}); + +export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema +}); + +export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema.optional() +}); + +export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string() + }) + ]) +}); + +// Sessions +export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts new file mode 100644 index 000000000..920dc4274 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + SSHAccountCredentialsSchema, + SSHAccountSchema, + SSHResourceConnectionDetailsSchema, + SSHResourceSchema +} from "./ssh-resource-schemas"; + +// Resources +export type TSSHResource = z.infer; +export type TSSHResourceConnectionDetails = z.infer; + +// Accounts +export type TSSHAccount = z.infer; +export type TSSHAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts index 4afe205b5..38e9f6239 100644 --- a/backend/src/ee/services/pam-session/pam-session-fns.ts +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; -import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; +import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session-types"; export const decryptSessionCommandLogs = async ({ projectId, @@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({ cipherTextBlob: encryptedLogs }); - return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; + return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[]; }; export const decryptSession = async ( @@ -32,7 +32,7 @@ export const decryptSession = async ( ) => { return { ...session, - commandLogs: session.encryptedLogsBlob + logs: session.encryptedLogsBlob ? await decryptSessionCommandLogs({ projectId, encryptedLogs: session.encryptedLogsBlob, diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts index 2bc1d5345..db2493196 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({ timestamp: z.coerce.date() }); +// SSH Terminal Event schemas +export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); + +export const TerminalEventSchema = z.object({ + timestamp: z.coerce.date(), + eventType: TerminalEventTypeSchema, + data: z.string(), // Base64 encoded binary data + elapsedTime: z.number() // Seconds since session start (for replay) +}); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - commandLogs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }); diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts index 26ff7daa6..18c185cac 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -12,10 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission"; -import { TUpdateSessionLogsDTO } from "./pam-session.types"; import { TPamSessionDALFactory } from "./pam-session-dal"; import { PamSessionStatus } from "./pam-session-enums"; import { decryptSession } from "./pam-session-fns"; +import { TUpdateSessionLogsDTO } from "./pam-session-types"; type TPamSessionServiceFactoryDep = { pamSessionDAL: TPamSessionDALFactory; diff --git a/backend/src/ee/services/pam-session/pam-session.types.ts b/backend/src/ee/services/pam-session/pam-session-types.ts similarity index 52% rename from backend/src/ee/services/pam-session/pam-session.types.ts rename to backend/src/ee/services/pam-session/pam-session-types.ts index 0c87a9fa4..893f930e5 100644 --- a/backend/src/ee/services/pam-session/pam-session.types.ts +++ b/backend/src/ee/services/pam-session/pam-session-types.ts @@ -1,12 +1,13 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas"; +import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; +export type TTerminalEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: TPamSessionCommandLog[]; + logs: (TPamSessionCommandLog | TTerminalEvent)[]; }; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 81814a67c..7b2e0ae00 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -5,6 +5,7 @@ import { ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -44,9 +45,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Settings, ProjectPermissionSub.Environments, ProjectPermissionSub.Tags, - ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, - ProjectPermissionSub.CertificateAuthorities, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, @@ -67,6 +66,20 @@ const buildAdminPermissionRules = () => { ); }); + can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs); + + can( + [ + ProjectPermissionCertificateAuthorityActions.Read, + ProjectPermissionCertificateAuthorityActions.Create, + ProjectPermissionCertificateAuthorityActions.Edit, + ProjectPermissionCertificateAuthorityActions.Delete, + ProjectPermissionCertificateAuthorityActions.Renew, + ProjectPermissionCertificateAuthorityActions.SignIntermediate + ], + ProjectPermissionSub.CertificateAuthorities + ); + can( [ ProjectPermissionPkiTemplateActions.Read, @@ -95,7 +108,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionCertificateActions.Edit, ProjectPermissionCertificateActions.Create, ProjectPermissionCertificateActions.Delete, - ProjectPermissionCertificateActions.ReadPrivateKey + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionCertificateActions.Import ], ProjectPermissionSub.Certificates ); @@ -460,7 +474,7 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); // double check if all CRUD are needed for CA and Certificates - can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionCertificateAuthorityActions.Read], ProjectPermissionSub.CertificateAuthorities); can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can( @@ -468,7 +482,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionCertificateActions.Read, ProjectPermissionCertificateActions.Edit, ProjectPermissionCertificateActions.Create, - ProjectPermissionCertificateActions.Delete + ProjectPermissionCertificateActions.Delete, + ProjectPermissionCertificateActions.Import ], ProjectPermissionSub.Certificates ); @@ -599,7 +614,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); - can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionCertificateAuthorityActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); can(ProjectPermissionPkiTemplateActions.Read, ProjectPermissionSub.CertificateTemplates); can(ProjectPermissionCmekActions.Read, ProjectPermissionSub.Cmek); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 19340644a..20f9c1f09 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -23,12 +23,22 @@ export enum ProjectPermissionCommitsActions { PerformRollback = "perform-rollback" } +export enum ProjectPermissionCertificateAuthorityActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + Renew = "renew", + SignIntermediate = "sign-intermediate" +} + export enum ProjectPermissionCertificateActions { Read = "read", Create = "create", Edit = "edit", Delete = "delete", - ReadPrivateKey = "read-private-key" + ReadPrivateKey = "read-private-key", + Import = "import" } export enum ProjectPermissionSecretActions { @@ -292,7 +302,8 @@ export type SecretSyncSubjectFields = { }; export type PkiSyncSubjectFields = { - subscriberName: string; + subscriberName?: string; + name: string; }; export type DynamicSecretSubjectFields = { @@ -332,6 +343,26 @@ export type PkiSubscriberSubjectFields = { // (dangtony98): consider adding [commonName] as a subject field in the future }; +export type CertificateAuthoritySubjectFields = { + name: string; +}; + +export type CertificateSubjectFields = { + commonName?: string; + altNames?: string; + serialNumber?: string; + friendlyName?: string; + status?: string; +}; + +export type CertificateProfileSubjectFields = { + slug: string; +}; + +export type CertificateTemplateV2SubjectFields = { + name: string; +}; + export type AppConnectionSubjectFields = { connectionId: string; }; @@ -399,8 +430,17 @@ export type ProjectPermissionSet = ProjectPermissionIdentityActions, ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) ] - | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] - | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] + | [ + ProjectPermissionCertificateAuthorityActions, + ( + | ProjectPermissionSub.CertificateAuthorities + | (ForcedSubject & CertificateAuthoritySubjectFields) + ) + ] + | [ + ProjectPermissionCertificateActions, + ProjectPermissionSub.Certificates | (ForcedSubject & CertificateSubjectFields) + ] | [ ProjectPermissionPkiTemplateActions, ( @@ -454,7 +494,13 @@ export type ProjectPermissionSet = ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) ] | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] - | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; + | [ + ProjectPermissionCertificateProfileActions, + ( + | ProjectPermissionSub.CertificateProfiles + | (ForcedSubject & CertificateProfileSubjectFields) + ) + ]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -572,6 +618,17 @@ const SecretSyncConditionV2Schema = z const PkiSyncConditionSchema = z .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), subscriberName: z.union([ z.string(), z @@ -698,6 +755,7 @@ const PkiTemplateConditionSchema = z z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB], [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] }) @@ -749,6 +807,98 @@ const PamAccountConditionSchema = z }) .partial(); +const CertificateAuthorityConditionSchema = z + .object({ + name: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); + +const CertificateConditionSchema = z + .object({ + commonName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + altNames: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + serialNumber: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + friendlyName: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + status: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); + +const CertificateProfileConditionSchema = z + .object({ + slug: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]) + }) + .partial(); + const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), @@ -828,18 +978,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.CertificateAuthorities).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), - z.object({ - subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z .literal(ProjectPermissionSub.SshCertificateAuthorities) @@ -1130,7 +1268,30 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ inverted: z.boolean().optional().describe("Whether rule allows or forbids."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe( "Describe what action an entity can take." - ) + ), + conditions: CertificateProfileConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateAuthorities).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateAuthorityActions).describe( + "Describe what action an entity can take." + ), + conditions: CertificateAuthorityConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), + z.object({ + subject: z.literal(ProjectPermissionSub.Certificates).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateActions).describe( + "Describe what action an entity can take." + ), + conditions: CertificateConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() }), ...GeneralPermissionSchema ]); 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 index 9148b0336..7379d7ece 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -1,3 +1,6 @@ +import axios, { AxiosError } from "axios"; + +import { TPkiAcmeChallenges } from "@app/db/schemas/pki-acme-challenges"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; @@ -13,14 +16,14 @@ import { 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" + | "transaction" + | "findByIdForChallengeValidation" + | "markAsValidCascadeById" + | "markAsInvalidCascadeById" + | "updateById" >; }; @@ -28,9 +31,8 @@ export const pkiAcmeChallengeServiceFactory = ({ acmeChallengeDAL }: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { const appCfg = getConfig(); - - const validateChallengeResponse = async (challengeId: string): Promise => { - const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => { + const markChallengeAsReady = async (challengeId: string): Promise => { + return acmeChallengeDAL.transaction(async (tx) => { logger.info({ challengeId }, "Validating ACME challenge response"); const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); if (!challenge) { @@ -54,84 +56,102 @@ export const pkiAcmeChallengeServiceFactory = ({ if (challenge.type !== AcmeChallengeType.HTTP_01) { throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); } - let host = challenge.auth.identifierValue; + const 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; - } + return acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Processing }, tx); }); - if (error) { - throw error; + }; + + const validateChallengeResponse = async (challengeId: string, retryCount: number): Promise => { + logger.info({ challengeId, retryCount }, "Validating ACME challenge response"); + const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId); + if (!challenge) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + if (challenge.status !== AcmeChallengeStatus.Processing) { + throw new BadRequestError({ + message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}` + }); + } + let host = challenge.auth.identifierValue; + 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 axios.get(challengeUrl.toString(), { + // In case if we override the host in the development mode, still provide the original host in the header + // to help the upstream server to validate the request + headers: { Host: challenge.auth.identifierValue }, + timeout: timeoutMs, + responseType: "text", + validateStatus: () => true + }); + if (challengeResponse.status !== 200) { + throw new AcmeIncorrectResponseError({ + message: `ACME challenge response is not 200: ${challengeResponse.status}` + }); + } + const challengeResponseBody: string = challengeResponse.data; + 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" }); + } + logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid"); + await acmeChallengeDAL.markAsValidCascadeById(challengeId); + } catch (exp) { + if (retryCount >= 2) { + logger.error( + exp, + `Last attempt to validate ACME challenge response failed, marking ${challengeId} challenge as invalid` + ); + // This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid + await acmeChallengeDAL.markAsInvalidCascadeById(challengeId); + } + // Properly type and inspect the error + if (axios.isAxiosError(exp)) { + const axiosError = exp as AxiosError; + const errorCode = axiosError.code; + const errorMessage = axiosError.message; + + if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { + throw new AcmeConnectionError({ message: "Connection refused" }); + } + if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { + throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) { + throw new AcmeConnectionError({ message: "Connection reset by peer" }); + } + if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) { + logger.error(exp, "Connection timed out while validating ACME challenge response"); + throw new AcmeConnectionError({ message: "Connection timed out" }); + } + logger.error(exp, "Unknown error validating ACME challenge response"); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } + if (exp instanceof Error) { + logger.error(exp, "Error validating ACME challenge response"); + throw exp; + } + logger.error(exp, "Unknown error validating ACME challenge response"); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } }; - return { validateChallengeResponse }; + return { markChallengeAsReady, validateChallengeResponse }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts index a5206d036..759e3cdf9 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-fns.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -8,7 +8,7 @@ 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}`; + return `${baseUrl}/api/v1/cert-manager/acme/profiles/${profileId}${path}`; }; export const extractAccountIdFromKid = (kid: string, profileId: string): string => { diff --git a/backend/src/ee/services/pki-acme/pki-acme-queue.ts b/backend/src/ee/services/pki-acme/pki-acme-queue.ts new file mode 100644 index 000000000..851159981 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-queue.ts @@ -0,0 +1,67 @@ +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; + +type TPkiAcmeQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + acmeChallengeService: TPkiAcmeChallengeServiceFactory; +}; + +export type TPkiAcmeQueueServiceFactory = Awaited>; + +export const pkiAcmeQueueServiceFactory = async ({ + queueService, + acmeChallengeService +}: TPkiAcmeQueueServiceFactoryDep) => { + const appCfg = getConfig(); + + // Initialize the worker to process challenge validation jobs + await queueService.startPg( + QueueJobs.PkiAcmeChallengeValidation, + async ([job]) => { + const { challengeId } = job.data; + const retryCount = job.retryCount || 0; + try { + logger.info({ challengeId, retryCount }, "Processing ACME challenge validation job"); + await acmeChallengeService.validateChallengeResponse(challengeId, retryCount); + logger.info({ challengeId, retryCount }, "ACME challenge validation completed successfully"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error( + error, + `Failed to validate ACME challenge ${challengeId} (retryCount ${retryCount}): ${errorMessage}` + ); + // Re-throw to let pg-boss handle retries with exponential backoff + throw error; + } + }, + { + batchSize: 1, + workerCount: 2, + pollingIntervalSeconds: 1 + } + ); + + const queueChallengeValidation = async (challengeId: string): Promise => { + if (appCfg.isSecondaryInstance) { + return; + } + + logger.info({ challengeId }, "Queueing ACME challenge validation"); + await queueService.queuePg( + QueueJobs.PkiAcmeChallengeValidation, + { challengeId }, + { + retryLimit: 3, + retryDelay: 30, // Base delay of 30 seconds + retryBackoff: true // Exponential backoff: 30s, 60s, 120s + } + ); + }; + + return { + queueChallengeValidation + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 58ca7e833..23b86d172 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -58,7 +58,15 @@ export const GetAcmeDirectoryResponseSchema = z.object({ newNonce: z.string(), newAccount: z.string(), newOrder: z.string(), - revokeCert: z.string().optional() + revokeCert: z.string().optional(), + meta: z + .object({ + termsOfService: z.string().optional(), + website: z.string().optional(), + caaIdentities: z.array(z.string()).optional(), + externalAccountRequired: z.boolean().optional() + }) + .optional() }); // New Account payload schema diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 43da08b1c..d9654e50b 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -31,12 +31,17 @@ import { orderCertificate } from "@app/services/certificate-authority/acme/acme- 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 { + extractAlgorithmsFromCSR, + extractCertificateRequestFromCSR +} from "@app/services/certificate-common/certificate-csr-utils"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType, TCertificateProfileWithConfigs } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -62,6 +67,7 @@ import { import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; +import { TPkiAcmeQueueServiceFactory } from "./pki-acme-queue"; import { AcmeAuthStatus, AcmeChallengeStatus, @@ -94,12 +100,13 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; appConnectionDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateAuthorityDAL: Pick; externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; + certificateTemplateV2DAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -126,7 +133,9 @@ type TPkiAcmeServiceFactoryDep = { >; licenseService: Pick; certificateV3Service: Pick; - acmeChallengeService: TPkiAcmeChallengeServiceFactory; + certificateTemplateV2Service: Pick; + acmeChallengeService: Pick; + pkiAcmeQueueService: Pick; }; export const pkiAcmeServiceFactory = ({ @@ -138,6 +147,7 @@ export const pkiAcmeServiceFactory = ({ certificateProfileDAL, certificateBodyDAL, certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -147,7 +157,9 @@ export const pkiAcmeServiceFactory = ({ kmsService, licenseService, certificateV3Service, - acmeChallengeService + certificateTemplateV2Service, + acmeChallengeService, + pkiAcmeQueueService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); @@ -206,6 +218,9 @@ export const pkiAcmeServiceFactory = ({ const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + if (protectedHeader.jwk && protectedHeader.kid) { + throw new AcmeMalformedError({ message: "Both JWK and KID are provided in the protected header" }); + } const parsedUrl = (() => { try { return new URL(protectedHeader.url); @@ -288,6 +303,7 @@ export const pkiAcmeServiceFactory = ({ url, rawJwsPayload, getJWK: async (protectedHeader) => { + // get jwk instead of kid if (!protectedHeader.kid) { throw new AcmeMalformedError({ message: "KID is required in the protected header" }); } @@ -353,7 +369,10 @@ export const pkiAcmeServiceFactory = ({ return { newNonce: buildUrl(profile.id, "/new-nonce"), newAccount: buildUrl(profile.id, "/new-account"), - newOrder: buildUrl(profile.id, "/new-order") + newOrder: buildUrl(profile.id, "/new-order"), + meta: { + externalAccountRequired: true + } }; }; @@ -386,11 +405,61 @@ export const pkiAcmeServiceFactory = ({ payload: TCreateAcmeAccountPayload; }): Promise> => { const profile = await validateAcmeProfile(profileId); + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); + + const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( + profileId, + alg, + publicKeyThumbprint + ); + if (onlyReturnExisting) { + if (!existingAccount) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); + } + 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"` + } + }; + } + + // Note: We only check EAB for the new account request. This is a very special case for cert-manager. + // There's a bug in their ACME client implementation, they don't take the account KID value they have + // and relying on a '{"onlyReturnExisting": true}' new-account request to find out their KID value. + // But the problem is, that new-account request doesn't come with EAB. And while the get existing account operation + // fails, they just discard the error and proceed to request a new order. Since no KID provided, their ACME + // client will send JWK instead. As a result, we are seeing KID not provide in header error for the new-order + // endpoint. + // + // To solve the problem, we lose the check for EAB a bit for the onlyReturnExisting new account request. + // It should be fine as we've already checked EAB when they created the account. + // And the private key ownership indicating they are the same user. + // ref: https://github.com/cert-manager/cert-manager/issues/7388#issuecomment-3535630925 if (!externalAccountBinding) { throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } + if (existingAccount) { + 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 publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ projectId: profile.projectId, projectDAL, @@ -441,30 +510,7 @@ export const pkiAcmeServiceFactory = ({ }); } - 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"` - } - }; - } - + // TODO: handle unique constraint violation error, should be very very rare const newAccount = await acmeAccountDAL.create({ profileId: profile.id, alg, @@ -649,6 +695,13 @@ export const pkiAcmeServiceFactory = ({ payload: TFinalizeAcmeOrderPayload; }): Promise> => { const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!; + + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for ACME enrollment" + }); + } + let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); @@ -669,9 +722,6 @@ export const pkiAcmeServiceFactory = ({ // 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 @@ -687,7 +737,7 @@ export const pkiAcmeServiceFactory = ({ const csrIdentifierValues = new Set( (certificateRequest.subjectAlternativeNames ?? []) .map((san) => san.value.toLowerCase()) - .concat([certificateRequest.commonName.toLowerCase()]) + .concat(certificateRequest.commonName ? [certificateRequest.commonName.toLowerCase()] : []) ); if ( csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length || @@ -698,7 +748,7 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" }); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId!); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); } @@ -731,14 +781,39 @@ export const pkiAcmeServiceFactory = ({ 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 + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + + certificateRequest.keyAlgorithm = extractedKeyAlgorithm; + certificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; + if (finalizingOrder.notAfter) { + const notBefore = finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : new Date(); + const notAfter = new Date(finalizingOrder.notAfter); + const diffMs = notAfter.getTime() - notBefore.getTime(); + const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + certificateRequest.validity = { ttl: `${diffDays}d` }; + } + + const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + template.id, + certificateRequest + ); + if (!validationResult.isValid) { + throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); + } // 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!, + // It is possible that the CSR does not have a common name, in which case we use an empty string + // (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.) + 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 @@ -781,6 +856,8 @@ export const pkiAcmeServiceFactory = ({ // TODO: audit log the error if (exp instanceof BadRequestError) { errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` }); + } else if (exp instanceof AcmeError) { + errorToReturn = exp; } else { errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); } @@ -935,7 +1012,8 @@ export const pkiAcmeServiceFactory = ({ if (!result) { throw new NotFoundError({ message: "ACME challenge not found" }); } - await acmeChallengeService.validateChallengeResponse(challengeId); + await acmeChallengeService.markChallengeAsReady(challengeId); + await pkiAcmeQueueService.queueChallengeValidation(challengeId); const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!; return { status: 200, diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts index 3ddb424f1..6607ce711 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-types.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -1,6 +1,8 @@ import { JWSHeaderParameters } from "jose"; import { z } from "zod"; +import { TPkiAcmeChallenges } from "@app/db/schemas/pki-acme-challenges"; + import { AcmeOrderResourceSchema, CreateAcmeAccountBodySchema, @@ -176,5 +178,6 @@ export type TPkiAcmeServiceFactory = { }; export type TPkiAcmeChallengeServiceFactory = { - validateChallengeResponse: (challengeId: string) => Promise; + markChallengeAsReady: (challengeId: string) => Promise; + validateChallengeResponse: (challengeId: string, retryCount: number) => Promise; }; diff --git a/backend/src/ee/services/project-template/project-template-service.ts b/backend/src/ee/services/project-template/project-template-service.ts index 5a9f04d8d..1ba21873a 100644 --- a/backend/src/ee/services/project-template/project-template-service.ts +++ b/backend/src/ee/services/project-template/project-template-service.ts @@ -189,11 +189,15 @@ export const projectTemplateServiceFactory = ({ message: `A project template with the name "${params.name}" already exists.` }); + const projectTemplateEnvironments = + type === ProjectType.SecretManager && environments === undefined + ? ProjectTemplateDefaultEnvironments + : environments; + const projectTemplate = await projectTemplateDAL.create({ ...params, roles: JSON.stringify(roles.map((role) => ({ ...role, permissions: packRules(role.permissions) }))), - environments: - type === ProjectType.SecretManager ? JSON.stringify(environments ?? ProjectTemplateDefaultEnvironments) : null, + environments: JSON.stringify(projectTemplateEnvironments), orgId: actor.orgId, type }); diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 7206bd293..38411627a 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -622,7 +622,7 @@ export const samlConfigServiceFactory = ({ const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); newUser = await userDAL.create( { - username: serverCfg.trustSamlEmails ? email : uniqueUsername, + username: serverCfg.trustSamlEmails ? email.toLowerCase() : uniqueUsername, email, isEmailVerified: serverCfg.trustSamlEmails, firstName, @@ -639,7 +639,7 @@ export const samlConfigServiceFactory = ({ userId: newUser.id, aliasType: UserAliasType.SAML, externalId, - emails: email ? [email] : [], + emails: email ? [email.toLowerCase()] : [], orgId, isEmailVerified: serverCfg.trustSamlEmails }, diff --git a/backend/src/ee/services/scim/scim-dal.ts b/backend/src/ee/services/scim/scim-dal.ts index 77a19d4d2..e856070e1 100644 --- a/backend/src/ee/services/scim/scim-dal.ts +++ b/backend/src/ee/services/scim/scim-dal.ts @@ -1,10 +1,56 @@ +import { Knex } from "knex"; + import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify, TOrmify } from "@app/lib/knex"; +import { AccessScope, OrgMembershipRole, OrgMembershipStatus, TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; -export type TScimDALFactory = TOrmify; +import { TExpiringScimToken } from "./scim-types"; -export const scimDALFactory = (db: TDbClient): TScimDALFactory => { +export type TScimDALFactory = ReturnType; + +export const scimDALFactory = (db: TDbClient) => { const scimTokenOrm = ormify(db, TableName.ScimToken); - return scimTokenOrm; + + const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0): Promise => { + try { + const batch = await (tx || db.replicaNode())(TableName.ScimToken) + .leftJoin(TableName.Organization, `${TableName.Organization}.id`, `${TableName.ScimToken}.orgId`) + .leftJoin(TableName.Membership, `${TableName.Membership}.scopeOrgId`, `${TableName.ScimToken}.orgId`) + .leftJoin(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) + .leftJoin(TableName.Users, `${TableName.Users}.id`, `${TableName.Membership}.actorUserId`) + .whereRaw( + ` + (${TableName.ScimToken}."ttlDays" > 0 AND + (${TableName.ScimToken}."createdAt" + INTERVAL '1 day' * ${TableName.ScimToken}."ttlDays") < NOW() + INTERVAL '7 days' AND + (${TableName.ScimToken}."createdAt" + INTERVAL '1 day' * ${TableName.ScimToken}."ttlDays") > NOW()) + ` + ) + .where(`${TableName.ScimToken}.expiryNotificationSent`, false) + .where(`${TableName.Membership}.scope`, AccessScope.Organization) + .where(`${TableName.MembershipRole}.role`, OrgMembershipRole.Admin) + .whereNot(`${TableName.Membership}.status`, OrgMembershipStatus.Invited) + .whereNotNull(`${TableName.Membership}.actorUserId`) + .where(`${TableName.Users}.isGhost`, false) + .whereNotNull(`${TableName.Users}.email`) + .groupBy([`${TableName.ScimToken}.id`, `${TableName.Organization}.name`]) + .select([ + db.ref("id").withSchema(TableName.ScimToken), + db.ref("ttlDays").withSchema(TableName.ScimToken), + db.ref("description").withSchema(TableName.ScimToken), + db.ref("orgId").withSchema(TableName.ScimToken), + db.ref("createdAt").withSchema(TableName.ScimToken), + db.ref("name").withSchema(TableName.Organization).as("orgName"), + db.raw(`array_agg(${TableName.Users}."email") as "adminEmails"`) + ]) + .limit(batchSize) + .offset(offset); + + return batch; + } catch (err) { + throw new DatabaseError({ error: err, name: "FindExpiringTokens" }); + } + }; + + return { ...scimTokenOrm, findExpiringTokens }; }; diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 8b9256023..465ed3ee5 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -19,6 +19,7 @@ import { TScimDALFactory } from "@app/ee/services/scim/scim-dal"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TAdditionalPrivilegeDALFactory } from "@app/services/additional-privilege/additional-privilege-dal"; import { AuthTokenType } from "@app/services/auth/auth-type"; @@ -47,7 +48,7 @@ import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, p import { TScimGroup, TScimServiceFactory } from "./scim-types"; type TScimServiceFactoryDep = { - scimDAL: Pick; + scimDAL: Pick; userDAL: Pick< TUserDALFactory, "find" | "findOne" | "create" | "transaction" | "findUserEncKeyByUserIdsBatch" | "findById" | "updateById" @@ -389,15 +390,13 @@ export const scimServiceFactory = ({ ); } } else { - if (trustScimEmails) { - user = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + user = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!user) { const uniqueUsername = await normalizeUsername( @@ -425,7 +424,8 @@ export const scimServiceFactory = ({ aliasType, externalId, emails: email ? [email.toLowerCase()] : [], - orgId + orgId, + isEmailVerified: trustScimEmails }, tx ); @@ -1237,6 +1237,70 @@ export const scimServiceFactory = ({ return { scimTokenId: scimToken.id, orgId: scimToken.orgId }; }; + const notifyExpiringTokens: TScimServiceFactory["notifyExpiringTokens"] = async () => { + const appCfg = getConfig(); + let processedCount = 0; + let hasMoreRecords = true; + let offset = 0; + const batchSize = 500; + + while (hasMoreRecords) { + // eslint-disable-next-line no-await-in-loop + const expiringTokens = await scimDAL.findExpiringTokens(undefined, batchSize, offset); + + if (expiringTokens.length === 0) { + hasMoreRecords = false; + break; + } + + const successfullyNotifiedTokenIds: string[] = []; + + // eslint-disable-next-line no-await-in-loop + await Promise.all( + expiringTokens.map(async (token) => { + try { + if (token.adminEmails.length === 0) { + // Still mark as notified to avoid repeated checks + successfullyNotifiedTokenIds.push(token.id); + return; + } + + const createdOn = new Date(token.createdAt); + const expiringOn = new Date(createdOn.getTime() + Number(token.ttlDays) * 86400 * 1000); + + await smtpService.sendMail({ + recipients: token.adminEmails, + subjectLine: "SCIM Token Expiry Notice", + template: SmtpTemplates.ScimTokenExpired, + substitutions: { + tokenDescription: token.description, + orgName: token.orgName, + url: `${appCfg.SITE_URL}/organizations/${token.orgId}/settings?selectedTab=provisioning-settings`, + createdOn, + expiringOn + } + }); + + successfullyNotifiedTokenIds.push(token.id); + } catch (error) { + logger.error(error, `Failed to send expiration notification for SCIM token ${token.id}:`); + } + }) + ); + + // Batch update all successfully notified tokens in a single query + if (successfullyNotifiedTokenIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await scimDAL.update({ $in: { id: successfullyNotifiedTokenIds } }, { expiryNotificationSent: true }); + } + + processedCount += expiringTokens.length; + offset += batchSize; + } + + return processedCount; + }; + return { createScimToken, listScimTokens, @@ -1253,6 +1317,7 @@ export const scimServiceFactory = ({ deleteScimGroup, replaceScimGroup, updateScimGroup, - fnValidateScimToken + fnValidateScimToken, + notifyExpiringTokens }; }; diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 8bdea39e1..1275ef283 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -158,6 +158,16 @@ export type TScimGroup = { }; }; +export type TExpiringScimToken = { + id: string; + ttlDays: number; + description: string; + orgId: string; + createdAt: Date; + orgName: string; + adminEmails: string[]; +}; + export type TScimServiceFactory = { createScimToken: (arg: TCreateScimTokenDTO) => Promise<{ scimToken: string; @@ -200,4 +210,5 @@ export type TScimServiceFactory = { scimTokenId: string; orgId: string; }>; + notifyExpiringTokens: () => Promise; }; 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 2610d9324..395dd13bd 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 @@ -181,11 +181,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), - tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt"), - tx - .ref("shouldCheckSecretPermission") - .withSchema(TableName.SecretApprovalPolicy) - .as("policySecretReadAccessCompat") + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") ); const findById = async (id: string, tx?: Knex) => { @@ -225,8 +221,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { enforcementLevel: el.policyEnforcementLevel, envId: el.policyEnvId, deletedAt: el.policyDeletedAt, - allowedSelfApprovals: el.policyAllowedSelfApprovals, - shouldCheckSecretPermission: el.policySecretReadAccessCompat + allowedSelfApprovals: el.policyAllowedSelfApprovals } }), childrenMapper: [ @@ -354,16 +349,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { (tx || db.replicaNode())(TableName.SecretApprovalRequest) .join(TableName.SecretFolder, `${TableName.SecretApprovalRequest}.folderId`, `${TableName.SecretFolder}.id`) .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) - .join( - TableName.SecretApprovalPolicyApprover, - `${TableName.SecretApprovalRequest}.policyId`, - `${TableName.SecretApprovalPolicyApprover}.policyId` - ) .join( TableName.SecretApprovalPolicy, `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicy}.id` ) + .leftJoin( + TableName.SecretApprovalPolicyApprover, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyApprover}.policyId` + ) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, + `${TableName.UserGroupMembership}.groupId` + ) .where({ projectId }) .where((qb) => { if (policyId) void qb.where(`${TableName.SecretApprovalPolicy}.id`, policyId); @@ -373,10 +373,10 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { void bd .where(`${TableName.SecretApprovalPolicyApprover}.approverUserId`, userId) .orWhere(`${TableName.SecretApprovalRequest}.committerUserId`, userId) + .orWhere(`${TableName.UserGroupMembership}.userId`, userId) ) .select("status", `${TableName.SecretApprovalRequest}.id`) .groupBy(`${TableName.SecretApprovalRequest}.id`, "status") - .count("status") ) .select("status") .from("temp") @@ -499,7 +499,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { const query = (tx || db.replicaNode()) .select("*") - .select(db.raw("count(*) OVER() as total_count")) .from(innerQuery) .orderBy("createdAt", "desc") as typeof innerQuery; @@ -519,6 +518,14 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { }); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const countResult = await (tx || db.replicaNode()) + .count({ count: "*" }) + .from(query.clone().as("count_query")) + .first(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const totalCount = Number(countResult?.count || 0); + const docs = await (tx || db) .with("w", query) .select("*") @@ -526,9 +533,6 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - // @ts-expect-error knex does not infer - const totalCount = Number(docs[0]?.total_count || 0); - const formattedDoc = sqlNestRelationships({ data: docs, key: "id", 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 8f1c3d060..69d36e66f 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` + link: `/organizations/${project.orgId}/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}` + approvalUrl: `${cfg.SITE_URL}/organizations/${project.orgId}/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 e6455c113..6b0f7e0ed 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 @@ -1037,7 +1037,7 @@ export const secretApprovalRequestServiceFactory = ({ bypassReason, secretPath: policy.secretPath, environment: env.name, - approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval` + approvalUrl: `${cfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/approval` }, template: SmtpTemplates.AccessSecretRequestBypassed }); @@ -1416,7 +1416,7 @@ export const secretApprovalRequestServiceFactory = ({ const env = await projectEnvDAL.findOne({ id: policy.envId }); const user = await userDAL.findById(actorId); - const projectPath = `/projects/secret-management/${projectId}`; + const projectPath = `/organizations/${actorOrgId}/projects/secret-management/${projectId}`; const approvalPath = `${projectPath}/approval`; const cfg = getConfig(); const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; @@ -1792,7 +1792,7 @@ 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 projectPath = `/organizations/${actorOrgId}/projects/secret-management/${project.id}`; const approvalPath = `${projectPath}/approval`; const cfg = getConfig(); const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts index f653802b6..3c902e112 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts @@ -156,7 +156,7 @@ export const secretRotationV2QueueServiceFactory = async ({ const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation]; - const rotationPath = `/projects/secret-management/${projectId}/secrets/${environment.slug}`; + const rotationPath = `/organizations/${project.orgId}/projects/secret-management/${projectId}/secrets/${environment.slug}`; await notificationService.createUserNotifications( projectAdmins.map((admin) => ({ diff --git a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts index 406c25e03..2b6ab6a20 100644 --- a/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts +++ b/backend/src/ee/services/secret-scanning-v2/secret-scanning-v2-queue.ts @@ -637,7 +637,7 @@ export const secretScanningV2QueueServiceFactory = async ({ numberOfSecrets: payload.numberOfSecrets, isDiffScan: payload.isDiffScan, url: encodeURI( - `${appCfg.SITE_URL}/projects/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` + `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-scanning/${projectId}/findings?search=scanId:${payload.scanId}` ), timestamp } @@ -648,7 +648,7 @@ export const secretScanningV2QueueServiceFactory = async ({ timestamp, errorMessage: payload.errorMessage, url: encodeURI( - `${appCfg.SITE_URL}/projects/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` + `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-scanning/${projectId}/data-sources/${dataSource.type}/${dataSource.id}` ) } }); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 15ec747ef..3c8972248 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -106,6 +106,16 @@ export const GROUPS = { filterUsers: "Whether to filter the list of returned users. 'existingMembers' will only return existing users in the group, 'nonMembers' will only return users not in the group, undefined will return all users in the organization." }, + LIST_PROJECTS: { + id: "The ID of the group to list projects for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th project.", + limit: "The number of projects to return.", + search: "The text string that project name or slug will be filtered by.", + filterProjects: + "Whether to filter the list of returned projects. 'assignedProjects' will only return projects assigned to the group, 'unassignedProjects' will only return projects not assigned to the group, undefined will return all projects in the organization.", + orderBy: "The column to order projects by.", + orderDirection: "The direction to order projects in." + }, ADD_USER: { id: "The ID of the group to add the user to.", username: "The username of the user to add to the group." @@ -584,6 +594,10 @@ export const TOKEN_AUTH = { offset: "The offset to start from. If you enter 10, it will start from the 10th token.", limit: "The number of tokens to return." }, + GET_TOKEN: { + identityId: "The ID of the machine identity to get the token for.", + tokenId: "The ID of the token to get metadata for." + }, CREATE_TOKEN: { identityId: "The ID of the machine identity to create the token for.", name: "The name of the token to create." @@ -1948,9 +1962,11 @@ export const CERTIFICATE_AUTHORITIES = { export const CERTIFICATES = { GET: { + id: "The ID of the certificate to get.", serialNumber: "The serial number of the certificate to get." }, REVOKE: { + id: "The ID of the certificate to revoke.", serialNumber: "The serial number of the certificate to revoke. The revoked certificate will be added to the certificate revocation list (CRL) of the CA.", revocationReason: "The reason for revoking the certificate.", @@ -1958,9 +1974,11 @@ export const CERTIFICATES = { serialNumberRes: "The serial number of the revoked certificate." }, DELETE: { + id: "The ID of the certificate to delete.", serialNumber: "The serial number of the certificate to delete." }, GET_CERT: { + id: "The ID of the certificate to get the certificate body and certificate chain for.", serialNumber: "The serial number of the certificate to get the certificate body and certificate chain for.", certificate: "The certificate body of the certificate.", certificateChain: "The certificate chain of the certificate.", diff --git a/backend/src/lib/casl/permission-filter-utils.ts b/backend/src/lib/casl/permission-filter-utils.ts new file mode 100644 index 000000000..35f519c33 --- /dev/null +++ b/backend/src/lib/casl/permission-filter-utils.ts @@ -0,0 +1,225 @@ +import type { MongoAbility, MongoQuery, RawRuleOf } from "@casl/ability"; +import RE2 from "re2"; + +export interface PermissionFilterConfig { + operator: string; + value: unknown; + isPattern: boolean; + isInverted?: boolean; +} + +export type PermissionFilters = Record>; + +export interface ProcessedPermissionRules { + allowRules: Array>>; + forbidRules: Array>>; +} + +interface MongoRegexFilter { + $regex: RegExp; +} + +interface MongoEqFilter { + $eq: unknown; +} + +interface MongoInFilter { + $in: unknown[]; +} + +interface MongoNeFilter { + $ne: unknown; +} + +interface MongoGlobFilter { + $glob: unknown; +} + +/** + * Builds permission filters from CASL MongoDB-style conditions + * @param conditions - MongoDB-style conditions from CASL ability + * @param isInverted - Whether this rule is inverted (forbidden) + * @returns Record of field names to arrays of filter configurations + */ +const buildPermissionFiltersFromConditions = (conditions: MongoQuery, isInverted = false): PermissionFilters => { + const permissionFilters: PermissionFilters = {}; + + function addFilterToField(key: string, operator: string, value: unknown, isPattern: boolean) { + if (!permissionFilters[key]) { + permissionFilters[key] = []; + } + + // Convert operators for inverted/forbidden rules + let finalOperator = operator; + if (isInverted) { + switch (operator) { + case "=": + finalOperator = "!="; + break; + case "!=": + finalOperator = "="; + break; + case "LIKE": + finalOperator = "NOT LIKE"; + break; + case "NOT LIKE": + finalOperator = "LIKE"; + break; + case "IN": + finalOperator = "NOT IN"; + break; + case "NOT IN": + finalOperator = "IN"; + break; + case ">": + finalOperator = "<="; + break; + case ">=": + finalOperator = "<"; + break; + case "<": + finalOperator = ">="; + break; + case "<=": + finalOperator = ">"; + break; + case "IS NULL": + finalOperator = "IS NOT NULL"; + break; + case "IS NOT NULL": + finalOperator = "IS NULL"; + break; + // Default: keep the same operator + default: + finalOperator = operator; + break; + } + } + + permissionFilters[key].push({ operator: finalOperator, value, isPattern, isInverted }); + } + + function processCondition(key: string, value: unknown) { + if (value && typeof value === "object") { + const valueObj = value as Record; + + const operatorKeys = ["$regex", "$eq", "$in", "$glob", "$ne"]; + const presentOperators = operatorKeys.filter((op) => op in valueObj); + + if (presentOperators.length > 1) { + if ("$eq" in valueObj) { + addFilterToField(key, "=", valueObj.$eq, false); + } + if ("$glob" in valueObj) { + addFilterToField(key, "LIKE", valueObj.$glob, true); + } + if ("$regex" in valueObj) { + const regexValue = valueObj.$regex as RegExp; + const regexPattern = regexValue.source; + const globPattern = regexPattern + .replace(new RE2("^\\\\\\^"), "") + .replace(new RE2("\\\\\\$$"), "") + .replace(new RE2("\\\\\\.\\*", "g"), "*"); + addFilterToField(key, "LIKE", globPattern, true); + } + if ("$ne" in valueObj) { + const valueStr = String(valueObj.$ne); + const hasWildcards = valueStr.includes("*") || valueStr.includes("?"); + addFilterToField(key, hasWildcards ? "NOT LIKE" : "!=", valueObj.$ne, hasWildcards); + } + if ("$in" in valueObj) { + const inValues = valueObj.$in as unknown[]; + addFilterToField(key, "IN", inValues, false); + } + } else if ("$regex" in value) { + const regexFilter = value as MongoRegexFilter; + const regexPattern = regexFilter.$regex.source; + const globPattern = regexPattern + .replace(new RE2("^\\\\\\^"), "") + .replace(new RE2("\\\\\\$$"), "") + .replace(new RE2("\\\\\\.\\*", "g"), "*"); + addFilterToField(key, "LIKE", globPattern, true); + } else if ("$eq" in value) { + const eqFilter = value as MongoEqFilter; + addFilterToField(key, "=", eqFilter.$eq, false); + } else if ("$in" in value) { + const inFilter = value as MongoInFilter; + addFilterToField(key, "IN", inFilter.$in, false); + } else if ("$glob" in value) { + const globFilter = value as MongoGlobFilter; + addFilterToField(key, "LIKE", globFilter.$glob, true); + } else if ("$ne" in value) { + const neFilter = value as MongoNeFilter; + const valueStr = String(neFilter.$ne); + const hasWildcards = valueStr.includes("*") || valueStr.includes("?"); + addFilterToField(key, hasWildcards ? "NOT LIKE" : "!=", neFilter.$ne, hasWildcards); + } + } else { + addFilterToField(key, "=", value, false); + } + } + + function processConditions(mongoConditions: MongoQuery) { + if ( + mongoConditions && + typeof mongoConditions === "object" && + "$or" in mongoConditions && + Array.isArray(mongoConditions.$or) + ) { + mongoConditions.$or.forEach((orCondition: MongoQuery) => { + processConditions(orCondition); + }); + } else if (mongoConditions && typeof mongoConditions === "object") { + Object.entries(mongoConditions).forEach(([key, value]) => { + if (key.startsWith("$")) return; + processCondition(key, value); + }); + } + } + + if (conditions && typeof conditions === "object") { + processConditions(conditions); + } + + return permissionFilters; +}; + +/** + * Extract permission filters for a subject and action, + * converting them into ProcessedPermissionRules format for use with Knex queries. + * @param ability - CASL MongoAbility instance + * @param action - Permission action to filter for + * @param subjectName - Permission subject to filter for + * @returns ProcessedPermissionRules object for use with applyPermissionFiltersToQuery + */ +export function getProcessedPermissionRules( + ability: MongoAbility, + action: string, + subjectName: string +): ProcessedPermissionRules { + const matchingRules = ability.rules.filter((rule: RawRuleOf) => { + const actionMatches = Array.isArray(rule.action) ? rule.action.includes(action) : rule.action === action; + const subjectMatches = Array.isArray(rule.subject) + ? rule.subject.includes(subjectName) + : rule.subject === subjectName; + return actionMatches && subjectMatches && rule.conditions; + }); + + const allowRules: Array>> = []; + const forbidRules: Array>> = []; + + matchingRules.forEach((rule: RawRuleOf) => { + if (rule.conditions) { + const isInverted = rule.inverted || false; + const ruleFilters = buildPermissionFiltersFromConditions(rule.conditions, isInverted); + + if (isInverted) { + forbidRules.push(ruleFilters); + } else { + allowRules.push(ruleFilters); + } + } + }); + + return { allowRules, forbidRules }; +} diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 96107306f..21e83c2b7 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -119,6 +119,7 @@ const envSchema = z }) .default("{}") ), + DNS_MADE_EASY_SANDBOX_ENABLED: zodStrBool.default("false").optional(), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -400,7 +401,7 @@ const envSchema = z 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, + isBddNockApiEnabled: data.NODE_ENV !== "production" && data.BDD_NOCK_API_ENABLED, REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() ?.split(",") .map((el) => { diff --git a/backend/src/lib/delay/index.ts b/backend/src/lib/delay/index.ts index 32cb8ebfc..a5d4250fc 100644 --- a/backend/src/lib/delay/index.ts +++ b/backend/src/lib/delay/index.ts @@ -2,3 +2,13 @@ export const delay = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +export const applyJitter = (delayMs: number) => { + const jitterFactor = 0.2; + + // generates random value in [-0.2, +0.2] range + const randomFactor = (Math.random() * 2 - 1) * jitterFactor; + const jitterAmount = randomFactor * delayMs; + + return delayMs + jitterAmount; +}; diff --git a/backend/src/lib/knex/permission-filter-utils.ts b/backend/src/lib/knex/permission-filter-utils.ts new file mode 100644 index 000000000..d6e0f92a4 --- /dev/null +++ b/backend/src/lib/knex/permission-filter-utils.ts @@ -0,0 +1,145 @@ +import type { Knex } from "knex"; +import RE2 from "re2"; + +export interface PermissionFilterConfig { + operator: string; + value: unknown; + isPattern: boolean; + isInverted?: boolean; +} + +export type PermissionFilters = Record>; + +export interface ProcessedPermissionRules { + allowRules: Array>>; + forbidRules: Array>>; +} + +/** + * Applies a single filter configuration to a query + * @param query - The Knex query builder instance + * @param tableName - The name of the table to apply filters to + * @param key - The field name + * @param filterConfig - The filter configuration + */ +const applySingleFilter = ( + query: Knex.QueryBuilder, + tableName: string, + key: string, + filterConfig: PermissionFilterConfig +): void => { + if (filterConfig.value !== undefined && filterConfig.value !== null) { + const { operator, value, isPattern } = filterConfig; + const fieldName = `${tableName}.${key}`; + + switch (operator) { + case "=": + void query.andWhere(fieldName, "=", value as string | number); + break; + case "!=": + void query.andWhere(fieldName, "!=", value as string | number); + break; + case "LIKE": { + const likePattern = isPattern ? String(value).replace(new RE2("\\*", "g"), "%") : String(value); + void query.andWhere(fieldName, "like", likePattern); + break; + } + case "NOT LIKE": { + const notLikePattern = isPattern ? String(value).replace(new RE2("\\*", "g"), "%") : String(value); + void query.andWhere(fieldName, "not like", notLikePattern); + break; + } + case "IN": { + const inValues = Array.isArray(value) ? value : [value]; + void query.andWhere(fieldName, "in", inValues as (string | number)[]); + break; + } + case "NOT IN": { + const notInValues = Array.isArray(value) ? value : [value]; + void query.andWhere(fieldName, "not in", notInValues as (string | number)[]); + break; + } + case ">": + void query.andWhere(fieldName, ">", value as string | number); + break; + case ">=": + void query.andWhere(fieldName, ">=", value as string | number); + break; + case "<": + void query.andWhere(fieldName, "<", value as string | number); + break; + case "<=": + void query.andWhere(fieldName, "<=", value as string | number); + break; + case "IS NULL": + void query.andWhere(fieldName, "is", null); + break; + case "IS NOT NULL": + void query.andWhere(fieldName, "is not", null); + break; + default: + void query.andWhere(fieldName, "=", value as string | number); + break; + } + } +}; + +/** + * Applies complex permission rules to a Knex query with proper OR/AND logic + * @param query - The Knex query builder instance + * @param tableName - The name of the table to apply filters to + * @param processedRules - Processed permission rules with allow and forbid rules + * @returns The modified query builder with permission rules applied + */ +export const applyProcessedPermissionRulesToQuery = ( + originalQuery: Knex.QueryBuilder, + tableName: string, + processedRules?: ProcessedPermissionRules +): Knex.QueryBuilder => { + if (!processedRules || (processedRules.allowRules.length === 0 && processedRules.forbidRules.length === 0)) { + return originalQuery; + } + + let query = originalQuery; + + if (processedRules.allowRules.length > 0) { + query = query.andWhere((allowBuilder) => { + processedRules.allowRules.forEach((rule, index) => { + const ruleBuilder = (ruleSubBuilder: Knex.QueryBuilder) => { + Object.entries(rule).forEach(([key, filterConfigs]) => { + filterConfigs.forEach((filterConfig) => { + applySingleFilter(ruleSubBuilder, tableName, key, filterConfig); + }); + }); + }; + + if (index === 0) { + void allowBuilder.where(ruleBuilder); + } else { + void allowBuilder.orWhere(ruleBuilder); + } + }); + }); + } + + if (processedRules.forbidRules.length > 0) { + processedRules.forbidRules.forEach((forbidRule) => { + Object.entries(forbidRule).forEach(([key, filterConfigs]) => { + filterConfigs.forEach((filterConfig) => { + applySingleFilter(query, tableName, key, filterConfig); + }); + }); + }); + } + + return query; +}; + +/** + * Sanitizes a string value for safe use in SQL LIKE queries + * @param value - The string value to sanitize + * @returns The sanitized string with SQL special characters escaped + */ +export const sanitizeForLike = (value: string): string => { + return String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&"); +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8cf8555f9..c46e9c023 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -61,8 +61,10 @@ export enum QueueName { SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", + DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email", CaCrlRotation = "ca-crl-rotation", CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber + CertificateIssuance = "certificate-issuance", SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication PkiSync = "pki-sync", @@ -80,7 +82,8 @@ export enum QueueName { UserNotification = "user-notification", HealthAlert = "health-alert", CertificateV3AutoRenewal = "certificate-v3-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } export enum QueueJobs { @@ -120,11 +123,13 @@ export enum QueueJobs { SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", CreateFolderTreeCheckpoint = "create-folder-tree-checkpoint", + DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email", InvalidateCache = "invalidate-cache", SecretScanningV2FullScan = "secret-scanning-v2-full-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", SecretScanningV2SendNotification = "secret-scanning-v2-notification", CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber", + CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile", PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal", TelemetryAggregatedEvents = "telemetry-aggregated-events", DailyReminders = "daily-reminders", @@ -132,7 +137,8 @@ export enum QueueJobs { UserNotification = "user-notification-job", HealthAlert = "health-alert", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } export type TQueueJobTypes = { @@ -219,11 +225,19 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryInstanceStats; payload: undefined; }; + [QueueName.DynamicSecretLeaseRevocationFailedEmail]: { + name: QueueJobs.DynamicSecretLeaseRevocationFailedEmail; + payload: { + leaseId: string; + }; + }; [QueueName.DynamicSecretRevocation]: | { name: QueueJobs.DynamicSecretRevocation; payload: { + isRetry?: boolean; leaseId: string; + dynamicSecretId: string; }; } | { @@ -343,6 +357,21 @@ export type TQueueJobTypes = { caType: CaType; }; }; + [QueueName.CertificateIssuance]: { + name: QueueJobs.CaIssueCertificateFromProfile; + payload: { + certificateId: string; + profileId: string; + caId: string; + commonName?: string; + altNames?: string[]; + ttl: string; + signatureAlgorithm: string; + keyAlgorithm: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + }; + }; [QueueName.DailyReminders]: { name: QueueJobs.DailyReminders; payload: undefined; @@ -375,6 +404,10 @@ export type TQueueJobTypes = { name: QueueJobs.PamAccountRotation; payload: undefined; }; + [QueueName.PkiAcmeChallengeValidation]: { + name: QueueJobs.PkiAcmeChallengeValidation; + payload: { challengeId: string }; + }; }; const SECRET_SCANNING_JOBS = [ 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 6337bae0f..a09f34a0e 100644 --- a/backend/src/server/plugins/add-errors-to-response-schemas.ts +++ b/backend/src/server/plugins/add-errors-to-response-schemas.ts @@ -6,7 +6,7 @@ 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/"); +const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/cert-manager/acme/"); export const addErrorsToResponseSchemas = fp(async (server) => { server.addHook("onRoute", (routeOptions) => { diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index e6ba2eec7..8008855c6 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -195,7 +195,7 @@ export const injectIdentity = fp( rootOrgId: identity.rootOrgId, parentOrgId: identity.parentOrgId, identityId: identity.identityId, - identityName: identity.name, + identityName: identity.identityName, authMethod: null, isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), token diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index b71451b6e..633b4211a 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -43,7 +43,9 @@ export const registerServeUI = async ( const frontendPath = path.join(dir, frontendName); await server.register(staticServe, { root: frontendPath, - wildcard: false + wildcard: false, + maxAge: "30d", + immutable: true }); server.route({ @@ -58,11 +60,12 @@ export const registerServeUI = async ( return; } - // This should help avoid caching any chunks (temp fix) - void reply.header("Cache-Control", "no-cache, no-store, must-revalidate, private, max-age=0"); - void reply.header("Pragma", "no-cache"); - void reply.header("Expires", "0"); - return reply.sendFile("index.html"); + return reply.sendFile("index.html", { + immutable: false, + maxAge: 0, + lastModified: false, + etag: false + }); } }); } diff --git a/backend/src/server/routes/bdd/bdd-nock-router.dev.ts b/backend/src/server/routes/bdd/bdd-nock-router.dev.ts new file mode 100644 index 000000000..c5f6001f5 --- /dev/null +++ b/backend/src/server/routes/bdd/bdd-nock-router.dev.ts @@ -0,0 +1,104 @@ +import type { Definition } from "nock"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { ForbiddenRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +// When running in production, we don't want to even import nock, because it's not needed and it increases memory usage a lots. +// It once caused an outage in the production environment. +// This is why we would rather to crash the app if it's not in development mode (in that case, Kubernetes should stop it from rolling out). +if (process.env.NODE_ENV === "production") { + throw new Error("BDD Nock API can only be enabled in development or test mode"); +} + +export const registerBddNockRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + const importNock = async () => { + // eslint-disable-next-line import/no-extraneous-dependencies + const { default: nock } = await import("nock"); + return nock; + }; + + const checkIfBddNockApiEnabled = () => { + // 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 === "production" || !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; + }); + + const nock = await importNock(); + 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"); + const nock = await importNock(); + 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"); + const nock = await importNock(); + nock.restore(); + return { status: "ok" }; + } + }); +}; diff --git a/backend/src/server/routes/bdd/bdd-nock-router.ts b/backend/src/server/routes/bdd/bdd-nock-router.ts new file mode 100644 index 000000000..90f2ed00c --- /dev/null +++ b/backend/src/server/routes/bdd/bdd-nock-router.ts @@ -0,0 +1,6 @@ +export const registerBddNockRouter = async () => { + // This route is only available in development or test mode. + // The actual implementation is in the dev.ts file and will be aliased to that file in development or test mode. + // And if somehow we try to enable it in production, we will throw an error. + throw new Error("BDD Nock should not be enabled in production"); +}; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5dd7a1c22..914491d3c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1,3 +1,4 @@ +import { registerBddNockRouter } from "@bdd_routes/bdd-nock-router"; import { CronJob } from "cron"; import { Knex } from "knex"; import { monitorEventLoopDelay } from "perf_hooks"; @@ -80,6 +81,7 @@ import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-c 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 { pkiAcmeQueueServiceFactory } from "@app/ee/services/pki-acme/pki-acme-queue"; 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"; @@ -172,6 +174,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; +import { certificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue"; import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal"; import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; @@ -179,6 +182,8 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { certificateRequestDALFactory } from "@app/services/certificate-request/certificate-request-dal"; +import { certificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; @@ -1091,6 +1096,7 @@ export const registerRoutes = async ( const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); + const certificateRequestDAL = certificateRequestDALFactory(db); const certificateSyncDAL = certificateSyncDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db); @@ -1186,7 +1192,7 @@ export const registerRoutes = async ( certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -1328,7 +1334,8 @@ export const registerRoutes = async ( eventBusService, licenseService, membershipRoleDAL, - membershipUserDAL + membershipUserDAL, + telemetryService }); const projectService = projectServiceFactory({ @@ -1873,7 +1880,12 @@ export const registerRoutes = async ( dynamicSecretProviders, dynamicSecretDAL, folderDAL, - kmsService + kmsService, + smtpService, + userDAL, + identityDAL, + projectMembershipDAL, + projectDAL }); const dynamicSecretService = dynamicSecretServiceFactory({ projectDAL, @@ -1906,6 +1918,7 @@ export const registerRoutes = async ( // DAILY const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ + scimService, auditLogDAL, queueService, secretVersionDAL, @@ -2207,6 +2220,31 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateRequestService = certificateRequestServiceFactory({ + certificateRequestDAL, + certificateDAL, + certificateService, + permissionService + }); + + const certificateIssuanceQueue = certificateIssuanceQueueFactory({ + certificateAuthorityDAL, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + certificateDAL, + projectDAL, + kmsService, + certificateBodyDAL, + certificateSecretDAL, + queueService, + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL, + certificateRequestService + }); + const certificateV3Service = certificateV3ServiceFactory({ certificateDAL, certificateSecretDAL, @@ -2218,7 +2256,12 @@ export const registerRoutes = async ( permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL, + certificateBodyDAL, + certificateIssuanceQueue, + certificateRequestService }); const certificateV3Queue = certificateV3QueueServiceFactory({ @@ -2243,6 +2286,12 @@ export const registerRoutes = async ( const acmeChallengeService = pkiAcmeChallengeServiceFactory({ acmeChallengeDAL }); + + const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({ + queueService, + acmeChallengeService + }); + const pkiAcmeService = pkiAcmeServiceFactory({ projectDAL, appConnectionDAL, @@ -2252,6 +2301,7 @@ export const registerRoutes = async ( certificateProfileDAL, certificateBodyDAL, certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -2261,7 +2311,9 @@ export const registerRoutes = async ( kmsService, licenseService, certificateV3Service, - acmeChallengeService + certificateTemplateV2Service, + acmeChallengeService, + pkiAcmeQueueService }); const pkiSubscriberService = pkiSubscriberServiceFactory({ @@ -2431,6 +2483,7 @@ export const registerRoutes = async ( } } + await kmsService.startService(hsmStatus); await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); @@ -2443,7 +2496,7 @@ export const registerRoutes = async ( await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiAlertV2Queue.init(); await certificateV3Queue.init(); - await kmsService.startService(hsmStatus); + await certificateIssuanceQueue.initializeCertificateIssuanceQueue(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); @@ -2509,6 +2562,7 @@ export const registerRoutes = async ( auditLogStream: auditLogStreamService, certificate: certificateService, certificateV3: certificateV3Service, + certificateRequest: certificateRequestService, certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, @@ -2698,6 +2752,12 @@ export const registerRoutes = async ( await server.register(registerV3Routes, { prefix: "/api/v3" }); await server.register(registerV4Routes, { prefix: "/api/v4" }); + // 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: "/api/__bdd_nock__" }); + } + server.addHook("onClose", async () => { cronJobs.forEach((job) => job.stop()); await telemetryService.flushAll(); diff --git a/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts b/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts index e44b9af4e..0feb1ba55 100644 --- a/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts +++ b/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts @@ -2,6 +2,8 @@ import { IdentityProjectAdditionalPrivilegeSchema } from "@app/db/schemas"; import { UnpackedPermissionSchema } from "./permission"; -export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ +export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.omit({ + projectMembershipId: true +}).extend({ permissions: UnpackedPermissionSchema.array() }); diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index ddb3f2326..f6ec36f6f 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -9,6 +9,8 @@ import { SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { getLicenseKeyConfig } from "@app/ee/services/license/license-fns"; +import { LicenseType } from "@app/ee/services/license/license-types"; import { getConfig, overridableKeys } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; @@ -65,6 +67,9 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { const config = await getServerCfg(); const serverEnvs = getConfig(); + const licenseKeyConfig = getLicenseKeyConfig(); + const hasOfflineLicense = licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Offline; + return { config: { ...config, @@ -73,7 +78,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { isSecretScanningDisabled: serverEnvs.DISABLE_SECRET_SCANNING, kubernetesAutoFetchServiceAccountToken: serverEnvs.KUBERNETES_AUTO_FETCH_SERVICE_ACCOUNT_TOKEN, paramsFolderSecretDetectionEnabled: serverEnvs.PARAMS_FOLDER_SECRET_DETECTION_ENABLED, - isOfflineUsageReportsEnabled: !!serverEnvs.LICENSE_KEY_OFFLINE + isOfflineUsageReportsEnabled: hasOfflineLicense } }; } diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 5a3496750..48fdc7c38 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -61,6 +61,10 @@ import { DigitalOceanConnectionListItemSchema, SanitizedDigitalOceanConnectionSchema } from "@app/services/app-connection/digital-ocean"; +import { + DNSMadeEasyConnectionListItemSchema, + SanitizedDNSMadeEasyConnectionSchema +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema"; import { FlyioConnectionListItemSchema, SanitizedFlyioConnectionSchema } from "@app/services/app-connection/flyio"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; @@ -170,7 +174,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, ...SanitizedLaravelForgeConnectionSchema.options, - ...SanitizedChefConnectionSchema.options + ...SanitizedChefConnectionSchema.options, + ...SanitizedDNSMadeEasyConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -215,7 +220,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, LaravelForgeConnectionListItemSchema, - ChefConnectionListItemSchema + ChefConnectionListItemSchema, + DNSMadeEasyConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts new file mode 100644 index 000000000..e1e0b2860 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateDNSMadeEasyConnectionSchema, + SanitizedDNSMadeEasyConnectionSchema, + UpdateDNSMadeEasyConnectionSchema +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerDNSMadeEasyConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.DNSMadeEasy, + server, + sanitizedResponseSchema: SanitizedDNSMadeEasyConnectionSchema, + createSchema: CreateDNSMadeEasyConnectionSchema, + updateSchema: UpdateDNSMadeEasyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/dns-made-easy-zones`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const zones = await server.services.appConnection.dnsMadeEasy.listZones(connectionId, req.permission); + return zones; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index aa1d671b6..d7a4065fd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -16,6 +16,7 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerChecklyConnectionRouter } from "./checkly-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; +import { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router"; import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router"; import { registerFlyioConnectionRouter } from "./flyio-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; @@ -78,6 +79,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { -// 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-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts index 3d15b473a..2917270ef 100644 --- a/backend/src/server/routes/v1/certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -85,7 +85,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, isInternal: false, actorOrgId: req.permission.orgId, - enableDirectIssuance: !req.body.requireTemplateForIssuance, ...req.body }); @@ -220,7 +219,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { isInternal: false, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - enableDirectIssuance: !req.body.requireTemplateForIssuance, ...req.body }); @@ -617,6 +615,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + // TODO: DEPRECATE server.route({ method: "POST", url: "/:caId/issue-certificate", @@ -625,7 +624,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], description: "Issue certificate from CA", params: z.object({ @@ -711,6 +709,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + // TODO: DEPRECATE server.route({ method: "POST", url: "/:caId/sign-certificate", @@ -719,7 +718,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], description: "Sign certificate from CA", params: z.object({ @@ -805,6 +803,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + // TODO: DEPRECATE server.route({ method: "GET", url: "/:caId/certificate-templates", @@ -813,7 +812,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], description: "Get list of certificate templates for the CA", params: z.object({ @@ -854,6 +852,7 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { } }); + // TODO: DEPRECATE server.route({ method: "GET", url: "/:caId/crls", @@ -862,7 +861,6 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], description: "Get list of CRLs of the CA", params: z.object({ diff --git a/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts index 01952c7f4..7e89a99a1 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/certificate-authority-endpoints.ts @@ -28,14 +28,10 @@ export const registerCertificateAuthorityEndpoints = < projectId: string; status: CaStatus; configuration: I["configuration"]; - enableDirectIssuance: boolean; }>; updateSchema: z.ZodType<{ - projectId: string; - name?: string; status?: CaStatus; configuration?: I["configuration"]; - enableDirectIssuance?: boolean; }>; responseSchema: z.ZodTypeAny; }) => { @@ -83,7 +79,7 @@ export const registerCertificateAuthorityEndpoints = < server.route({ method: "GET", - url: "/:caName", + url: "/:id", config: { rateLimit: readLimit }, @@ -91,10 +87,7 @@ export const registerCertificateAuthorityEndpoints = < hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], params: z.object({ - caName: z.string() - }), - querystring: z.object({ - projectId: z.string().uuid() + id: z.string() }), response: { 200: responseSchema @@ -102,14 +95,12 @@ export const registerCertificateAuthorityEndpoints = < }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { caName } = req.params; - const { projectId } = req.query; + const { id } = req.params; - const certificateAuthority = - (await server.services.certificateAuthority.findCertificateAuthorityByNameAndProjectId( - { caName, type: caType, projectId }, - req.permission - )) as T; + const certificateAuthority = (await server.services.certificateAuthority.findCertificateAuthorityById( + { id, type: caType }, + req.permission + )) as T; await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -166,7 +157,7 @@ export const registerCertificateAuthorityEndpoints = < server.route({ method: "PATCH", - url: "/:caName", + url: "/:id", config: { rateLimit: writeLimit }, @@ -174,7 +165,7 @@ export const registerCertificateAuthorityEndpoints = < hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], params: z.object({ - caName: z.string() + id: z.string() }), body: updateSchema, response: { @@ -183,13 +174,13 @@ export const registerCertificateAuthorityEndpoints = < }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { caName } = req.params; + const { id } = req.params; const certificateAuthority = (await server.services.certificateAuthority.updateCertificateAuthority( { ...req.body, type: caType, - caName + id }, req.permission )) as T; @@ -213,7 +204,7 @@ export const registerCertificateAuthorityEndpoints = < server.route({ method: "DELETE", - url: "/:caName", + url: "/:id", config: { rateLimit: writeLimit }, @@ -221,10 +212,7 @@ export const registerCertificateAuthorityEndpoints = < hide: false, tags: [ApiDocsTags.PkiCertificateAuthorities], params: z.object({ - caName: z.string() - }), - body: z.object({ - projectId: z.string().uuid() + id: z.string() }), response: { 200: responseSchema @@ -232,11 +220,10 @@ export const registerCertificateAuthorityEndpoints = < }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const { caName } = req.params; - const { projectId } = req.body; + const { id } = req.params; const certificateAuthority = (await server.services.certificateAuthority.deleteCertificateAuthority( - { caName, type: caType, projectId }, + { id, type: caType }, req.permission )) as T; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts new file mode 100644 index 000000000..7a7281d57 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/general-certificate-authority-router.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; + +const CertificateAuthoritySchema = z.discriminatedUnion("type", [ + InternalCertificateAuthoritySchema, + AcmeCertificateAuthoritySchema, + AzureAdCsCertificateAuthoritySchema +]); + +export const registerGeneralCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get Certificate Authorities", + querystring: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + certificateAuthorities: CertificateAuthoritySchema.array() + }) + } + }, + handler: async (req) => { + const internalCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.INTERNAL + }, + req.permission + ); + + const acmeCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.ACME + }, + req.permission + ); + + const azureAdCsCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AZURE_AD_CS + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_CAS, + metadata: { + caIds: [ + ...(internalCas ?? []).map((ca) => ca.id), + ...(acmeCas ?? []).map((ca) => ca.id), + ...(azureAdCsCas ?? []).map((ca) => ca.id) + ] + } + } + }); + + return { + certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? [])] + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts index 61dc3ed57..73e3bde54 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/internal-certificate-authority-router.ts @@ -1,4 +1,12 @@ -import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, CERTIFICATE_AUTHORITIES } 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 { CaRenewalType, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CreateInternalCertificateAuthoritySchema, InternalCertificateAuthoritySchema, @@ -15,4 +23,406 @@ export const registerInternalCertificateAuthorityRouter = async (server: Fastify createSchema: CreateInternalCertificateAuthoritySchema, updateSchema: UpdateInternalCertificateAuthoritySchema }); + + server.route({ + method: "GET", + url: "/:caId/csr", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get CA CSR", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CSR.caId) + }), + response: { + 200: z.object({ + csr: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CSR.csr) + }) + } + }, + handler: async (req) => { + const { ca, csr } = await server.services.internalCertificateAuthority.getCaCsr({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CSR, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + csr + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/renew", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Perform CA certificate renewal", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.caId) + }), + body: z.object({ + type: z.nativeEnum(CaRenewalType).describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.type), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.notAfter) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.RENEW_CA_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, ca } = + await server.services.internalCertificateAuthority.renewCaCert({ + caId: req.params.caId, + 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: ca.projectId, + event: { + type: EventType.RENEW_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/ca-certificates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get list of past and current CA certificates for a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.caId) + }), + response: { + 200: z.array( + z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.serialNumber), + version: z.number().describe(CERTIFICATE_AUTHORITIES.GET_CA_CERTS.version) + }) + ) + } + }, + handler: async (req) => { + const { caCerts, ca } = await server.services.internalCertificateAuthority.getCaCerts({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CERTS, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return caCerts; + } + }); + + server.route({ + method: "GET", + url: "/:caId/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get current CA cert and cert chain of a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + }), + response: { + 200: z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, ca } = + await server.services.internalCertificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/sign-intermediate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Create intermediate CA certificate from parent CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) + }), + body: z.object({ + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificateChain), + issuingCaCertificate: z + .string() + .trim() + .describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.issuingCaCertificate), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.internalCertificateAuthority.signIntermediate({ + caId: req.params.caId, + 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: ca.projectId, + event: { + type: EventType.SIGN_INTERMEDIATE, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Import certificate and chain to CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.caId) + }), + body: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificateChain) + }), + response: { + 200: z.object({ + message: z.string().trim(), + caId: z.string().trim() + }) + } + }, + handler: async (req) => { + const { ca } = await server.services.internalCertificateAuthority.importCertToCa({ + caId: req.params.caId, + 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: ca.projectId, + event: { + type: EventType.IMPORT_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + message: "Successfully imported certificate to CA", + caId: req.params.caId + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/crls", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get list of CRLs of the CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.caId) + }), + response: { + 200: z.array( + z.object({ + id: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.id), + crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRLS.crl) + }) + ) + } + }, + handler: async (req) => { + const { ca, crls } = await server.services.certificateAuthorityCrl.getCaCrls({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CRLS, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return crls; + } + }); + + // this endpoint will be used to serve the CA certificate when a client makes a request + // against the Authority Information Access CA Issuer URL + server.route({ + method: "GET", + url: "/:caId/certificates/:caCertId/der", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + description: "Get DER-encoded certificate of CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT_BY_ID.caId), + caCertId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT_BY_ID.caCertId) + }), + response: { + 200: z.instanceof(Buffer) + } + }, + handler: async (req, res) => { + const caCert = await server.services.internalCertificateAuthority.getCaCertById(req.params); + + void res.header("Content-Type", "application/pkix-cert"); + + return Buffer.from(caCert.rawData); + } + }); }; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 5792c5e83..ff770326d 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -8,7 +8,8 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { CertStatus } from "@app/services/certificate/certificate-types"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { ExternalConfigUnionSchema } from "@app/services/certificate-profile/certificate-profile-external-config-schemas"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { server.route({ @@ -23,7 +24,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid body: z .object({ projectId: z.string().min(1), - caId: z.string().uuid(), + caId: z.string().uuid().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -32,6 +33,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -45,53 +47,113 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z.object({}).optional(), + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - 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 !!data.estConfig; } return true; }, { - message: - "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." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -115,7 +177,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid certificateProfileId: certificateProfile.id, name: certificateProfile.slug, projectId: certificateProfile.projectId, - enrollmentType: certificateProfile.enrollmentType + enrollmentType: certificateProfile.enrollmentType, + issuerType: certificateProfile.issuerType } } }); @@ -139,11 +202,21 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }), response: { 200: z.object({ certificateProfiles: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + status: z.string(), + name: z.string(), + isExternal: z.boolean().optional(), + externalType: z.string().nullable().optional() + }) + .optional(), metrics: z .object({ profileId: z.string(), @@ -174,7 +247,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid id: z.string(), directoryUrl: z.string() }) - .optional() + .optional(), + externalConfigs: ExternalConfigUnionSchema }).array(), totalCount: z.number() }) @@ -220,12 +294,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid response: { 200: z.object({ certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }).extend({ certificateAuthority: z .object({ id: z.string(), projectId: z.string(), status: z.string(), - name: z.string() + name: z.string(), + isExternal: z.boolean().optional(), + externalType: z.string().nullable().optional() }) .optional(), certificateTemplate: z @@ -250,7 +328,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), renewBeforeDays: z.number().optional() }) - .optional() + .optional(), + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -298,7 +377,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -339,6 +420,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -351,7 +433,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { @@ -373,7 +456,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -418,7 +503,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index e8cdbb540..397085171 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -4,24 +4,795 @@ import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { ApiDocsTags, CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs"; +import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs"; +import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { addNoCacheHeaders } from "@app/server/lib/caching"; -import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertExtendedKeyUsage, CertKeyUsage, CrlReason } from "@app/services/certificate/certificate-types"; +import { CertKeyAlgorithm, CertSignatureAlgorithm, CrlReason } from "@app/services/certificate/certificate-types"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { - validateAltNamesField, - validateCaDateField -} from "@app/services/certificate-authority/certificate-authority-validators"; -import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} 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 { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types"; -export const registerCertRouter = async (server: FastifyZodProvider) => { +import { booleanSchema } from "../sanitizedSchemas"; + +type CertificateServiceResponse = TCertificateFromProfileResponse | Omit; + +const extractCertificateData = (data: CertificateServiceResponse) => ({ + certificate: data.certificate, + issuingCaCertificate: data.issuingCaCertificate, + certificateChain: data.certificateChain, + privateKey: "privateKey" in data ? data.privateKey : undefined, + serialNumber: data.serialNumber, + certificateId: data.certificateId +}); + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +const validateTtlAndDateFields = (data: { + attributes?: { notBefore?: string; notAfter?: string; ttl?: string }; + notBefore?: string; + notAfter?: string; + ttl?: string; +}) => { + if (data.attributes) { + const hasDateFields = data.attributes.notBefore || data.attributes.notAfter; + const hasTtl = data.attributes.ttl; + return !(hasDateFields && hasTtl); + } + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); +}; + +const validateDateOrder = (data: { + attributes?: { notBefore?: string; notAfter?: string }; + notBefore?: string; + notAfter?: string; +}) => { + if (data.attributes?.notBefore && data.attributes?.notAfter) { + const notBefore = new Date(data.attributes.notBefore); + const notAfter = new Date(data.attributes.notAfter); + return notBefore < notAfter; + } + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; +}; + +export const registerCertificateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + csr: z + .string() + .trim() + .min(1, "CSR cannot be empty") + .max(4096, "CSR cannot exceed 4096 characters") + .optional(), + attributes: z + .object({ + commonName: validateTemplateRegexField.optional(), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional() + }) + .optional(), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z + .object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + .nullable(), + certificateRequestId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { csr, attributes, ...requestBody } = req.body; + const profile = await server.services.certificateProfile.getProfileById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId + }); + + let useOrderFlow = false; + if (profile?.caId) { + const ca = await server.services.certificateAuthority.getCaById({ + caId: profile.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + isInternal: true + }); + const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL; + useOrderFlow = caType !== CaType.INTERNAL; + } + + if (useOrderFlow) { + const certificateOrderObject = { + altNames: attributes?.altNames || [], + validity: { ttl: attributes?.ttl || "" }, + commonName: attributes?.commonName, + keyUsages: attributes?.keyUsages, + extendedKeyUsages: attributes?.extendedKeyUsages, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + signatureAlgorithm: attributes?.signatureAlgorithm, + keyAlgorithm: attributes?.keyAlgorithm, + csr + }; + + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateOrder: certificateOrderObject, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + profileName: data.profileName + } + } + }); + + return { + certificate: null, + certificateRequestId: data.certificateRequestId + }; + } + + if (csr) { + const extractedCsrData = extractCertificateRequestFromCSR(csr); + + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + csr, + validity: { ttl: attributes?.ttl || "" }, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + enrollmentType: EnrollmentType.API, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: extractedCsrData.commonName || "" + } + } + }); + return { + certificate: extractCertificateData(data), + certificateRequestId: data.certificateRequestId + }; + } + + const certificateRequestForService: CertificateRequestForService = { + commonName: attributes?.commonName, + keyUsages: attributes?.keyUsages, + extendedKeyUsages: attributes?.extendedKeyUsages, + altNames: attributes?.altNames, + validity: { ttl: attributes?.ttl || "" }, + notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined, + notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined, + signatureAlgorithm: attributes?.signatureAlgorithm, + keyAlgorithm: attributes?.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: requestBody.profileId, + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: requestBody.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: requestBody.profileId, + certificateId: data.certificateId, + commonName: attributes?.commonName || "", + profileName: data.profileName + } + } + }); + return { + certificate: extractCertificateData(data), + certificateRequestId: data.certificateRequestId + }; + } + }); server.route({ method: "GET", - url: "/:serialNumber", + url: "/certificate-requests/:requestId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + requestId: z.string().uuid() + }), + query: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + status: z.nativeEnum(CertificateRequestStatus), + certificate: z.string().nullable(), + privateKey: z.string().nullable(), + serialNumber: z.string().nullable(), + errorMessage: z.string().nullable(), + createdAt: z.date(), + updatedAt: z.date() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateRequest.getCertificateFromRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: (req.query as { projectId: string }).projectId, + certificateRequestId: req.params.requestId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: (req.query as { projectId: string }).projectId, + event: { + type: EventType.GET_CERTIFICATE_REQUEST, + metadata: { + certificateRequestId: req.params.requestId + } + } + }); + return data; + } + }); + + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: true, + deprecated: true, + tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string(), + certificateRequestId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateRequestForService: CertificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + commonName: req.body.commonName || "", + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: true, + deprecated: true, + tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim(), + certificateId: z.string(), + certificateRequestId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + csr: req.body.csr, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + enrollmentType: EnrollmentType.API, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: certificateRequestData.commonName || "" + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/order-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: true, + deprecated: true, + tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", + body: z + .object({ + profileId: z.string().uuid(), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + commonName: validateTemplateRegexField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().optional(), + certificateRequestId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateOrderObject = { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateOrder: certificateOrderObject, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/:id/renew", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .optional(), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string(), + certificateRequestId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.id + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); + } + + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.id, + removeRootsFromChain: req.body?.removeRootsFromChain + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.id, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return data; + } + }); + + server.route({ + method: "PATCH", + url: "/:id/config", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + renewBeforeDays: z.number().int().min(1).max(30).optional(), + enableAutoRenewal: z.boolean().optional() + }) + .refine((data) => !(data.renewBeforeDays !== undefined && data.enableAutoRenewal === false), { + message: "Cannot specify both renewBeforeDays and enableAutoRenewal=false" + }), + response: { + 200: z.object({ + message: z.string(), + renewBeforeDays: z.number().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.body.enableAutoRenewal === false) { + const data = await server.services.certificateV3.disableRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.id, + commonName: data.commonName + } + } + }); + + return { + message: "Auto-renewal disabled successfully" + }; + } + + if (req.body.renewBeforeDays !== undefined) { + const data = await server.services.certificateV3.updateRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.id, + renewBeforeDays: req.body.renewBeforeDays + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.id, + renewBeforeDays: req.body.renewBeforeDays.toString(), + commonName: data.commonName + } + } + }); + + return { + message: "Certificate configuration updated successfully", + renewBeforeDays: data.renewBeforeDays + }; + } + + return { + message: "No configuration changes requested" + }; + } + }); + + server.route({ + method: "GET", + url: "/:id", config: { rateLimit: readLimit }, @@ -31,7 +802,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Get certificate", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + id: z.string().trim().describe(CERTIFICATES.GET.id) }), response: { 200: z.object({ @@ -41,7 +812,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { cert } = await server.services.certificate.getCert({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -67,10 +838,9 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); - // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. server.route({ method: "GET", - url: "/:serialNumber/private-key", + url: "/:id/private-key", config: { rateLimit: readLimit }, @@ -80,7 +850,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Get certificate private key", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + id: z.string().trim().describe(CERTIFICATES.GET.id) }), response: { 200: z.string().trim() @@ -88,7 +858,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req, reply) => { const { cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -114,10 +884,9 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); - // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. server.route({ method: "GET", - url: "/:serialNumber/bundle", + url: "/:id/bundle", config: { rateLimit: readLimit }, @@ -127,7 +896,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Get certificate bundle including the certificate, chain, and private key.", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + id: z.string().trim().describe(CERTIFICATES.GET_CERT.id) }), response: { 200: z.object({ @@ -141,7 +910,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { handler: async (req, reply) => { const { certificate, certificateChain, serialNumber, cert, privateKey } = await server.services.certificate.getCertBundle({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -172,120 +941,6 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); - server.route({ - method: "POST", - url: "/issue-certificate", - config: { - rateLimit: writeLimit - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificates], - description: "Issue certificate", - body: z - .object({ - caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId), - certificateTemplateId: z - .string() - .trim() - .optional() - .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), - pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), - friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), - commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), - altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), - ttl: z - .string() - .refine((val) => ms(val) > 0, "TTL must be a positive number") - .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), - notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), - notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter), - keyUsages: z - .nativeEnum(CertKeyUsage) - .array() - .optional() - .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.keyUsages), - extendedKeyUsages: z - .nativeEnum(CertExtendedKeyUsage) - .array() - .optional() - .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.extendedKeyUsages) - }) - .refine( - (data) => { - const { ttl, notAfter } = data; - return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); - }, - { - message: "Either ttl or notAfter must be present, but not both", - path: ["ttl", "notAfter"] - } - ) - .refine( - (data) => - (data.caId !== undefined && data.certificateTemplateId === undefined) || - (data.caId === undefined && data.certificateTemplateId !== undefined), - { - message: "Either CA ID or Certificate Template ID must be present, but not both", - path: ["caId", "certificateTemplateId"] - } - ), - response: { - 200: z.object({ - certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), - issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), - certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), - privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), - serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) - }) - } - }, - handler: async (req) => { - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = - await server.services.internalCertificateAuthority.issueCertFromCa({ - 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: ca.projectId, - event: { - type: EventType.ISSUE_CERT, - metadata: { - caId: ca.id, - dn: ca.dn, - serialNumber - } - } - }); - - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.IssueCert, - distinctId: getTelemetryDistinctId(req), - organizationId: req.permission.orgId, - properties: { - caId: req.body.caId, - certificateTemplateId: req.body.certificateTemplateId, - commonName: req.body.commonName, - ...req.auditLogInfo - } - }); - - return { - certificate, - certificateChain, - issuingCaCertificate, - privateKey, - serialNumber - }; - } - }); - server.route({ method: "POST", url: "/import-certificate", @@ -350,121 +1005,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/sign-certificate", - config: { - rateLimit: writeLimit - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificates], - description: "Sign certificate", - body: z - .object({ - caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId), - certificateTemplateId: z - .string() - .trim() - .optional() - .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), - pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), - csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), - friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), - commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), - altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), - ttl: z - .string() - .refine((val) => ms(val) > 0, "TTL must be a positive number") - .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), - notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), - notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter), - keyUsages: z - .nativeEnum(CertKeyUsage) - .array() - .optional() - .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.keyUsages), - extendedKeyUsages: z - .nativeEnum(CertExtendedKeyUsage) - .array() - .optional() - .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.extendedKeyUsages) - }) - .refine( - (data) => { - const { ttl, notAfter } = data; - return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); - }, - { - message: "Either ttl or notAfter must be present, but not both", - path: ["ttl", "notAfter"] - } - ) - .refine( - (data) => - (data.caId !== undefined && data.certificateTemplateId === undefined) || - (data.caId === undefined && data.certificateTemplateId !== undefined), - { - message: "Either CA ID or Certificate Template ID must be present, but not both", - path: ["caId", "certificateTemplateId"] - } - ), - response: { - 200: z.object({ - certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate), - issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), - certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), - serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) - }) - } - }, - handler: async (req) => { - const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } = - await server.services.internalCertificateAuthority.signCertFromCa({ - isInternal: false, - 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: ca.projectId, - event: { - type: EventType.SIGN_CERT, - metadata: { - caId: ca.id, - dn: ca.dn, - serialNumber - } - } - }); - - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SignCert, - distinctId: getTelemetryDistinctId(req), - organizationId: req.permission.orgId, - properties: { - caId: req.body.caId, - certificateTemplateId: req.body.certificateTemplateId, - commonName, - ...req.auditLogInfo - } - }); - - return { - certificate: certificate.toString("pem"), - certificateChain, - issuingCaCertificate, - serialNumber - }; - } - }); - - server.route({ - method: "POST", - url: "/:serialNumber/revoke", + url: "/:id/revoke", config: { rateLimit: writeLimit }, @@ -474,7 +1015,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Revoke", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumber) + id: z.string().trim().describe(CERTIFICATES.REVOKE.id) }), body: z.object({ revocationReason: z.nativeEnum(CrlReason).describe(CERTIFICATES.REVOKE.revocationReason) @@ -489,7 +1030,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { revokedAt, cert, ca } = await server.services.certificate.revokeCert({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -512,7 +1053,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { return { message: "Successfully revoked certificate", - serialNumber: req.params.serialNumber, + serialNumber: cert.serialNumber, revokedAt }; } @@ -520,7 +1061,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ method: "DELETE", - url: "/:serialNumber", + url: "/:id", config: { rateLimit: writeLimit }, @@ -530,7 +1071,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Delete certificate", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.DELETE.serialNumber) + id: z.string().trim().describe(CERTIFICATES.DELETE.id) }), response: { 200: z.object({ @@ -540,7 +1081,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { deletedCert } = await server.services.certificate.deleteCert({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -568,7 +1109,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", - url: "/:serialNumber/certificate", + url: "/:id/certificate", config: { rateLimit: readLimit }, @@ -578,7 +1119,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Get certificate body of certificate", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + id: z.string().trim().describe(CERTIFICATES.GET_CERT.id) }), response: { 200: z.object({ @@ -590,7 +1131,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req) => { const { certificate, certificateChain, serialNumber, cert } = await server.services.certificate.getCertBody({ - serialNumber: req.params.serialNumber, + id: req.params.id, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -620,7 +1161,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:serialNumber/pkcs12", + url: "/:id/pkcs12", config: { rateLimit: writeLimit }, @@ -630,7 +1171,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiCertificates], description: "Download certificate in PKCS12 format", params: z.object({ - serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + id: z.string().trim().describe(CERTIFICATES.GET.id) }), body: z.object({ password: z @@ -645,7 +1186,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }, handler: async (req, reply) => { const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({ - serialNumber: req.params.serialNumber, + id: req.params.id, password: req.body.password, alias: req.body.alias, actor: req.permission.type, @@ -671,7 +1212,7 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { reply.header("Content-Type", "application/octet-stream"); reply.header( "Content-Disposition", - `attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"` + `attachment; filename="certificate-${cert.serialNumber?.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"` ); return pkcs12Data; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 5ff0e39c0..499d0b98e 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -1,28 +1,239 @@ +import RE2 from "re2"; import { z } from "zod"; -import { CertificateTemplateEstConfigsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { ApiDocsTags, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; -import { ms } from "@app/lib/ms"; +import { ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; -import { slugSchema } from "@app/server/lib/schemas"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; -import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; -import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; -const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({ - id: true, - certificateTemplateId: true, - isEnabled: true, - disableBootstrapCertValidation: true +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .refine( + (val) => { + if (!val) return true; + if (val.length < 2) return false; + const unit = val.slice(-1); + const number = val.slice(0, -1); + const digitRegex = new RE2("^\\d+$"); + return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number); + }, + { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + } + ) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() }); export const registerCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: createCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, ...data } = req.body; + const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + projectId, + data + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + projectId: certificateTemplate.projectId + } + } + }); + + return { certificateTemplate }; + } + }); + server.route({ method: "GET", - url: "/:certificateTemplateId", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificateTemplates: certificateTemplateV2ResponseSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_TEMPLATES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateTemplates: templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", config: { rateLimit: readLimit }, @@ -30,20 +241,22 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], params: z.object({ - certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + id: z.string().uuid() }), response: { - 200: sanitizedCertificateTemplate + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateTemplate = await server.services.certificateTemplate.getCertTemplate({ - id: req.params.certificateTemplateId, + const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({ actor: req.permission.type, actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id }); await server.services.auditLog.createAuditLog({ @@ -58,125 +271,38 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid } }); - return certificateTemplate; - } - }); - - server.route({ - method: "POST", - url: "/", - config: { - rateLimit: writeLimit - }, - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateTemplates], - body: z.object({ - caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), - pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), - name: slugSchema().describe(CERTIFICATE_TEMPLATES.CREATE.name), - commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), - subjectAlternativeName: validateTemplateRegexField.describe( - CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName - ), - ttl: z - .string() - .refine((val) => ms(val) > 0, "TTL must be a positive number") - .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), - keyUsages: z - .nativeEnum(CertKeyUsage) - .array() - .optional() - .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]) - .describe(CERTIFICATE_TEMPLATES.CREATE.keyUsages), - extendedKeyUsages: z - .nativeEnum(CertExtendedKeyUsage) - .array() - .optional() - .default([]) - .describe(CERTIFICATE_TEMPLATES.CREATE.extendedKeyUsages) - }), - response: { - 200: sanitizedCertificateTemplate - } - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - handler: async (req) => { - const certificateTemplate = await server.services.certificateTemplate.createCertTemplate({ - 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: certificateTemplate.projectId, - event: { - type: EventType.CREATE_CERTIFICATE_TEMPLATE, - metadata: { - certificateTemplateId: certificateTemplate.id, - caId: certificateTemplate.caId, - pkiCollectionId: certificateTemplate.pkiCollectionId as string, - name: certificateTemplate.name, - commonName: certificateTemplate.commonName, - subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl, - projectId: certificateTemplate.projectId - } - } - }); - - return certificateTemplate; + return { certificateTemplate }; } }); server.route({ method: "PATCH", - url: "/:certificateTemplateId", + url: "/:id", config: { rateLimit: writeLimit }, schema: { hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], - body: z.object({ - caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), - pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), - name: slugSchema().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), - commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), - subjectAlternativeName: validateTemplateRegexField - .optional() - .describe(CERTIFICATE_TEMPLATES.UPDATE.subjectAlternativeName), - ttl: z - .string() - .refine((val) => ms(val) > 0, "TTL must be a positive number") - .optional() - .describe(CERTIFICATE_TEMPLATES.UPDATE.ttl), - keyUsages: z.nativeEnum(CertKeyUsage).array().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.keyUsages), - extendedKeyUsages: z - .nativeEnum(CertExtendedKeyUsage) - .array() - .optional() - .describe(CERTIFICATE_TEMPLATES.UPDATE.extendedKeyUsages) - }), params: z.object({ - certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + id: z.string().uuid() }), + body: updateCertificateTemplateV2Schema, response: { - 200: sanitizedCertificateTemplate + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateTemplate = await server.services.certificateTemplate.updateCertTemplate({ - ...req.body, - id: req.params.certificateTemplateId, + const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({ actor: req.permission.type, actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id, + data: req.body }); await server.services.auditLog.createAuditLog({ @@ -186,23 +312,18 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, - name: certificateTemplate.name, - caId: certificateTemplate.caId, - pkiCollectionId: certificateTemplate.pkiCollectionId as string, - commonName: certificateTemplate.commonName, - subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl + name: certificateTemplate.name } } }); - return certificateTemplate; + return { certificateTemplate }; } }); server.route({ method: "DELETE", - url: "/:certificateTemplateId", + url: "/:id", config: { rateLimit: writeLimit }, @@ -210,20 +331,22 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid hide: false, tags: [ApiDocsTags.PkiCertificateTemplates], params: z.object({ - certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + id: z.string().uuid() }), response: { - 200: sanitizedCertificateTemplate + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateTemplate = await server.services.certificateTemplate.deleteCertTemplate({ - id: req.params.certificateTemplateId, + const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({ actor: req.permission.type, actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id }); await server.services.auditLog.createAuditLog({ @@ -238,158 +361,7 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid } }); - return certificateTemplate; - } - }); - - server.route({ - method: "POST", - url: "/:certificateTemplateId/est-config", - config: { - rateLimit: writeLimit - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateTemplates], - description: "Create Certificate Template EST configuration", - params: z.object({ - certificateTemplateId: z.string().trim() - }), - body: z - .object({ - caChain: z.string().trim().optional(), - passphrase: z.string().min(1), - isEnabled: z.boolean().default(true), - disableBootstrapCertValidation: z.boolean().default(false) - }) - .refine( - ({ caChain, disableBootstrapCertValidation }) => - disableBootstrapCertValidation || (!disableBootstrapCertValidation && caChain), - "CA chain is required" - ), - response: { - 200: sanitizedEstConfig - } - }, - handler: async (req) => { - const estConfig = await server.services.certificateTemplate.createEstConfiguration({ - certificateTemplateId: req.params.certificateTemplateId, - 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: estConfig.projectId, - event: { - type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG, - metadata: { - certificateTemplateId: estConfig.certificateTemplateId, - isEnabled: estConfig.isEnabled as boolean - } - } - }); - - return estConfig; - } - }); - - server.route({ - method: "PATCH", - url: "/:certificateTemplateId/est-config", - config: { - rateLimit: writeLimit - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateTemplates], - description: "Update Certificate Template EST configuration", - params: z.object({ - certificateTemplateId: z.string().trim() - }), - body: z.object({ - caChain: z.string().trim().optional(), - passphrase: z.string().min(1).optional(), - disableBootstrapCertValidation: z.boolean().optional(), - isEnabled: z.boolean().optional() - }), - response: { - 200: sanitizedEstConfig - } - }, - handler: async (req) => { - const estConfig = await server.services.certificateTemplate.updateEstConfiguration({ - certificateTemplateId: req.params.certificateTemplateId, - 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: estConfig.projectId, - event: { - type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG, - metadata: { - certificateTemplateId: estConfig.certificateTemplateId, - isEnabled: estConfig.isEnabled as boolean - } - } - }); - - return estConfig; - } - }); - - server.route({ - method: "GET", - url: "/:certificateTemplateId/est-config", - config: { - rateLimit: readLimit - }, - onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), - schema: { - hide: false, - tags: [ApiDocsTags.PkiCertificateTemplates], - description: "Get Certificate Template EST configuration", - params: z.object({ - certificateTemplateId: z.string().trim() - }), - response: { - 200: sanitizedEstConfig.extend({ - caChain: z.string() - }) - } - }, - handler: async (req) => { - const estConfig = await server.services.certificateTemplate.getEstConfiguration({ - isInternal: false, - certificateTemplateId: req.params.certificateTemplateId, - actor: req.permission.type, - actorId: req.permission.id, - actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId - }); - - await server.services.auditLog.createAuditLog({ - ...req.auditLogInfo, - projectId: estConfig.projectId, - event: { - type: EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG, - metadata: { - certificateTemplateId: estConfig.certificateTemplateId - } - } - }); - - return estConfig; + return { certificateTemplate }; } }); }; diff --git a/backend/src/server/routes/v1/deprecated-certificate-authority-routers/acme-certificate-authority-router.ts b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/acme-certificate-authority-router.ts new file mode 100644 index 000000000..3c549ac1b --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/acme-certificate-authority-router.ts @@ -0,0 +1,18 @@ +import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { + CreateAcmeCertificateAuthoritySchema, + UpdateAcmeCertificateAuthoritySchema +} from "@app/services/certificate-authority/acme/deprecated-acme-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAcmeCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.ACME, + server, + responseSchema: AcmeCertificateAuthoritySchema, + createSchema: CreateAcmeCertificateAuthoritySchema, + updateSchema: UpdateAcmeCertificateAuthoritySchema + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts new file mode 100644 index 000000000..9407ee681 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; +import { + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "@app/services/certificate-authority/azure-ad-cs/deprecated-azure-ad-cs-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAzureAdCsCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.AZURE_AD_CS, + server, + responseSchema: AzureAdCsCertificateAuthoritySchema, + createSchema: CreateAzureAdCsCertificateAuthoritySchema, + updateSchema: UpdateAzureAdCsCertificateAuthoritySchema + }); + + server.route({ + method: "GET", + url: "/:caId/templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + description: "Get available certificate templates from Azure AD CS CA", + params: z.object({ + caId: z.string().describe("Azure AD CS CA ID") + }), + querystring: z.object({ + projectId: z.string().describe("Project ID") + }), + response: { + 200: z.object({ + templates: z.array( + z.object({ + id: z.string().describe("Template identifier"), + name: z.string().describe("Template display name"), + description: z.string().optional().describe("Template description") + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const templates = await server.services.certificateAuthority.getAzureAdcsTemplates({ + caId: req.params.caId, + projectId: req.query.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_AZURE_AD_TEMPLATES, + metadata: { + caId: req.params.caId, + amount: templates.length + } + } + }); + + return { templates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-certificate-authority-routers/certificate-authority-endpoints.ts b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/certificate-authority-endpoints.ts new file mode 100644 index 000000000..dd0f8b215 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/certificate-authority-endpoints.ts @@ -0,0 +1,258 @@ +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 { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + TCertificateAuthority, + TCertificateAuthorityInput +} from "@app/services/certificate-authority/certificate-authority-types"; + +export const registerCertificateAuthorityEndpoints = < + T extends TCertificateAuthority, + I extends TCertificateAuthorityInput +>({ + server, + caType, + createSchema, + updateSchema, + responseSchema +}: { + caType: CaType; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + projectId: string; + status: CaStatus; + configuration: I["configuration"]; + enableDirectIssuance: boolean; + }>; + updateSchema: z.ZodType<{ + projectId: string; + name?: string; + status?: CaStatus; + configuration?: I["configuration"]; + enableDirectIssuance?: boolean; + }>; + responseSchema: z.ZodTypeAny; +}) => { + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required") + }), + response: { + 200: responseSchema.array() + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const certificateAuthorities = (await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { projectId, type: caType }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_CAS, + metadata: { + caIds: certificateAuthorities.map((ca) => ca.id) + } + } + }); + + return certificateAuthorities; + } + }); + + server.route({ + method: "GET", + url: "/:caName", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + const { projectId } = req.query; + + const certificateAuthority = + (await server.services.certificateAuthority.findCertificateAuthorityByNameAndProjectId( + { caName, type: caType, projectId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: certificateAuthority.id, + name: certificateAuthority.name + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + body: createSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateAuthority = (await server.services.certificateAuthority.createCertificateAuthority( + { ...req.body, type: caType }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "PATCH", + url: "/:caName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + body: updateSchema, + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + + const certificateAuthority = (await server.services.certificateAuthority.deprecatedUpdateCertificateAuthority( + { + ...req.body, + type: caType, + caName + }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id, + status: certificateAuthority.status + } + } + }); + + return certificateAuthority; + } + }); + + server.route({ + method: "DELETE", + url: "/:caName", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateAuthorities], + params: z.object({ + caName: z.string() + }), + body: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: responseSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { caName } = req.params; + const { projectId } = req.body; + + const certificateAuthority = (await server.services.certificateAuthority.deprecatedDeleteCertificateAuthority( + { caName, type: caType, projectId }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateAuthority.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + name: certificateAuthority.name, + caId: certificateAuthority.id + } + } + }); + + return certificateAuthority; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-certificate-authority-routers/index.ts b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/index.ts new file mode 100644 index 000000000..69a783620 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/index.ts @@ -0,0 +1,16 @@ +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router"; +import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; + +export * from "./internal-certificate-authority-router"; + +export const DEPRECATED_CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record< + CaType, + (server: FastifyZodProvider) => Promise +> = { + [CaType.INTERNAL]: registerInternalCertificateAuthorityRouter, + [CaType.ACME]: registerAcmeCertificateAuthorityRouter, + [CaType.AZURE_AD_CS]: registerAzureAdCsCertificateAuthorityRouter +}; diff --git a/backend/src/server/routes/v1/deprecated-certificate-authority-routers/internal-certificate-authority-router.ts b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/internal-certificate-authority-router.ts new file mode 100644 index 000000000..848367d70 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-authority-routers/internal-certificate-authority-router.ts @@ -0,0 +1,18 @@ +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + CreateInternalCertificateAuthoritySchema, + UpdateInternalCertificateAuthoritySchema +} from "@app/services/certificate-authority/internal/deprecated-internal-certificate-authority-schemas"; +import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerInternalCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.INTERNAL, + server, + responseSchema: InternalCertificateAuthoritySchema, + createSchema: CreateInternalCertificateAuthoritySchema, + updateSchema: UpdateInternalCertificateAuthoritySchema + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-certificate-router.ts b/backend/src/server/routes/v1/deprecated-certificate-router.ts new file mode 100644 index 000000000..955407e4c --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-router.ts @@ -0,0 +1,680 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import RE2 from "re2"; +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, CERTIFICATE_AUTHORITIES, CERTIFICATES } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { addNoCacheHeaders } from "@app/server/lib/caching"; +import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertExtendedKeyUsage, CertKeyUsage, CrlReason } from "@app/services/certificate/certificate-types"; +import { + validateAltNamesField, + validateCaDateField +} from "@app/services/certificate-authority/certificate-authority-validators"; +import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; + +export const registerDeprecatedCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:serialNumber", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { cert } = await server.services.certificate.getCert({ + serialNumber: req.params.serialNumber, + 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.GET_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate: cert + }; + } + }); + + // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. + server.route({ + method: "GET", + url: "/:serialNumber/private-key", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate private key", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.string().trim() + } + }, + handler: async (req, reply) => { + const { cert, certPrivateKey } = await server.services.certificate.getCertPrivateKey({ + serialNumber: req.params.serialNumber, + 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.GET_CERT_PRIVATE_KEY, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return certPrivateKey; + } + }); + + // TODO: In the future add support for other formats outside of PEM (such as DER). Adding a "format" query param may be best. + server.route({ + method: "GET", + url: "/:serialNumber/bundle", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate bundle including the certificate, chain, and private key.", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), + privateKey: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req, reply) => { + const { certificate, certificateChain, serialNumber, cert, privateKey } = + await server.services.certificate.getCertBundle({ + serialNumber: req.params.serialNumber, + 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.GET_CERT_BUNDLE, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + + return { + certificate, + certificateChain, + serialNumber, + privateKey + }; + } + }); + + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Issue certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.extendedKeyUsages) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.internalCertificateAuthority.issueCertFromCa({ + 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: ca.projectId, + event: { + type: EventType.ISSUE_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IssueCert, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + caId: req.body.caId, + certificateTemplateId: req.body.certificateTemplateId, + commonName: req.body.commonName, + ...req.auditLogInfo + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Import certificate", + body: z.object({ + projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug), + + certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem), + privateKeyPem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.privateKeyPem), + chainPem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.chainPem), + + friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATES.IMPORT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATES.IMPORT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, privateKey, serialNumber, cert } = + await server.services.certificate.importCert({ + 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: cert.projectId, + event: { + type: EventType.IMPORT_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + privateKey, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Sign certificate", + body: z + .object({ + caId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.caId), + certificateTemplateId: z + .string() + .trim() + .optional() + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateTemplateId), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.pkiCollectionId), + csr: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.csr), + friendlyName: z.string().trim().optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.friendlyName), + commonName: z.string().trim().min(1).optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.commonName), + altNames: validateAltNamesField.describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.altNames), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.notAfter), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .optional() + .describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.extendedKeyUsages) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ) + .refine( + (data) => + (data.caId !== undefined && data.certificateTemplateId === undefined) || + (data.caId === undefined && data.certificateTemplateId !== undefined), + { + message: "Either CA ID or Certificate Template ID must be present, but not both", + path: ["caId", "certificateTemplateId"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca, commonName } = + await server.services.internalCertificateAuthority.signCertFromCa({ + isInternal: false, + 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: ca.projectId, + event: { + type: EventType.SIGN_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SignCert, + distinctId: getTelemetryDistinctId(req), + organizationId: req.permission.orgId, + properties: { + caId: req.body.caId, + certificateTemplateId: req.body.certificateTemplateId, + commonName, + ...req.auditLogInfo + } + }); + + return { + certificate: certificate.toString("pem"), + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:serialNumber/revoke", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Revoke", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumber) + }), + body: z.object({ + revocationReason: z.nativeEnum(CrlReason).describe(CERTIFICATES.REVOKE.revocationReason) + }), + response: { + 200: z.object({ + message: z.string().trim(), + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumberRes), + revokedAt: z.date().describe(CERTIFICATES.REVOKE.revokedAt) + }) + } + }, + handler: async (req) => { + const { revokedAt, cert, ca } = await server.services.certificate.revokeCert({ + serialNumber: req.params.serialNumber, + 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: ca.projectId, + event: { + type: EventType.REVOKE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + message: "Successfully revoked certificate", + serialNumber: req.params.serialNumber, + revokedAt + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serialNumber", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Delete certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.DELETE.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { deletedCert } = await server.services.certificate.deleteCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: deletedCert.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: deletedCert.id, + cn: deletedCert.commonName, + serialNumber: deletedCert.serialNumber + } + } + }); + + return { + certificate: deletedCert + }; + } + }); + + server.route({ + method: "GET", + url: "/:serialNumber/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Get certificate body of certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().nullable().describe(CERTIFICATES.GET_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, cert } = await server.services.certificate.getCertBody({ + serialNumber: req.params.serialNumber, + 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.GET_CERT_BODY, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + 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/deprecated-certificate-template-router.ts b/backend/src/server/routes/v1/deprecated-certificate-template-router.ts new file mode 100644 index 000000000..6737b30c0 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-certificate-template-router.ts @@ -0,0 +1,395 @@ +import { z } from "zod"; + +import { CertificateTemplateEstConfigsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { slugSchema } from "@app/server/lib/schemas"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { sanitizedCertificateTemplate } from "@app/services/certificate-template/certificate-template-schema"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +const sanitizedEstConfig = CertificateTemplateEstConfigsSchema.pick({ + id: true, + certificateTemplateId: true, + isEnabled: true, + disableBootstrapCertValidation: true +}); + +export const registerDeprecatedCertificateTemplateRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:certificateTemplateId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.GET.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.getCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: z.object({ + caId: z.string().describe(CERTIFICATE_TEMPLATES.CREATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.CREATE.pkiCollectionId), + name: slugSchema().describe(CERTIFICATE_TEMPLATES.CREATE.name), + commonName: validateTemplateRegexField.describe(CERTIFICATE_TEMPLATES.CREATE.commonName), + subjectAlternativeName: validateTemplateRegexField.describe( + CERTIFICATE_TEMPLATES.CREATE.subjectAlternativeName + ), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), + keyUsages: z + .nativeEnum(CertKeyUsage) + .array() + .optional() + .default([CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]) + .describe(CERTIFICATE_TEMPLATES.CREATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .optional() + .default([]) + .describe(CERTIFICATE_TEMPLATES.CREATE.extendedKeyUsages) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.createCertTemplate({ + 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: certificateTemplate.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + name: certificateTemplate.name, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl, + projectId: certificateTemplate.projectId + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: z.object({ + caId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.caId), + pkiCollectionId: z.string().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.pkiCollectionId), + name: slugSchema().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.name), + commonName: validateTemplateRegexField.optional().describe(CERTIFICATE_TEMPLATES.UPDATE.commonName), + subjectAlternativeName: validateTemplateRegexField + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.subjectAlternativeName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.ttl), + keyUsages: z.nativeEnum(CertKeyUsage).array().optional().describe(CERTIFICATE_TEMPLATES.UPDATE.keyUsages), + extendedKeyUsages: z + .nativeEnum(CertExtendedKeyUsage) + .array() + .optional() + .describe(CERTIFICATE_TEMPLATES.UPDATE.extendedKeyUsages) + }), + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.UPDATE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.updateCertTemplate({ + ...req.body, + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + caId: certificateTemplate.caId, + pkiCollectionId: certificateTemplate.pkiCollectionId as string, + commonName: certificateTemplate.commonName, + subjectAlternativeName: certificateTemplate.subjectAlternativeName, + ttl: certificateTemplate.ttl + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "DELETE", + url: "/:certificateTemplateId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + certificateTemplateId: z.string().describe(CERTIFICATE_TEMPLATES.DELETE.certificateTemplateId) + }), + response: { + 200: sanitizedCertificateTemplate + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplate.deleteCertTemplate({ + id: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return certificateTemplate; + } + }); + + server.route({ + method: "POST", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + description: "Create Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + body: z + .object({ + caChain: z.string().trim().optional(), + passphrase: z.string().min(1), + isEnabled: z.boolean().default(true), + disableBootstrapCertValidation: z.boolean().default(false) + }) + .refine( + ({ caChain, disableBootstrapCertValidation }) => + disableBootstrapCertValidation || (!disableBootstrapCertValidation && caChain), + "CA chain is required" + ), + response: { + 200: sanitizedEstConfig + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.createEstConfiguration({ + certificateTemplateId: req.params.certificateTemplateId, + 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: estConfig.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId, + isEnabled: estConfig.isEnabled as boolean + } + } + }); + + return estConfig; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + description: "Update Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + body: z.object({ + caChain: z.string().trim().optional(), + passphrase: z.string().min(1).optional(), + disableBootstrapCertValidation: z.boolean().optional(), + isEnabled: z.boolean().optional() + }), + response: { + 200: sanitizedEstConfig + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.updateEstConfiguration({ + certificateTemplateId: req.params.certificateTemplateId, + 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: estConfig.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId, + isEnabled: estConfig.isEnabled as boolean + } + } + }); + + return estConfig; + } + }); + + server.route({ + method: "GET", + url: "/:certificateTemplateId/est-config", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + description: "Get Certificate Template EST configuration", + params: z.object({ + certificateTemplateId: z.string().trim() + }), + response: { + 200: sanitizedEstConfig.extend({ + caChain: z.string() + }) + } + }, + handler: async (req) => { + const estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: false, + certificateTemplateId: req.params.certificateTemplateId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: estConfig.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE_EST_CONFIG, + metadata: { + certificateTemplateId: estConfig.certificateTemplateId + } + } + }); + + return estConfig; + } + }); +}; diff --git a/backend/src/server/routes/v1/deprecated-pki-alert-router.ts b/backend/src/server/routes/v1/deprecated-pki-alert-router.ts new file mode 100644 index 000000000..bfabc5a89 --- /dev/null +++ b/backend/src/server/routes/v1/deprecated-pki-alert-router.ts @@ -0,0 +1,205 @@ +import { z } from "zod"; + +import { PkiAlertsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +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 registerDeprecatedPkiAlertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + tags: [ApiDocsTags.PkiAlerting], + description: "Create PKI alert", + body: z.object({ + projectId: z.string().trim().describe(ALERTS.CREATE.projectId), + pkiCollectionId: z.string().trim().describe(ALERTS.CREATE.pkiCollectionId), + name: z.string().trim().describe(ALERTS.CREATE.name), + alertBeforeDays: z.number().describe(ALERTS.CREATE.alertBeforeDays), + emails: z + .array(z.string().trim().email({ message: "Invalid email address" })) + .min(1, { message: "You must specify at least 1 email" }) + .max(5, { message: "You can specify a maximum of 5 emails" }) + .describe(ALERTS.CREATE.emails) + }), + response: { + 200: PkiAlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlert.createPkiAlert({ + 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.CREATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + pkiCollectionId: alert.pkiCollectionId, + name: alert.name, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, + recipientEmails: alert.recipientEmails + } + } + }); + + return alert; + } + }); + + server.route({ + method: "GET", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + tags: [ApiDocsTags.PkiAlerting], + description: "Get PKI alert", + params: z.object({ + alertId: z.string().trim().describe(ALERTS.GET.alertId) + }), + response: { + 200: PkiAlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlert.getPkiAlertById({ + 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: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + tags: [ApiDocsTags.PkiAlerting], + description: "Update PKI alert", + params: z.object({ + alertId: z.string().trim().describe(ALERTS.UPDATE.alertId) + }), + body: z.object({ + name: z.string().trim().optional().describe(ALERTS.UPDATE.name), + alertBeforeDays: z.number().optional().describe(ALERTS.UPDATE.alertBeforeDays), + pkiCollectionId: z.string().trim().optional().describe(ALERTS.UPDATE.pkiCollectionId), + emails: z + .array(z.string().trim().email({ message: "Invalid email address" })) + .min(1, { message: "You must specify at least 1 email" }) + .max(5, { message: "You can specify a maximum of 5 emails" }) + .optional() + .describe(ALERTS.UPDATE.emails) + }), + response: { + 200: PkiAlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlert.updatePkiAlert({ + 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, + pkiCollectionId: alert.pkiCollectionId, + name: alert.name, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, + recipientEmails: alert.recipientEmails + } + } + }); + + return alert; + } + }); + + server.route({ + method: "DELETE", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + tags: [ApiDocsTags.PkiAlerting], + description: "Delete PKI alert", + params: z.object({ + alertId: z.string().trim().describe(ALERTS.DELETE.alertId) + }), + response: { + 200: PkiAlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlert.deletePkiAlert({ + 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; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index aafffdfdb..71f299e43 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -314,7 +314,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider accessToken: z.string(), expiresIn: z.coerce.number(), accessTokenMaxTTL: z.coerce.number(), - tokenType: z.literal("Bearer") + tokenType: z.literal("Bearer"), + tokenData: IdentityAccessTokensSchema }) } }, @@ -346,7 +347,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider accessToken, tokenType: "Bearer" as const, expiresIn: identityTokenAuth.accessTokenTTL, - accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL + accessTokenMaxTTL: identityTokenAuth.accessTokenMaxTTL, + tokenData: identityAccessToken }; } }); @@ -406,6 +408,110 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider } }); + // deprecated - use the GET /token-auth/tokens/:tokenId instead, this endpoint will be removed in the future + server.route({ + method: "GET", + url: "/token-auth/identities/:identityId/tokens/:tokenId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: true, + tags: [ApiDocsTags.TokenAuth], + description: "Get token for machine identity with Token Auth", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(TOKEN_AUTH.GET_TOKEN.identityId), + tokenId: z.string().describe(TOKEN_AUTH.GET_TOKEN.tokenId) + }), + response: { + 200: z.object({ + token: IdentityAccessTokensSchema + }) + } + }, + handler: async (req) => { + const { token, identityMembershipOrg } = await server.services.identityTokenAuth.getTokenAuthTokenById({ + tokenId: req.params.tokenId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.scopeOrgId, + event: { + type: EventType.GET_TOKEN_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: token.identityId, + identityName: identityMembershipOrg.identity.name, + tokenId: token.id + } + } + }); + + return { token }; + } + }); + + server.route({ + method: "GET", + url: "/token-auth/tokens/:tokenId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.TokenAuth], + description: "Get token for machine identity with Token Auth", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + tokenId: z.string().describe(TOKEN_AUTH.GET_TOKEN.tokenId) + }), + response: { + 200: z.object({ + token: IdentityAccessTokensSchema + }) + } + }, + handler: async (req) => { + const { token, identityMembershipOrg } = await server.services.identityTokenAuth.getTokenAuthTokenById({ + tokenId: req.params.tokenId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.scopeOrgId, + event: { + type: EventType.GET_TOKEN_IDENTITY_TOKEN_AUTH, + metadata: { + identityId: identityMembershipOrg.identity.id, + identityName: identityMembershipOrg.identity.name, + tokenId: token.id + } + } + }); + + return { token }; + } + }); + server.route({ method: "PATCH", url: "/token-auth/tokens/:tokenId", diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 68099e50e..c27399453 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -8,14 +8,18 @@ import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/ 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 { registerGeneralCertificateAuthorityRouter } from "./certificate-authority-routers/general-certificate-authority-router"; import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; -import { registerCertRouter } from "./certificate-router"; +import { registerCertificateRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { DEPRECATED_CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./deprecated-certificate-authority-routers"; +import { registerDeprecatedCertRouter } from "./deprecated-certificate-router"; +import { registerDeprecatedCertificateTemplateRouter } from "./deprecated-certificate-template-router"; import { registerDeprecatedIdentityProjectMembershipRouter } from "./deprecated-identity-project-membership-router"; +import { registerDeprecatedPkiAlertRouter } from "./deprecated-pki-alert-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; @@ -151,21 +155,54 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { - await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register( async (caRouter) => { for await (const [caType, router] of Object.entries(CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP)) { await caRouter.register(router, { prefix: `/${caType}` }); } + + await caRouter.register(registerGeneralCertificateAuthorityRouter); + }, + { + prefix: "/ca" + } + ); + await pkiRouter.register(registerCertificateRouter, { prefix: "/certificates" }); + await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); + await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); + await pkiRouter.register( + async (pkiSyncRouter) => { + await pkiSyncRouter.register(registerPkiSyncRouter); + for await (const [destination, router] of Object.entries(PKI_SYNC_REGISTER_ROUTER_MAP)) { + await pkiSyncRouter.register(router, { prefix: `/${destination}` }); + } + }, + { prefix: "/syncs" } + ); + }, + { prefix: "/cert-manager" } + ); + + // NOTE: THESE /pki/* ENDPOINTS ARE TO BE DEPRECATED IN FAVOR OF /cert-manager/* + // DO NOT EXTEND THEM ANYMORE!!! + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register( + async (caRouter) => { + for await (const [caType, router] of Object.entries(DEPRECATED_CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP)) { + await caRouter.register(router, { prefix: `/${caType}` }); + } }, { prefix: "/ca" } ); - await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); - await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerDeprecatedCertRouter, { prefix: "/certificates" }); + await pkiRouter.register(registerDeprecatedCertificateTemplateRouter, { prefix: "/certificate-templates" }); await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); - await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); + await pkiRouter.register(registerDeprecatedPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); await pkiRouter.register( @@ -238,10 +275,4 @@ 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/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 95477c341..7a3b84c5a 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -10,7 +10,12 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { IntegrationMetadataSchema } from "@app/services/integration/integration-schema"; import { Integrations } from "@app/services/integration-auth/integration-list"; -import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; +import { + PostHogEventTypes, + TIntegrationCreatedEvent, + TIntegrationDeletedEvent, + TIntegrationSyncedEvent +} from "@app/services/telemetry/telemetry-types"; import {} from "../sanitizedSchemas"; @@ -288,31 +293,47 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets }); + const deleteIntegrationEventProperty = shake({ + integrationId: integration.id, + integration: integration.integration, + environment: integration.environment.slug, + secretPath: integration.secretPath, + url: integration.url, + app: integration.app, + appId: integration.appId, + targetEnvironment: integration.targetEnvironment, + targetEnvironmentId: integration.targetEnvironmentId, + targetService: integration.targetService, + targetServiceId: integration.targetServiceId, + path: integration.path, + region: integration.region + }) as TIntegrationDeletedEvent["properties"]; + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: integration.projectId, event: { type: EventType.DELETE_INTEGRATION, // eslint-disable-next-line - metadata: shake({ - integrationId: integration.id, - integration: integration.integration, - environment: integration.environment.slug, - secretPath: integration.secretPath, - url: integration.url, - app: integration.app, - appId: integration.appId, - targetEnvironment: integration.targetEnvironment, - targetEnvironmentId: integration.targetEnvironmentId, - targetService: integration.targetService, - targetServiceId: integration.targetServiceId, - path: integration.path, - region: integration.region, + metadata: { + ...deleteIntegrationEventProperty, shouldDeleteIntegrationSecrets: req.query.shouldDeleteIntegrationSecrets // eslint-disable-next-line - }) as any + } as any } }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IntegrationDeleted, + organizationId: req.permission.orgId, + distinctId: getTelemetryDistinctId(req), + properties: { + ...deleteIntegrationEventProperty, + projectId: integration.projectId, + ...req.auditLogInfo + } + }); + return { integration }; } }); @@ -351,28 +372,41 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { id: req.params.integrationId }); + const syncIntegrationEventProperty = shake({ + integrationId: integration.id, + integration: integration.integration, + environment: integration.environment.slug, + secretPath: integration.secretPath, + url: integration.url, + app: integration.app, + appId: integration.appId, + targetEnvironment: integration.targetEnvironment, + targetEnvironmentId: integration.targetEnvironmentId, + targetService: integration.targetService, + targetServiceId: integration.targetServiceId, + path: integration.path, + region: integration.region + }) as TIntegrationSyncedEvent["properties"]; + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: integration.projectId, event: { type: EventType.MANUAL_SYNC_INTEGRATION, // eslint-disable-next-line - metadata: shake({ - integrationId: integration.id, - integration: integration.integration, - environment: integration.environment.slug, - secretPath: integration.secretPath, - url: integration.url, - app: integration.app, - appId: integration.appId, - targetEnvironment: integration.targetEnvironment, - targetEnvironmentId: integration.targetEnvironmentId, - targetService: integration.targetService, - targetServiceId: integration.targetServiceId, - path: integration.path, - region: integration.region - // eslint-disable-next-line - }) as any + metadata: syncIntegrationEventProperty as any + } + }); + + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.IntegrationSynced, + organizationId: req.permission.orgId, + distinctId: getTelemetryDistinctId(req), + properties: { + ...syncIntegrationEventProperty, + projectId: integration.projectId, + isManualSync: true, + ...req.auditLogInfo } }); diff --git a/backend/src/server/routes/v1/pki-alert-router.ts b/backend/src/server/routes/v1/pki-alert-router.ts index 60a906c3a..a786e6015 100644 --- a/backend/src/server/routes/v1/pki-alert-router.ts +++ b/backend/src/server/routes/v1/pki-alert-router.ts @@ -1,12 +1,18 @@ import { z } from "zod"; -import { PkiAlertsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { ALERTS, ApiDocsTags } from "@app/lib/api-docs"; +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 { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; +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({ @@ -17,25 +23,41 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + description: "Create a new PKI alert", tags: [ApiDocsTags.PkiAlerting], - description: "Create PKI alert", - body: z.object({ - projectId: z.string().trim().describe(ALERTS.CREATE.projectId), - pkiCollectionId: z.string().trim().describe(ALERTS.CREATE.pkiCollectionId), - name: z.string().trim().describe(ALERTS.CREATE.name), - alertBeforeDays: z.number().describe(ALERTS.CREATE.alertBeforeDays), - emails: z - .array(z.string().trim().email({ message: "Invalid email address" })) - .min(1, { message: "You must specify at least 1 email" }) - .max(5, { message: "You can specify a maximum of 5 emails" }) - .describe(ALERTS.CREATE.emails) + body: CreatePkiAlertV2Schema.extend({ + projectId: z.string().uuid().describe("Project ID") }), response: { - 200: PkiAlertsSchema + 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.pkiAlert.createPkiAlert({ + const alert = await server.services.pkiAlertV2.createAlert({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -45,21 +67,80 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, - projectId: alert.projectId, + projectId: req.body.projectId, event: { type: EventType.CREATE_PKI_ALERT, metadata: { pkiAlertId: alert.id, - pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBefore: alert.alertBeforeDays.toString(), - eventType: PkiAlertEventType.EXPIRATION, - recipientEmails: alert.recipientEmails + eventType: alert.eventType, + alertBefore: alert.alertBefore } } }); - return alert; + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + 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; } }); @@ -71,17 +152,41 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + description: "Get a PKI alert by ID", tags: [ApiDocsTags.PkiAlerting], - description: "Get PKI alert", params: z.object({ - alertId: z.string().trim().describe(ALERTS.GET.alertId) + alertId: z.string().uuid().describe("Alert ID") }), response: { - 200: PkiAlertsSchema + 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.pkiAlert.getPkiAlertById({ + const alert = await server.services.pkiAlertV2.getAlertById({ alertId: req.params.alertId, actor: req.permission.type, actorId: req.permission.id, @@ -100,7 +205,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { } }); - return alert; + return { alert }; } }); @@ -108,32 +213,46 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { method: "PATCH", url: "/:alertId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + description: "Update a PKI alert", tags: [ApiDocsTags.PkiAlerting], - description: "Update PKI alert", params: z.object({ - alertId: z.string().trim().describe(ALERTS.UPDATE.alertId) - }), - body: z.object({ - name: z.string().trim().optional().describe(ALERTS.UPDATE.name), - alertBeforeDays: z.number().optional().describe(ALERTS.UPDATE.alertBeforeDays), - pkiCollectionId: z.string().trim().optional().describe(ALERTS.UPDATE.pkiCollectionId), - emails: z - .array(z.string().trim().email({ message: "Invalid email address" })) - .min(1, { message: "You must specify at least 1 email" }) - .max(5, { message: "You can specify a maximum of 5 emails" }) - .optional() - .describe(ALERTS.UPDATE.emails) + alertId: z.string().uuid().describe("Alert ID") }), + body: UpdatePkiAlertV2Schema, response: { - 200: PkiAlertsSchema + 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.pkiAlert.updatePkiAlert({ + const alert = await server.services.pkiAlertV2.updateAlert({ alertId: req.params.alertId, actor: req.permission.type, actorId: req.permission.id, @@ -149,16 +268,14 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { type: EventType.UPDATE_PKI_ALERT, metadata: { pkiAlertId: alert.id, - pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBefore: alert.alertBeforeDays.toString(), - eventType: PkiAlertEventType.EXPIRATION, - recipientEmails: alert.recipientEmails + eventType: alert.eventType, + alertBefore: alert.alertBefore } } }); - return alert; + return { alert }; } }); @@ -170,17 +287,41 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { + hide: false, + description: "Delete a PKI alert", tags: [ApiDocsTags.PkiAlerting], - description: "Delete PKI alert", params: z.object({ - alertId: z.string().trim().describe(ALERTS.DELETE.alertId) + alertId: z.string().uuid().describe("Alert ID") }), response: { - 200: PkiAlertsSchema + 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.pkiAlert.deletePkiAlert({ + const alert = await server.services.pkiAlertV2.deleteAlert({ alertId: req.params.alertId, actor: req.permission.type, actorId: req.permission.id, @@ -199,7 +340,111 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { } }); - return alert; + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/:alertId/certificates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + 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: { + hide: false, + 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/v1/pki-sync-routers/aws-secrets-manager-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/aws-secrets-manager-pki-sync-router.ts new file mode 100644 index 000000000..ca40c4b4f --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/aws-secrets-manager-pki-sync-router.ts @@ -0,0 +1,22 @@ +import { + AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION, + AwsSecretsManagerPkiSyncSchema, + CreateAwsSecretsManagerPkiSyncSchema, + UpdateAwsSecretsManagerPkiSyncSchema +} from "@app/services/pki-sync/aws-secrets-manager"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerAwsSecretsManagerPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.AwsSecretsManager, + server, + responseSchema: AwsSecretsManagerPkiSyncSchema, + createSchema: CreateAwsSecretsManagerPkiSyncSchema, + updateSchema: UpdateAwsSecretsManagerPkiSyncSchema, + syncOptions: { + canImportCertificates: AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION.canImportCertificates, + canRemoveCertificates: AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION.canRemoveCertificates + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/chef-pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/chef-pki-sync-router.ts new file mode 100644 index 000000000..b7f04ebb4 --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-routers/chef-pki-sync-router.ts @@ -0,0 +1,17 @@ +import { ChefPkiSyncSchema, CreateChefPkiSyncSchema, UpdateChefPkiSyncSchema } from "@app/services/pki-sync/chef"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +import { registerSyncPkiEndpoints } from "./pki-sync-endpoints"; + +export const registerChefPkiSyncRouter = async (server: FastifyZodProvider) => + registerSyncPkiEndpoints({ + destination: PkiSync.Chef, + server, + responseSchema: ChefPkiSyncSchema, + createSchema: CreateChefPkiSyncSchema, + updateSchema: UpdateChefPkiSyncSchema, + syncOptions: { + canImportCertificates: false, + canRemoveCertificates: true + } + }); diff --git a/backend/src/server/routes/v1/pki-sync-routers/index.ts b/backend/src/server/routes/v1/pki-sync-routers/index.ts index 4b81db27f..e961d370c 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/index.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/index.ts @@ -1,11 +1,15 @@ import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; import { registerAwsCertificateManagerPkiSyncRouter } from "./aws-certificate-manager-pki-sync-router"; +import { registerAwsSecretsManagerPkiSyncRouter } from "./aws-secrets-manager-pki-sync-router"; import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router"; +import { registerChefPkiSyncRouter } from "./chef-pki-sync-router"; export * from "./pki-sync-router"; export const PKI_SYNC_REGISTER_ROUTER_MAP: Record Promise> = { [PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter, - [PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter + [PkiSync.AwsCertificateManager]: registerAwsCertificateManagerPkiSyncRouter, + [PkiSync.AwsSecretsManager]: registerAwsSecretsManagerPkiSyncRouter, + [PkiSync.Chef]: registerChefPkiSyncRouter }; diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/deprecated-certificate-templates-v2-router.ts similarity index 100% rename from backend/src/server/routes/v2/certificate-templates-v2-router.ts rename to backend/src/server/routes/v2/deprecated-certificate-templates-v2-router.ts diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index d3d91a3ba..7a747a697 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,5 +1,5 @@ import { registerCaRouter } from "./certificate-authority-router"; -import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router"; +import { registerCertificateTemplatesV2Router } from "./deprecated-certificate-templates-v2-router"; import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts similarity index 67% rename from backend/src/server/routes/v3/certificates-router.ts rename to backend/src/server/routes/v3/deprecated-certificates-router.ts index f590aa111..ab8ae176c 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/deprecated-certificates-router.ts @@ -2,16 +2,12 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags } from "@app/lib/api-docs"; +import { NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - ACMESANType, - CertificateOrderStatus, - CertKeyAlgorithm, - CertSignatureAlgorithm -} from "@app/services/certificate/certificate-types"; +import { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types"; import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; import { CertExtendedKeyUsageType, @@ -21,8 +17,11 @@ import { 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 { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; +import { booleanSchema } from "../sanitizedSchemas"; + interface CertificateRequestForService { commonName?: string; keyUsages?: CertKeyUsageType[]; @@ -63,8 +62,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -87,7 +88,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ) .optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -103,7 +105,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, @@ -131,7 +134,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateRequest: mappedCertificateRequest + certificateRequest: mappedCertificateRequest, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + status: CertificateRequestStatus.ISSUED, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: req.body.keyAlgorithm, + signatureAlgorithm: req.body.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId }); await server.services.auditLog.createAuditLog({ @@ -148,7 +175,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -159,8 +189,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), @@ -171,7 +203,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .min(1, "TTL cannot be empty") .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional() + notAfter: validateCaDateField.optional(), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -186,14 +219,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => issuingCaCertificate: z.string().trim(), certificateChain: z.string().trim(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const certificateRequest = extractCertificateRequestFromCSR(req.body.csr); - const data = await server.services.certificateV3.signCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, @@ -206,7 +238,34 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - enrollmentType: EnrollmentType.API + enrollmentType: EnrollmentType.API, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + actor: req.permission.type, + status: CertificateRequestStatus.ISSUED, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + csr: req.body.csr, + commonName: certificateRequestData.commonName, + altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: certificateRequestData.keyUsages, + extendedKeyUsages: certificateRequestData.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + keyAlgorithm: certificateRequestData.keyAlgorithm, + signatureAlgorithm: certificateRequestData.signatureAlgorithm + }); + + await server.services.certificateRequest.attachCertificateToRequest({ + certificateRequestId: certificateRequest.id, + certificateId: data.certificateId }); await server.services.auditLog.createAuditLog({ @@ -218,12 +277,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: certificateRequest.commonName || "" + commonName: certificateRequestData.commonName || "" } } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -234,23 +296,23 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => rateLimit: writeLimit }, schema: { - hide: false, + hide: true, + deprecated: true, tags: [ApiDocsTags.PkiCertificates], + description: "This endpoint will be removed in a future version.", body: z .object({ profileId: z.string().uuid(), - subjectAlternativeNames: z - .array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z - .string() - .trim() - .min(1, "SAN value cannot be empty") - .max(255, "SAN value must be less than 255 characters") - }) - ) - .min(1, "At least one subject alternative name must be provided"), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ), ttl: z .string() .trim() @@ -262,7 +324,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notAfter: validateCaDateField.optional(), commonName: validateTemplateRegexField.optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm), + removeRootsFromChain: booleanSchema.default(false).optional() }) .refine(validateTtlAndDateFields, { message: @@ -273,59 +336,53 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }), response: { 200: z.object({ - orderId: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - subjectAlternativeNames: z.array( - z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string(), - status: z.nativeEnum(CertificateOrderStatus) - }) - ), - authorizations: z.array( - z.object({ - identifier: z.object({ - type: z.nativeEnum(ACMESANType), - value: z.string() - }), - status: z.nativeEnum(CertificateOrderStatus), - expires: z.string().optional(), - challenges: z.array( - z.object({ - type: z.string(), - status: z.nativeEnum(CertificateOrderStatus), - url: z.string(), - token: z.string() - }) - ) - }) - ), - finalize: z.string(), - certificate: z.string().optional() + certificate: z.string().optional(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const certificateOrderObject = { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + const data = await server.services.certificateV3.orderCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, profileId: req.body.profileId, - certificateOrder: { - altNames: req.body.subjectAlternativeNames, - validity: { - ttl: req.body.ttl - }, - commonName: req.body.commonName, - keyUsages: req.body.keyUsages, - extendedKeyUsages: req.body.extendedKeyUsages, - notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm - } + certificateOrder: certificateOrderObject, + removeRootsFromChain: req.body.removeRootsFromChain + }); + + const certificateRequest = await server.services.certificateRequest.createCertificateRequest({ + status: CertificateRequestStatus.PENDING, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: data.projectId, + profileId: req.body.profileId, + commonName: req.body.commonName, + altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","), + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm }); await server.services.auditLog.createAuditLog({ @@ -335,13 +392,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, - orderId: data.orderId, profileName: data.profileName } } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -357,6 +416,11 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => params: z.object({ certificateId: z.string().uuid() }), + body: z + .object({ + removeRootsFromChain: booleanSchema.default(false).optional() + }) + .optional(), response: { 200: z.object({ certificate: z.string().trim(), @@ -364,18 +428,31 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateChain: z.string().trim(), privateKey: z.string().trim().optional(), serialNumber: z.string().trim(), - certificateId: z.string() + certificateId: z.string(), + certificateRequestId: z.string() }) } }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const originalCertificate = await server.services.certificate.getCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.certificateId + }); + if (!originalCertificate) { + throw new NotFoundError({ message: "Original certificate not found" }); + } + const data = await server.services.certificateV3.renewCertificate({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, - certificateId: req.params.certificateId + certificateId: req.params.certificateId, + removeRootsFromChain: req.body?.removeRootsFromChain }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 4ee4566c1..c770d0890 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,4 +1,4 @@ -import { registerCertificatesRouter } from "./certificates-router"; +import { registerCertificatesRouter } from "./deprecated-certificates-router"; import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts index 2af9e6419..69f103c85 100644 --- a/backend/src/services/additional-privilege/additional-privilege-service.ts +++ b/backend/src/services/additional-privilege/additional-privilege-service.ts @@ -79,7 +79,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; } @@ -103,7 +106,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -136,7 +142,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; } @@ -158,7 +167,10 @@ export const additionalPrivilegeServiceFactory = ({ }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -179,7 +191,10 @@ export const additionalPrivilegeServiceFactory = ({ const additionalPrivilege = await additionalPrivilegeDAL.deleteById(existingPrivilege.id); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -199,7 +214,10 @@ export const additionalPrivilegeServiceFactory = ({ throw new NotFoundError({ message: `Additional privilege with id ${selector.id} doesn't exist` }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; @@ -219,7 +237,10 @@ export const additionalPrivilegeServiceFactory = ({ throw new NotFoundError({ message: `Additional privilege with name ${selector.name} doesn't exist` }); return { - additionalPrivilege: { ...additionalPrivilege, permissions: unpackPermissions(additionalPrivilege.permissions) } + additionalPrivilege: { + ...additionalPrivilege, + permissions: unpackPermissions(additionalPrivilege.permissions) + } }; }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 1c184a436..8e0260c01 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -29,6 +29,7 @@ export enum AppConnection { Flyio = "flyio", GitLab = "gitlab", Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy", Zabbix = "zabbix", Railway = "railway", Bitbucket = "bitbucket", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 863fa75f9..d8af3773b 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -88,6 +88,11 @@ import { getDigitalOceanConnectionListItem, validateDigitalOceanConnectionCredentials } from "./digital-ocean"; +import { DNSMadeEasyConnectionMethod } from "./dns-made-easy/dns-made-easy-connection-enum"; +import { + getDNSMadeEasyConnectionListItem, + validateDNSMadeEasyConnectionCredentials +} from "./dns-made-easy/dns-made-easy-connection-fns"; import { FlyioConnectionMethod, getFlyioConnectionListItem, validateFlyioConnectionCredentials } from "./flyio"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; @@ -170,7 +175,9 @@ const PKI_APP_CONNECTIONS = [ AppConnection.AWS, AppConnection.Cloudflare, AppConnection.AzureADCS, - AppConnection.AzureKeyVault + AppConnection.AzureKeyVault, + AppConnection.Chef, + AppConnection.DNSMadeEasy ]; export const listAppConnectionOptions = (projectType?: ProjectType) => { @@ -206,6 +213,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), + getDNSMadeEasyConnectionListItem(), getZabbixConnectionListItem(), getRailwayConnectionListItem(), getBitbucketConnectionListItem(), @@ -338,6 +346,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.DNSMadeEasy]: validateDNSMadeEasyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator, @@ -394,6 +403,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return "API Token"; + case DNSMadeEasyConnectionMethod.APIKeySecret: + return "API Key & Secret"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: @@ -482,6 +493,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Flyio]: platformManagedCredentialsNotSupported, [AppConnection.GitLab]: platformManagedCredentialsNotSupported, [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported, + [AppConnection.DNSMadeEasy]: platformManagedCredentialsNotSupported, [AppConnection.Zabbix]: platformManagedCredentialsNotSupported, [AppConnection.Railway]: platformManagedCredentialsNotSupported, [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 5b8cc3fc1..27d6a27a8 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -32,6 +32,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", + [AppConnection.DNSMadeEasy]: "DNS Made Easy", [AppConnection.Zabbix]: "Zabbix", [AppConnection.Railway]: "Railway", [AppConnection.Bitbucket]: "Bitbucket", @@ -77,6 +78,7 @@ export const APP_CONNECTION_PLAN_MAP: Record; + page: number; +} + +export const getDNSMadeEasyUrl = (path: string) => { + const appCfg = getConfig(); + return `${appCfg.DNS_MADE_EASY_SANDBOX_ENABLED ? IntegrationUrls.DNS_MADE_EASY_SANDBOX_API_URL : IntegrationUrls.DNS_MADE_EASY_API_URL}${path}`; +}; + +export const makeDNSMadeEasyAuthHeaders = ( + apiKey: string, + secretKey: string, + currentDate?: Date +): Record => { + // Format date as "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Mon, 01 Jan 2024 12:00:00 GMT") + const requestDate = (currentDate ?? new Date()).toUTCString(); + + // Generate HMAC-SHA1 signature + const hmac = crypto.nativeCrypto.createHmac("sha1", secretKey); + hmac.update(requestDate); + const hmacSignature = hmac.digest("hex"); + + return { + "x-dnsme-apiKey": apiKey, + "x-dnsme-hmac": hmacSignature, + "x-dnsme-requestDate": requestDate + }; +}; + +export const getDNSMadeEasyConnectionListItem = () => { + return { + name: "DNS Made Easy" as const, + app: AppConnection.DNSMadeEasy as const, + methods: Object.values(DNSMadeEasyConnectionMethod) as [DNSMadeEasyConnectionMethod.APIKeySecret] + }; +}; + +export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise => { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + + const { + credentials: { apiKey, secretKey } + } = appConnection; + + try { + const allZones: TDNSMadeEasyZone[] = []; + let currentPage = 0; + let totalPages = 1; + + // Fetch all pages of zones + while (currentPage < totalPages) { + // eslint-disable-next-line no-await-in-loop + const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + }, + params: { + page: currentPage + } + }); + + if (resp.data?.data) { + // Map the API response to TDNSMadeEasyZone format + const zones = resp.data.data.map((zone) => ({ + id: String(zone.id), + name: zone.name + })); + allZones.push(...zones); + + // Update pagination info + totalPages = resp.data.totalPages || 1; + currentPage += 1; + } else { + break; + } + } + + return allZones; + } catch (error: unknown) { + logger.error(error, "Error listing DNS Made Easy zones"); + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to list DNS Made Easy zones: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list DNS Made Easy zones" + }); + } +}; + +export const listDNSMadeEasyRecords = async ( + appConnection: TDNSMadeEasyConnection, + options: { zoneId: string; type?: string; name?: string } +): Promise => { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + const { + credentials: { apiKey, secretKey } + } = appConnection; + const { zoneId, type, name } = options; + + try { + const allRecords: DNSMadeEasyApiResponse["data"] = []; + let currentPage = 0; + let totalPages = 1; + + // Fetch all pages of records + while (currentPage < totalPages) { + // Build query parameters + const queryParams: Record = {}; + if (type) { + queryParams.type = type; + } + if (name) { + queryParams.recordName = name; + } + queryParams.page = currentPage; + + // eslint-disable-next-line no-await-in-loop + const resp = await request.get( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(zoneId)}/records`), + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + }, + params: queryParams + } + ); + + if (resp.data?.data) { + allRecords.push(...resp.data.data); + + // Update pagination info + totalPages = resp.data.totalPages || 1; + currentPage += 1; + } else { + break; + } + } + + return allRecords; + } catch (error: unknown) { + logger.error(error, "Error listing DNS Made Easy records"); + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to list DNS Made Easy records: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list DNS Made Easy records" + }); + } +}; + +export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => { + if (config.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + + const { apiKey, secretKey } = config.credentials; + + try { + const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + } + }); + if (resp.status !== 200) { + throw new BadRequestError({ + message: "Unable to validate connection: Invalid API credentials provided." + }); + } + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to validate credentials: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + logger.error(error, "Error validating DNS Made Easy connection credentials"); + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts new file mode 100644 index 000000000..d968ba768 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts @@ -0,0 +1,64 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; +import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum"; + +export const DNSMadeEasyConnectionApiKeyCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API key required").max(256, "API key cannot exceed 256 characters"), + secretKey: z.string().trim().min(1, "Secret key required").max(256, "Secret key cannot exceed 256 characters") +}); + +const BaseDNSMadeEasyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.DNSMadeEasy) +}); + +export const DNSMadeEasyConnectionSchema = BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema +}); + +export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method", [ + BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.pick({ apiKey: true }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy]} (API Key)` })) +]); + +export const ValidateDNSMadeEasyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(DNSMadeEasyConnectionMethod.APIKeySecret) + .describe(AppConnections.CREATE(AppConnection.DNSMadeEasy).method), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.DNSMadeEasy).credentials + ) + }) +]); + +export const CreateDNSMadeEasyConnectionSchema = ValidateDNSMadeEasyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy) +); + +export const UpdateDNSMadeEasyConnectionSchema = z + .object({ + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.DNSMadeEasy).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy)); + +export const DNSMadeEasyConnectionListItemSchema = z + .object({ + name: z.literal("DNS Made Easy"), + app: z.literal(AppConnection.DNSMadeEasy), + methods: z.nativeEnum(DNSMadeEasyConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy] })); diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts new file mode 100644 index 000000000..b50c9b73c --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts @@ -0,0 +1,35 @@ +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listDNSMadeEasyZones } from "./dns-made-easy-connection-fns"; +import { TDNSMadeEasyConnection } from "./dns-made-easy-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const dnsMadeEasyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.DNSMadeEasy, connectionId, actor); + try { + const zones = await listDNSMadeEasyZones(appConnection); + return zones; + } catch (error) { + logger.error( + error, + `Failed to list DNS Made Easy zones for DNS Made Easy connection [connectionId=${connectionId}]` + ); + throw new BadRequestError({ + message: `Failed to list DNS Made Easy zones: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + return { + listZones + }; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts new file mode 100644 index 000000000..eff96f6f9 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts @@ -0,0 +1,30 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateDNSMadeEasyConnectionSchema, + DNSMadeEasyConnectionSchema, + ValidateDNSMadeEasyConnectionCredentialsSchema +} from "./dns-made-easy-connection-schema"; + +export type TDNSMadeEasyConnection = z.infer; + +export type TDNSMadeEasyConnectionInput = z.infer & { + app: AppConnection.DNSMadeEasy; +}; + +export type TValidateDNSMadeEasyConnectionCredentialsSchema = typeof ValidateDNSMadeEasyConnectionCredentialsSchema; + +export type TDNSMadeEasyConnectionConfig = DiscriminativePick< + TDNSMadeEasyConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TDNSMadeEasyZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index e2f0f5f16..8e0f654a3 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -663,7 +663,8 @@ export const authLoginServiceFactory = ({ timestamp: new Date().toISOString(), ip: ipAddress, userAgent, - siteUrl: removeTrailingSlash(cfg.SITE_URL || "https://app.infisical.com") + siteUrl: removeTrailingSlash(cfg.SITE_URL || "https://app.infisical.com"), + orgId: organizationId }, template: SmtpTemplates.OrgAdminBreakglassAccess }); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index c4703d49f..09431f4f8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,4 +1,5 @@ export enum AcmeDnsProvider { Route53 = "route53", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy" } 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 ff95083c6..9616b3090 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,11 +1,13 @@ import * as x509 from "@peculiar/x509"; import acme, { CsrBuffer } from "acme-client"; import { Knex } from "knex"; +import RE2 from "re2"; import { TableName } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, CryptographyError, NotFoundError } from "@app/lib/errors"; +import { ProcessedPermissionRules } from "@app/lib/knex/permission-filter-utils"; import { OrgServiceActor } from "@app/lib/types"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; @@ -14,6 +16,7 @@ import { decryptAppConnection } from "@app/services/app-connection/app-connectio import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -23,6 +26,7 @@ import { CertKeyUsage, CertStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; @@ -43,8 +47,63 @@ import { TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy"; import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; + +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined, + ttl: string +): number | undefined => { + if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) { + return undefined; + } + + const certificateTtlInDays = parseTtlToDays(ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays); + + if (!renewBeforeDays) { + return undefined; + } + + return renewBeforeDays; +}; + type TAcmeCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; appConnectionService: Pick; @@ -53,7 +112,7 @@ type TAcmeCertificateAuthorityFnsDeps = { "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -64,13 +123,14 @@ type TAcmeCertificateAuthorityFnsDeps = { pkiSyncDAL: Pick; pkiSyncQueue: Pick; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type TOrderCertificateDeps = { appConnectionDAL: Pick; certificateAuthorityDAL: Pick; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -78,6 +138,7 @@ type TOrderCertificateDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type DBConfigurationColumn = { @@ -91,7 +152,7 @@ type DBConfigurationColumn = { export const castDbEntryToAcmeCertificateAuthority = ( ca: Awaited> -): TAcmeCertificateAuthority & { credentials: unknown } => { +): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => { if (!ca.externalCa?.id) { throw new BadRequestError({ message: "Malformed ACME certificate authority" }); } @@ -120,18 +181,41 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; +const getAcmeChallengeRecord = ( + provider: AcmeDnsProvider, + identifierValue: string, + keyAuthorization: string +): { recordName: string; recordValue: string } => { + let recordName: string; + if (provider === AcmeDnsProvider.DNSMadeEasy) { + // For DNS Made Easy, we don't need to provide the domain name in the record name. + recordName = "_acme-challenge"; + } else { + recordName = `_acme-challenge.${identifierValue}`; // e.g., "_acme-challenge.example.com" + } + const recordValue = `"${keyAuthorization}"`; // must be double quoted + return { recordName, recordValue }; +}; + export const orderCertificate = async ( { caId, + profileId, subscriberId, commonName, altNames, csr, csrPrivateKey, keyUsages, - extendedKeyUsages + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId }: { caId: string; + profileId?: string; subscriberId?: string; commonName: string; altNames?: string[]; @@ -139,6 +223,11 @@ export const orderCertificate = async ( csrPrivateKey?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + ttl?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isRenewal?: boolean; + originalCertificateId?: string; }, deps: TOrderCertificateDeps, tx?: Knex @@ -151,7 +240,8 @@ export const orderCertificate = async ( certificateBodyDAL, certificateSecretDAL, kmsService, - projectDAL + projectDAL, + certificateProfileDAL } = deps; const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); @@ -181,7 +271,7 @@ export const orderCertificate = async ( let accountKey: Buffer | undefined; if (acmeCa.credentials) { const decryptedCredentials = await kmsDecryptor({ - cipherTextBlob: acmeCa.credentials as Buffer + cipherTextBlob: acmeCa.credentials }); const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( @@ -241,8 +331,11 @@ export const orderCertificate = async ( 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 + const { recordName, recordValue } = getAcmeChallengeRecord( + acmeCa.configuration.dnsProviderConfig.provider, + authz.identifier.value, + keyAuthorization + ); switch (acmeCa.configuration.dnsProviderConfig.provider) { case AcmeDnsProvider.Route53: { @@ -263,14 +356,26 @@ export const orderCertificate = async ( ); break; } + case AcmeDnsProvider.DNSMadeEasy: { + await dnsMadeEasyInsertTxtRecord( + connection as TDNSMadeEasyConnection, + 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 + const { recordName, recordValue } = getAcmeChallengeRecord( + acmeCa.configuration.dnsProviderConfig.provider, + authz.identifier.value, + keyAuthorization + ); switch (acmeCa.configuration.dnsProviderConfig.provider) { case AcmeDnsProvider.Route53: { @@ -291,6 +396,15 @@ export const orderCertificate = async ( ); break; } + case AcmeDnsProvider.DNSMadeEasy: { + await dnsMadeEasyDeleteTxtRecord( + connection as TDNSMadeEasyConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } default: { throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); } @@ -322,6 +436,7 @@ export const orderCertificate = async ( { caId: ca.id, pkiSubscriberId: subscriberId, + profileId, status: CertStatus.ACTIVE, friendlyName: commonName, commonName, @@ -331,11 +446,18 @@ export const orderCertificate = async ( notAfter: certObj.notAfter, keyUsages, extendedKeyUsages, - projectId: ca.projectId + keyAlgorithm, + signatureAlgorithm, + projectId: ca.projectId, + renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null }, innerTx ); + if (isRenewal && originalCertificateId) { + await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx); + } + await certificateBodyDAL.create( { certId: cert.id, @@ -355,6 +477,26 @@ export const orderCertificate = async ( ); } + if (profileId && ttl && certificateProfileDAL) { + const profile = await certificateProfileDAL.findById(profileId, innerTx); + if (profile) { + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile as { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, + ttl + ); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById( + cert.id, + { + renewBeforeDays: finalRenewBeforeDays + }, + innerTx + ); + } + } + } + return cert; }); }; @@ -371,13 +513,13 @@ export const AcmeCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAcmeCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, projectId, configuration, - enableDirectIssuance, actor, status }: { @@ -385,7 +527,6 @@ export const AcmeCertificateAuthorityFns = ({ name: string; projectId: string; configuration: TCreateAcmeCertificateAuthorityDTO["configuration"]; - enableDirectIssuance: boolean; actor: OrgServiceActor; }) => { if (crypto.isFipsModeEnabled()) { @@ -413,6 +554,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy && appConnection.app !== AppConnection.DNSMadeEasy) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection` + }); + } + // validates permission to connect await appConnectionService.validateAppConnectionUsageById( appConnection.app as AppConnection, @@ -425,7 +572,7 @@ export const AcmeCertificateAuthorityFns = ({ const ca = await certificateAuthorityDAL.create( { projectId, - enableDirectIssuance, + enableDirectIssuance: false, name, status }, @@ -473,14 +620,12 @@ export const AcmeCertificateAuthorityFns = ({ id, status, configuration, - enableDirectIssuance, actor, name }: { id: string; status?: CaStatus; configuration: TUpdateAcmeCertificateAuthorityDTO["configuration"]; - enableDirectIssuance?: boolean; actor: OrgServiceActor; name?: string; }) => { @@ -508,6 +653,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy && + appConnection.app !== AppConnection.DNSMadeEasy + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection` + }); + } + const ca = await certificateAuthorityDAL.findById(id); if (!ca) { @@ -541,13 +695,12 @@ export const AcmeCertificateAuthorityFns = ({ ); } - if (name || status || enableDirectIssuance) { + if (name || status) { await certificateAuthorityDAL.updateById( id, { name, - status, - enableDirectIssuance + status }, tx ); @@ -563,11 +716,21 @@ export const AcmeCertificateAuthorityFns = ({ return castDbEntryToAcmeCertificateAuthority(updatedCa); }; - const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { - const cas = await certificateAuthorityDAL.findWithAssociatedCa({ - [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, - [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.ACME - }); + const listCertificateAuthorities = async ({ + projectId, + permissionFilters + }: { + projectId: string; + permissionFilters?: ProcessedPermissionRules; + }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa( + { + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.ACME + }, + {}, + permissionFilters + ); return cas.map(castDbEntryToAcmeCertificateAuthority); }; @@ -616,10 +779,71 @@ export const AcmeCertificateAuthorityFns = ({ await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; + const orderCertificateFromProfile = async ({ + caId, + profileId, + commonName, + altNames = [], + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }: { + caId: string; + profileId?: string; + commonName: string; + altNames?: string[]; + csr: CsrBuffer; + csrPrivateKey: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + ttl?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isRenewal?: boolean; + originalCertificateId?: string; + }) => { + return orderCertificate( + { + caId, + profileId, + subscriberId: undefined, + commonName, + altNames, + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages, + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }, + { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + certificateProfileDAL + } + ); + }; + return { createCertificateAuthority, updateCertificateAuthority, listCertificateAuthorities, - orderSubscriberCertificate + orderSubscriberCertificate, + orderCertificateFromProfile }; }; diff --git a/backend/src/services/certificate-authority/acme/deprecated-acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/deprecated-acme-certificate-authority-schemas.ts new file mode 100644 index 000000000..232475380 --- /dev/null +++ b/backend/src/services/certificate-authority/acme/deprecated-acme-certificate-authority-schemas.ts @@ -0,0 +1,14 @@ +import { CaType } from "../certificate-authority-enums"; +import { + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../deprecated-certificate-authority-schemas"; +import { AcmeCertificateAuthorityConfigurationSchema } from "./acme-certificate-authority-schemas"; + +export const CreateAcmeCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(CaType.ACME).extend({ + configuration: AcmeCertificateAuthorityConfigurationSchema +}); + +export const UpdateAcmeCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.ACME).extend({ + configuration: AcmeCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts new file mode 100644 index 000000000..cbfb26a2e --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts @@ -0,0 +1,106 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { + getDNSMadeEasyUrl, + listDNSMadeEasyRecords, + makeDNSMadeEasyAuthHeaders +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-fns"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; + +export const dnsMadeEasyInsertTxtRecord = async ( + connection: TDNSMadeEasyConnection, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiKey, secretKey } + } = connection; + + logger.info({ hostedZoneId, domain, value }, "Inserting TXT record for DNS Made Easy"); + try { + await request.post( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), + { + type: "TXT", + name: domain, + value, + ttl: 60 + }, + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = + (error.response?.data as { error?: string[] | string })?.error?.[0] || + (error.response?.data as { error?: string[] | string })?.error || + error.message || + "Unknown error"; + + if (error.status === 400 && error.message.includes("already exists")) { + logger.info({ domain, value }, `Record already exists for domain: ${domain} and value: ${value}`); + return; + } + + throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage)); + } + throw error; + } +}; + +export const dnsMadeEasyDeleteTxtRecord = async ( + connection: TDNSMadeEasyConnection, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiKey, secretKey } + } = connection; + + logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy"); + try { + const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain }); + + let foundRecord = false; + if (dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.value === value + ); + + if (recordToDelete) { + await request.delete( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records/${recordToDelete.id}`), + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + } + } + ); + foundRecord = true; + } + } + if (!foundRecord) { + logger.warn({ hostedZoneId, domain, value }, "Record to delete not found"); + } + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = + (error.response?.data as { error?: string[] | string })?.error?.[0] || + (error.response?.data as { error?: string[] | string })?.error || + error.message || + "Unknown error"; + throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage)); + } + throw error; + } +}; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 26f59a402..7ebd66146 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -5,6 +5,7 @@ import RE2 from "re2"; import { TableName } from "@app/db/schemas"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ProcessedPermissionRules } from "@app/lib/knex/permission-filter-utils"; import { ms } from "@app/lib/ms"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; @@ -21,8 +22,10 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage, - CertStatus + CertStatus, + TAltNameType } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; @@ -42,6 +45,60 @@ import { TUpdateAzureAdCsCertificateAuthorityDTO } from "./azure-ad-cs-certificate-authority-types"; +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; + +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined, + ttl: string +): number | undefined => { + const hasAutoRenewEnabled = profile?.apiConfig?.autoRenew === true; + if (!hasAutoRenewEnabled) { + return undefined; + } + + const profileRenewBeforeDays = profile?.apiConfig?.renewBeforeDays; + if (profileRenewBeforeDays !== undefined) { + const certificateTtlInDays = parseTtlToDays(ttl); + return calculateRenewalThreshold(profileRenewBeforeDays, certificateTtlInDays); + } + + return undefined; +}; + type TAzureAdCsCertificateAuthorityFnsDeps = { appConnectionDAL: Pick; appConnectionService: Pick; @@ -50,7 +107,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -61,6 +118,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = { pkiSyncDAL: Pick; pkiSyncQueue: Pick; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type AzureCertificateRequest = { @@ -190,7 +248,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie export const castDbEntryToAzureAdCsCertificateAuthority = ( ca: Awaited> -): TAzureAdCsCertificateAuthority & { credentials: unknown } => { +): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => { if (!ca.externalCa?.id) { throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" }); } @@ -591,13 +649,13 @@ export const AzureAdCsCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAzureAdCsCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, projectId, configuration, - enableDirectIssuance, actor, status }: { @@ -605,16 +663,8 @@ export const AzureAdCsCertificateAuthorityFns = ({ name: string; projectId: string; configuration: TCreateAzureAdCsCertificateAuthorityDTO["configuration"]; - enableDirectIssuance: boolean; actor: OrgServiceActor; }) => { - // Azure ADCS does not support direct issuance - enforce this restriction - if (enableDirectIssuance) { - throw new BadRequestError({ - message: "Azure ADCS Certificate Authorities do not support direct issuance" - }); - } - const { azureAdcsConnectionId } = configuration; const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); @@ -679,24 +729,15 @@ export const AzureAdCsCertificateAuthorityFns = ({ id, status, configuration, - enableDirectIssuance, actor, name }: { id: string; status?: CaStatus; configuration: TUpdateAzureAdCsCertificateAuthorityDTO["configuration"]; - enableDirectIssuance?: boolean; actor: OrgServiceActor; name?: string; }) => { - // Azure ADCS does not support direct issuance - enforce this restriction - if (enableDirectIssuance) { - throw new BadRequestError({ - message: "Azure ADCS Certificate Authorities do not support direct issuance" - }); - } - const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { if (configuration) { const { azureAdcsConnectionId } = configuration; @@ -737,13 +778,12 @@ export const AzureAdCsCertificateAuthorityFns = ({ ); } - if (name || status || enableDirectIssuance !== undefined) { + if (name || status) { await certificateAuthorityDAL.updateById( id, { name, - status, - enableDirectIssuance: false // Always false for Azure ADCS CAs + status }, tx ); @@ -759,11 +799,21 @@ export const AzureAdCsCertificateAuthorityFns = ({ return castDbEntryToAzureAdCsCertificateAuthority(updatedCa); }; - const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { - const cas = await certificateAuthorityDAL.findWithAssociatedCa({ - [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, - [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AZURE_AD_CS - }); + const listCertificateAuthorities = async ({ + projectId, + permissionFilters + }: { + projectId: string; + permissionFilters?: ProcessedPermissionRules; + }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa( + { + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AZURE_AD_CS + }, + {}, + permissionFilters + ); return cas.map(castDbEntryToAzureAdCsCertificateAuthority); }; @@ -1043,6 +1093,384 @@ export const AzureAdCsCertificateAuthorityFns = ({ }; }; + const orderCertificateFromProfile = async ({ + caId, + profileId, + commonName, + altNames = [], + keyUsages = [], + extendedKeyUsages = [], + template, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm = CertKeyAlgorithm.RSA_2048, + isRenewal, + originalCertificateId + }: { + caId: string; + profileId: string; + commonName: string; + altNames?: string[]; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + template?: string; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: CertKeyAlgorithm; + isRenewal?: boolean; + originalCertificateId?: string; + }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { + throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + if (azureCa.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 { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + let alg; + if (signatureAlgorithm) { + switch (signatureAlgorithm.toUpperCase()) { + case "RSA-SHA256": + case "SHA256WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + break; + case "RSA-SHA384": + case "SHA384WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_3072); + break; + case "RSA-SHA512": + case "SHA512WITHRSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_4096); + break; + case "ECDSA-SHA256": + case "SHA256WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P256); + break; + case "ECDSA-SHA384": + case "SHA384WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P384); + break; + case "ECDSA-SHA512": + case "SHA512WITHECDSA": + alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P521); + break; + default: + alg = keyAlgorithmToAlgCfg(keyAlgorithm); + break; + } + } else { + alg = keyAlgorithmToAlgCfg(keyAlgorithm); + } + + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const subjectDN = buildSubjectDN(commonName); + + let sanExtension = ""; + if (altNames && altNames.length > 0) { + sanExtension = altNames.join(","); + } + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: subjectDN, + keys: leafKeys, + signingAlgorithm: alg, + ...(sanExtension && { + extensions: [ + new x509.SubjectAlternativeNameExtension( + altNames.map((name) => ({ type: "dns" as TAltNameType, value: name })), + false + ) + ] + }) + }); + + const csrPem = csrObj.toString("pem"); + + let templateValue = template; + if (!templateValue) { + templateValue = "WebServer"; + } + + const templateInput = templateValue.trim(); + if (!templateInput || templateInput.length === 0) { + throw new BadRequestError({ + message: "Certificate template name cannot be empty" + }); + } + + let validityPeriod: string | undefined; + if (notBefore && notAfter) { + if (notAfter <= notBefore) { + throw new BadRequestError({ + message: "Certificate notAfter date must be after notBefore date" + }); + } + + const diffMs = notAfter.getTime() - notBefore.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + validityPeriod = `${diffDays}d`; + } else if (notAfter) { + const diffMs = notAfter.getTime() - Date.now(); + if (diffMs <= 0) { + throw new BadRequestError({ + message: "Certificate notAfter date must be in the future" + }); + } + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + validityPeriod = `${diffDays}d`; + } else if (validity.ttl) { + validityPeriod = validity.ttl; + } + + const certificateRequest: AzureCertificateRequest = { + csr: csrPem, + template: templateInput, + attributes: { + subject: subjectDN, + ...(sanExtension && { subjectAlternativeName: sanExtension }), + ...(validityPeriod && { validityPeriod }) + } + }; + + let submissionResponse; + const maxOidRetries = 3; + let oidRetryCount = 0; + + while (oidRetryCount <= maxOidRetries) { + try { + submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest); + break; + } catch (error) { + const isOidError = + error instanceof BadRequestError && + (error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type")); + + if (isOidError && oidRetryCount < maxOidRetries) { + oidRetryCount += 1; + + const delay = 3000 * oidRetryCount; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + throw error; + } + } + + if (!submissionResponse) { + throw new BadRequestError({ + message: "Failed to submit certificate request after multiple attempts due to OID resolution issues" + }); + } + + if (submissionResponse.status === "denied") { + throw new BadRequestError({ message: "Certificate request was denied by ADCS" }); + } + + let certificatePem = ""; + + if (submissionResponse.status === "issued" && submissionResponse.certificate) { + certificatePem = submissionResponse.certificate; + } else { + const maxRetries = 5; + const initialDelay = 2000; + let retryCount = 0; + let lastError: Error | null = null; + + // eslint-disable-next-line no-await-in-loop + while (retryCount < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId); + break; + } catch (error) { + lastError = error as Error; + // eslint-disable-next-line no-plusplus + retryCount++; + + if (retryCount < maxRetries) { + // Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s + const delay = initialDelay * 2 ** (retryCount - 1); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + } + + if (retryCount === maxRetries) { + throw new BadRequestError({ + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.` + }); + } + } + + if (!certificatePem) { + throw new BadRequestError({ + message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing." + }); + } + + let cleanedCertificatePem = certificatePem.trim(); + + if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Expected PEM format." + }); + } + + cleanedCertificatePem = cleanedCertificatePem + .replace(new RE2("\\r\\n", "g"), "\n") + .replace(new RE2("\\r", "g"), "\n") + .trim(); + + if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Missing end marker." + }); + } + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(cleanedCertificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = submissionResponse.certificateChain || ""; + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + let certificateId: string; + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + profileId, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames: altNames.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages, + extendedKeyUsages, + keyAlgorithm, + signatureAlgorithm, + projectId: ca.projectId, + renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null + }, + tx + ); + + certificateId = cert.id; + + if (isRenewal && originalCertificateId) { + await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx); + } + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + + if (profileId && validity?.ttl && certificateProfileDAL) { + const profile = await certificateProfileDAL.findById(profileId, tx); + if (profile) { + const finalRenewBeforeDays = calculateFinalRenewBeforeDays(undefined, validity.ttl); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById( + cert.id, + { + renewBeforeDays: finalRenewBeforeDays + }, + tx + ); + } + } + } + }); + + return { + certificate: cleanedCertificatePem, + certificateChain: certificateChainPem, + privateKey: skLeaf, + serialNumber: certObj.serialNumber, + certificateId: certificateId!, + ca: azureCa + }; + }; + const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca || ca.projectId !== projectId) { @@ -1182,6 +1610,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ updateCertificateAuthority, listCertificateAuthorities, orderSubscriberCertificate, + orderCertificateFromProfile, getTemplates }; }; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts index 2c2dfe484..004e3d4a5 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") }); +export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({ + username: z.string(), + password: z.string(), + sslRejectUnauthorized: z.boolean().optional(), + sslCertificate: z.string().optional() +}); + export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ type: z.literal(CaType.AZURE_AD_CS), configuration: AzureAdCsCertificateAuthorityConfigurationSchema diff --git a/backend/src/services/certificate-authority/azure-ad-cs/deprecated-azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/deprecated-azure-ad-cs-certificate-authority-schemas.ts new file mode 100644 index 000000000..a695fcec4 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/deprecated-azure-ad-cs-certificate-authority-schemas.ts @@ -0,0 +1,18 @@ +import { CaType } from "../certificate-authority-enums"; +import { + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../deprecated-certificate-authority-schemas"; +import { AzureAdCsCertificateAuthorityConfigurationSchema } from "./azure-ad-cs-certificate-authority-schemas"; + +export const CreateAzureAdCsCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const UpdateAzureAdCsCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index 352675441..6f621a515 100644 --- a/backend/src/services/certificate-authority/certificate-authority-dal.ts +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -4,6 +4,10 @@ import { TDbClient } from "@app/db"; import { CertificateAuthoritiesSchema, TableName, TCertificateAuthorities } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex"; +import { + applyProcessedPermissionRulesToQuery, + type ProcessedPermissionRules +} from "@app/lib/knex/permission-filter-utils"; export type TCertificateAuthorityDALFactory = ReturnType; @@ -220,10 +224,11 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => { const findWithAssociatedCa = async ( filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string; serialNumber?: string }, { offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt = {}, + permissionFilters?: ProcessedPermissionRules, tx?: Knex ) => { try { - const query = (tx || db.replicaNode())(TableName.CertificateAuthority) + let query = (tx || db.replicaNode())(TableName.CertificateAuthority) .leftJoin( TableName.InternalCertificateAuthority, `${TableName.CertificateAuthority}.id`, @@ -268,6 +273,14 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => { db.ref("appConnectionId").withSchema(TableName.ExternalCertificateAuthority).as("externalAppConnectionId") ); + if (permissionFilters) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.CertificateAuthority, + permissionFilters + ) as typeof query; + } + if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts index 5ecc50a4b..b50fb6293 100644 --- a/backend/src/services/certificate-authority/certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -19,14 +19,10 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), - enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) }); export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ - name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), - projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), - enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) }); diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index ed27f7571..a6dd4ce9e 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -1,8 +1,12 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, TableName } 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 { + ProjectPermissionCertificateAuthorityActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; @@ -11,6 +15,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; @@ -37,6 +42,7 @@ import { CaType } from "./certificate-authority-enums"; import { TCertificateAuthority, TCreateCertificateAuthorityDTO, + TDeprecatedUpdateCertificateAuthorityDTO, TUpdateCertificateAuthorityDTO } from "./certificate-authority-types"; import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal"; @@ -62,7 +68,7 @@ type TCertificateAuthorityServiceFactoryDep = { internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory; projectDAL: Pick; permissionService: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -72,6 +78,7 @@ type TCertificateAuthorityServiceFactoryDep = { pkiSubscriberDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + certificateProfileDAL?: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -90,7 +97,8 @@ export const certificateAuthorityServiceFactory = ({ kmsService, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TCertificateAuthorityServiceFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -104,7 +112,8 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -119,11 +128,12 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const createCertificateAuthority = async ( - { type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO, + { type, projectId, name, configuration, status }: TCreateCertificateAuthorityDTO, actor: OrgServiceActor ) => { const { permission } = await permissionService.getProjectPermission({ @@ -136,8 +146,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Create, + subject(ProjectPermissionSub.CertificateAuthorities, { name }) ); if (type === CaType.INTERNAL) { @@ -145,7 +155,6 @@ export const certificateAuthorityServiceFactory = ({ ...(configuration as TCreateInternalCertificateAuthorityDTO["configuration"]), isInternal: true, projectId, - enableDirectIssuance, name }); @@ -171,7 +180,6 @@ export const certificateAuthorityServiceFactory = ({ name, projectId, configuration: configuration as TCreateAcmeCertificateAuthorityDTO["configuration"], - enableDirectIssuance, status, actor }); @@ -182,7 +190,6 @@ export const certificateAuthorityServiceFactory = ({ name, projectId, configuration: configuration as TCreateAzureAdCsCertificateAuthorityDTO["configuration"], - enableDirectIssuance, status, actor }); @@ -191,6 +198,64 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "Invalid certificate authority type" }); }; + const findCertificateAuthorityById = async ({ id, type }: { id: string; type: CaType }, actor: OrgServiceActor) => { + const certificateAuthority = await certificateAuthorityDAL.findByIdWithAssociatedCa(id); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with id "${id}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) + ); + + if (type === CaType.INTERNAL) { + if (!certificateAuthority.internalCa?.id) { + throw new NotFoundError({ + message: `Internal certificate authority with id "${id}" not found` + }); + } + + return { + id: certificateAuthority.id, + type, + enableDirectIssuance: certificateAuthority.enableDirectIssuance, + subject: ProjectPermissionSub.CertificateAuthorities, + name: certificateAuthority.name, + projectId: certificateAuthority.projectId, + configuration: certificateAuthority.internalCa, + status: certificateAuthority.status + } as TCertificateAuthority; + } + + if (certificateAuthority.externalCa?.type !== type) { + throw new NotFoundError({ + message: `Could not find external certificate authority with id ${id} and type "${type}"` + }); + } + + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); + } + + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); + }; + const findCertificateAuthorityByNameAndProjectId = async ( { caName, type, projectId }: { caName: string; type: CaType; projectId: string }, actor: OrgServiceActor @@ -215,8 +280,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: caName }) ); if (type === CaType.INTERNAL) { @@ -268,15 +333,25 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionCertificateAuthorityActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const permissionFilters = getProcessedPermissionRules( + permission, + ProjectPermissionCertificateAuthorityActions.Read, ProjectPermissionSub.CertificateAuthorities ); if (type === CaType.INTERNAL) { - const cas = await certificateAuthorityDAL.findWithAssociatedCa({ - [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, - $notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"] - }); + const cas = await certificateAuthorityDAL.findWithAssociatedCa( + { + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + $notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"] + }, + {}, + permissionFilters + ); return cas .filter((ca): ca is typeof ca & { internalCa: NonNullable } => Boolean(ca.internalCa)) @@ -292,28 +367,25 @@ export const certificateAuthorityServiceFactory = ({ } if (type === CaType.ACME) { - return acmeFns.listCertificateAuthorities({ projectId }); + return acmeFns.listCertificateAuthorities({ projectId, permissionFilters }); } if (type === CaType.AZURE_AD_CS) { - return azureAdCsFns.listCertificateAuthorities({ projectId }); + return azureAdCsFns.listCertificateAuthorities({ projectId, permissionFilters }); } throw new BadRequestError({ message: "Invalid certificate authority type" }); }; const updateCertificateAuthority = async ( - { caName, type, configuration, enableDirectIssuance, status, name, projectId }: TUpdateCertificateAuthorityDTO, + { id, type, configuration, status, name }: TUpdateCertificateAuthorityDTO, actor: OrgServiceActor ) => { - const certificateAuthority = await certificateAuthorityDAL.findByNameAndProjectIdWithAssociatedCa( - caName, - projectId - ); + const certificateAuthority = await certificateAuthorityDAL.findByIdWithAssociatedCa(id); if (!certificateAuthority) throw new NotFoundError({ - message: `Could not find certificate authority with name "${caName}" in project "${projectId}"` + message: `Could not find certificate authority with id "${id}"` }); const { permission } = await permissionService.getProjectPermission({ @@ -326,20 +398,19 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) ); if (type === CaType.INTERNAL) { if (!certificateAuthority.internalCa?.id) { throw new NotFoundError({ - message: `Internal certificate authority with name "${caName}" in project "${projectId}" not found` + message: `Internal certificate authority with id "${id}" not found` }); } const updatedCa = await internalCertificateAuthorityService.updateCaById({ isInternal: true, - enableDirectIssuance, caId: certificateAuthority.id, status, name @@ -366,7 +437,6 @@ export const certificateAuthorityServiceFactory = ({ return acmeFns.updateCertificateAuthority({ id: certificateAuthority.id, configuration: configuration as TUpdateAcmeCertificateAuthorityDTO["configuration"], - enableDirectIssuance, actor, status, name @@ -377,7 +447,6 @@ export const certificateAuthorityServiceFactory = ({ return azureAdCsFns.updateCertificateAuthority({ id: certificateAuthority.id, configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"], - enableDirectIssuance, actor, status, name @@ -387,7 +456,148 @@ export const certificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "Invalid certificate authority type" }); }; - const deleteCertificateAuthority = async ( + const deleteCertificateAuthority = async ({ id, type }: { id: string; type: CaType }, actor: OrgServiceActor) => { + const certificateAuthority = await certificateAuthorityDAL.findByIdWithAssociatedCa(id); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with id "${id}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateAuthorityActions.Delete, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) + ); + + if (!certificateAuthority.internalCa?.id && type === CaType.INTERNAL) { + throw new BadRequestError({ + message: "Internal certificate authority cannot be deleted" + }); + } + + if (certificateAuthority.externalCa?.id && certificateAuthority.externalCa.type !== type) { + throw new BadRequestError({ + message: "External certificate authority cannot be deleted" + }); + } + + await certificateAuthorityDAL.deleteById(certificateAuthority.id); + + if (type === CaType.INTERNAL) { + return { + id: certificateAuthority.id, + type, + enableDirectIssuance: certificateAuthority.enableDirectIssuance, + name: certificateAuthority.name, + projectId: certificateAuthority.projectId, + configuration: certificateAuthority.internalCa, + status: certificateAuthority.status + } as TCertificateAuthority; + } + + if (type === CaType.ACME) { + return castDbEntryToAcmeCertificateAuthority(certificateAuthority); + } + + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); + }; + + const deprecatedUpdateCertificateAuthority = async ( + { caName, type, configuration, status, name, projectId }: TDeprecatedUpdateCertificateAuthorityDTO, + actor: OrgServiceActor + ) => { + const certificateAuthority = await certificateAuthorityDAL.findByNameAndProjectIdWithAssociatedCa( + caName, + projectId + ); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with name "${caName}" in project "${projectId}"` + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId: certificateAuthority.projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) + ); + + if (type === CaType.INTERNAL) { + if (!certificateAuthority.internalCa?.id) { + throw new NotFoundError({ + message: `Internal certificate authority with name "${caName}" in project "${projectId}" not found` + }); + } + + const updatedCa = await internalCertificateAuthorityService.updateCaById({ + isInternal: true, + caId: certificateAuthority.id, + status, + name + }); + + if (!updatedCa.internalCa) { + throw new BadRequestError({ + message: "Failed to update internal certificate authority" + }); + } + + return { + id: updatedCa.id, + type, + enableDirectIssuance: updatedCa.enableDirectIssuance, + name: updatedCa.name, + projectId: updatedCa.projectId, + configuration: updatedCa.internalCa, + status: updatedCa.status + } as TCertificateAuthority; + } + + if (type === CaType.ACME) { + return acmeFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAcmeCertificateAuthorityDTO["configuration"], + actor, + status, + name + }); + } + + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"], + actor, + status, + name + }); + } + + throw new BadRequestError({ message: "Invalid certificate authority type" }); + }; + + const deprecatedDeleteCertificateAuthority = async ( { caName, type, projectId }: { caName: string; type: CaType; projectId: string }, actor: OrgServiceActor ) => { @@ -411,8 +621,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Delete, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) ); if (!certificateAuthority.internalCa?.id && type === CaType.INTERNAL) { @@ -467,6 +677,13 @@ export const certificateAuthorityServiceFactory = ({ actorAuthMethod: OrgServiceActor["authMethod"]; actorOrgId?: string; }) => { + const certificateAuthority = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + + if (!certificateAuthority) + throw new NotFoundError({ + message: `Could not find certificate authority with id "${caId}"` + }); + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -477,8 +694,10 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { + name: certificateAuthority.name + }) ); return azureAdCsFns.getTemplates({ @@ -487,12 +706,57 @@ export const certificateAuthorityServiceFactory = ({ }); }; + const getCaById = async ({ + caId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + isInternal + }: { + caId: string; + actor: OrgServiceActor["type"]; + actorId: string; + actorAuthMethod: OrgServiceActor["authMethod"]; + actorOrgId?: string; + isInternal?: boolean; + }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca) { + throw new NotFoundError({ message: "CA not found" }); + } + + if (!isInternal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { + name: ca.name + }) + ); + } + + return ca; + }; + return { createCertificateAuthority, - findCertificateAuthorityByNameAndProjectId, + findCertificateAuthorityById, listCertificateAuthoritiesByProjectId, + findCertificateAuthorityByNameAndProjectId, updateCertificateAuthority, deleteCertificateAuthority, - getAzureAdcsTemplates + getAzureAdcsTemplates, + getCaById, + deprecatedUpdateCertificateAuthority, + deprecatedDeleteCertificateAuthority }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index 13b5cec40..029c9a760 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -19,9 +19,14 @@ export type TCertificateAuthorityInput = | TAcmeCertificateAuthorityInput | TCreateAzureAdCsCertificateAuthorityDTO; -export type TCreateCertificateAuthorityDTO = Omit; +export type TCreateCertificateAuthorityDTO = Omit; export type TUpdateCertificateAuthorityDTO = Partial> & { + type: CaType; + id: string; +}; + +export type TDeprecatedUpdateCertificateAuthorityDTO = Partial> & { type: CaType; caName: string; projectId: string; diff --git a/backend/src/services/certificate-authority/certificate-issuance-queue.ts b/backend/src/services/certificate-authority/certificate-issuance-queue.ts new file mode 100644 index 000000000..f590f2850 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-issuance-queue.ts @@ -0,0 +1,377 @@ +import acme from "acme-client"; + +import { crypto } from "@app/lib/crypto/cryptography"; +import { NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { CertKeyAlgorithm } from "../certificate-common/certificate-constants"; +import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "../certificate-request/certificate-request-types"; +import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; +import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { CaType } from "./certificate-authority-enums"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal"; + +export type TIssueCertificateFromProfileJobData = { + certificateId: string; + profileId: string; + caId: string; + commonName?: string; + altNames?: string[]; + ttl: string; + signatureAlgorithm: string; + keyAlgorithm: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + isRenewal?: boolean; + originalCertificateId?: string; + certificateRequestId?: string; + csr?: string; +}; + +type TCertificateIssuanceQueueFactoryDep = { + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + appConnectionDAL: Pick; + appConnectionService: Pick; + externalCertificateAuthorityDAL: Pick; + certificateDAL: TCertificateDALFactory; + projectDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + queueService: TQueueServiceFactory; + pkiSubscriberDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + certificateProfileDAL?: Pick; + certificateRequestService?: Pick< + TCertificateRequestServiceFactory, + "attachCertificateToRequest" | "updateCertificateRequestStatus" + >; +}; + +export type TCertificateIssuanceQueueFactory = ReturnType; + +export const certificateIssuanceQueueFactory = ({ + certificateAuthorityDAL, + appConnectionDAL, + appConnectionService, + externalCertificateAuthorityDAL, + certificateDAL, + projectDAL, + kmsService, + queueService, + certificateBodyDAL, + certificateSecretDAL, + pkiSubscriberDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL, + certificateRequestService +}: TCertificateIssuanceQueueFactoryDep) => { + const acmeFns = AcmeCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL + }); + + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL, + pkiSyncDAL, + pkiSyncQueue, + certificateProfileDAL + }); + + /** + * Queue a certificate issuance job using pgBoss + */ + const queueCertificateIssuance = async ({ + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId, + csr + }: TIssueCertificateFromProfileJobData) => { + const jobData: TIssueCertificateFromProfileJobData = { + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId, + csr + }; + + await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, { + retryLimit: 3, + retryDelay: 5, + retryBackoff: true + }); + }; + + /** + * Process certificate issuance jobs + */ + const processCertificateIssuanceJobs = async (data: TIssueCertificateFromProfileJobData) => { + const { + certificateId, + profileId, + caId, + commonName, + altNames, + ttl, + signatureAlgorithm, + keyAlgorithm, + keyUsages, + extendedKeyUsages, + isRenewal, + originalCertificateId, + certificateRequestId, + csr + } = data; + + try { + logger.info(`Processing certificate issuance job for [certificateId=${certificateId}] [caId=${caId}]`); + + if (!caId) { + throw new NotFoundError({ + message: `Certificate authority ID is required for external CA certificate issuance` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + + if (ca.externalCa?.type === CaType.ACME) { + let certificateCsr: string; + let skLeaf: string = ""; + + if (csr) { + certificateCsr = csr; + } else { + const keyAlg = keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const [, generatedCsr] = await acme.crypto.createCsr( + { + altNames: altNames ? [...altNames] : [], + commonName: commonName || "" + }, + skLeaf + ); + certificateCsr = generatedCsr.toString(); + } + + const acmeResult = await acmeFns.orderCertificateFromProfile({ + caId, + profileId, + commonName: commonName || "", + altNames: altNames || [], + csr: Buffer.from(certificateCsr), + csrPrivateKey: skLeaf, + keyUsages: keyUsages as CertKeyUsage[], + extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[], + ttl, + signatureAlgorithm, + keyAlgorithm, + isRenewal, + originalCertificateId + }); + + if (certificateRequestId && certificateRequestService && acmeResult?.id) { + try { + await certificateRequestService.attachCertificateToRequest({ + certificateRequestId, + certificateId: acmeResult.id + }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); + } catch (attachError) { + logger.error( + attachError, + `Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]` + ); + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}` + }); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + } else if (ca.externalCa?.type === CaType.AZURE_AD_CS) { + let template: string | undefined; + if (certificateProfileDAL) { + try { + const profile = await certificateProfileDAL.findById(profileId); + if ( + profile?.externalConfigs && + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null + ) { + const configs = profile.externalConfigs; + if (typeof configs.template === "string") { + template = configs.template; + } + } + } catch (error) { + logger.warn( + `Failed to fetch profile ${profileId} for template extraction: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + const azureParams = { + caId, + profileId, + commonName: commonName || "", + altNames: altNames || [], + keyUsages: keyUsages as CertKeyUsage[], + extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[], + validity: { ttl }, + signatureAlgorithm, + keyAlgorithm: keyAlgorithm as CertKeyAlgorithm, + isRenewal, + originalCertificateId, + template, + ...(csr && { csr }) + }; + + const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams); + + if (certificateRequestId && certificateRequestService && azureResult?.certificateId) { + try { + await certificateRequestService.attachCertificateToRequest({ + certificateRequestId, + certificateId: azureResult.certificateId + }); + logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`); + } catch (attachError) { + logger.error( + attachError, + `Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]` + ); + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}` + }); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + } + } + + logger.info( + `Successfully processed certificate issuance job with [certificateId=${certificateId}] [caId=${caId}]` + ); + } catch (error: unknown) { + logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`); + + if (certificateRequestId && certificateRequestService) { + try { + await certificateRequestService.updateCertificateRequestStatus({ + certificateRequestId, + status: CertificateRequestStatus.FAILED, + errorMessage: `Certificate issuance failed: ${error instanceof Error ? error.message : String(error)}` + }); + logger.info(`Updated certificate request ${certificateRequestId} status to failed due to issuance error`); + } catch (statusUpdateError) { + logger.error( + statusUpdateError, + `Failed to update certificate request status [certificateRequestId=${certificateRequestId}]` + ); + } + } + + throw error; + } + }; + + const initializeCertificateIssuanceQueue = async () => { + await queueService.startPg( + QueueJobs.CaIssueCertificateFromProfile, + async ([job]) => { + const data = job.data as TIssueCertificateFromProfileJobData; + await processCertificateIssuanceJobs(data); + }, + { + workerCount: 2, + batchSize: 1, + pollingIntervalSeconds: 1 + } + ); + + logger.info("Certificate issuance queue worker initialized successfully"); + }; + + return { + queueCertificateIssuance, + initializeCertificateIssuanceQueue, + processCertificateIssuanceJobs + }; +}; diff --git a/backend/src/services/certificate-authority/deprecated-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/deprecated-certificate-authority-schemas.ts new file mode 100644 index 000000000..5ecc50a4b --- /dev/null +++ b/backend/src/services/certificate-authority/deprecated-certificate-authority-schemas.ts @@ -0,0 +1,32 @@ +import z from "zod"; + +import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { CertificateAuthorities } from "@app/lib/api-docs/constants"; +import { slugSchema } from "@app/server/lib/schemas"; + +import { CaStatus, CaType } from "./certificate-authority-enums"; + +export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick({ + projectId: true, + enableDirectIssuance: true, + name: true, + id: true +}).extend({ + status: z.nativeEnum(CaStatus) +}); + +export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => + z.object({ + name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), + enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), + status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) + }); + +export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => + z.object({ + name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), + enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), + status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) + }); diff --git a/backend/src/services/certificate-authority/internal/deprecated-internal-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/internal/deprecated-internal-certificate-authority-schemas.ts new file mode 100644 index 000000000..292af17c9 --- /dev/null +++ b/backend/src/services/certificate-authority/internal/deprecated-internal-certificate-authority-schemas.ts @@ -0,0 +1,14 @@ +import { CaType } from "../certificate-authority-enums"; +import { + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../deprecated-certificate-authority-schemas"; +import { InternalCertificateAuthorityConfigurationSchema } from "./internal-certificate-authority-schemas"; + +export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.INTERNAL +).extend({ + configuration: InternalCertificateAuthorityConfigurationSchema +}); + +export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(CaType.INTERNAL); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 6f730fd72..2c5cb4b97 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -136,8 +136,8 @@ export const InternalCertificateAuthorityFns = ({ const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const appCfg = getConfig(); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), @@ -366,8 +366,8 @@ export const InternalCertificateAuthorityFns = ({ const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const appCfg = getConfig(); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts index 1cf9a8597..e3b6b4b21 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-schemas.ts @@ -11,7 +11,7 @@ import { } from "../certificate-authority-schemas"; import { validateCaDateField } from "../certificate-authority-validators"; -const InternalCertificateAuthorityConfigurationSchema = z +export const InternalCertificateAuthorityConfigurationSchema = z .object({ type: z.nativeEnum(InternalCaType).describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.type), friendlyName: z.string().optional().describe(CertificateAuthorities.CONFIGURATIONS.INTERNAL.friendlyName), diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index a7292e366..91b7727ec 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -7,8 +7,8 @@ import { Knex } from "knex"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { - ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionCertificateProfileActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSub @@ -34,8 +34,6 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, - CertSignatureAlgorithm, - CertSignatureType, CertStatus, TAltNameMapping } from "../../certificate/certificate-types"; @@ -69,6 +67,7 @@ import { TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TIssueCertFromCaResponse, TRenewCaCertDTO, TSignCertFromCaDTO, TSignIntermediateDTO, @@ -127,6 +126,22 @@ export const internalCertificateAuthorityServiceFactory = ({ kmsService, permissionService }: TInternalCertificateAuthorityServiceFactoryDep) => { + const $checkSignature = (caKeyAlg: string, requestedKeyType: string, signatureAlgorithm?: string) => { + const isRsaCa = caKeyAlg.startsWith("RSA"); + const isEcdsaCa = caKeyAlg.startsWith("EC") || caKeyAlg.startsWith("ECDSA"); + + // eslint-disable-next-line no-nested-ternary + const caSupports = isRsaCa ? "RSA" : isEcdsaCa ? "ECDSA" : "unknown"; + + const isRequestValid = (requestedKeyType === "RSA" && isRsaCa) || (requestedKeyType === "ECDSA" && isEcdsaCa); + + if (!isRequestValid) { + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlg}. CA can only sign with ${caSupports}-based signature algorithms.` + }); + } + }; + const createCa = async ({ type, friendlyName, @@ -140,7 +155,6 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter, maxPathLength, keyAlgorithm, - enableDirectIssuance, name, ...dto }: TCreateCaDTO) => { @@ -160,8 +174,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Create, + subject(ProjectPermissionSub.CertificateAuthorities, { name: commonName }) ); } else { projectId = dto.projectId; @@ -192,9 +206,9 @@ export const internalCertificateAuthorityServiceFactory = ({ const ca = await certificateAuthorityDAL.create( { projectId, - enableDirectIssuance, name: name || slugify(`${(friendlyName || dn).slice(0, 16)}-${alphaNumericNanoId(8)}`), - status: type === InternalCaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE + status: type === InternalCaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + enableDirectIssuance: false }, tx ); @@ -343,8 +357,8 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); return expandInternalCa(ca); @@ -354,7 +368,7 @@ export const internalCertificateAuthorityServiceFactory = ({ * Update CA with id [caId]. * Note: Used to enable/disable CA */ - const updateCaById = async ({ caId, status, enableDirectIssuance, name, ...dto }: TUpdateCaDTO) => { + const updateCaById = async ({ caId, status, name, ...dto }: TUpdateCaDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca.internalCa) throw new NotFoundError({ message: `CA with ID '${caId}' not found` }); @@ -369,14 +383,14 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); } const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { - if (enableDirectIssuance !== undefined || status !== undefined || name !== undefined) { - await certificateAuthorityDAL.updateById(ca.id, { enableDirectIssuance, status, name }, tx); + if (status !== undefined || name !== undefined) { + await certificateAuthorityDAL.updateById(ca.id, { status, name }, tx); } return certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); @@ -402,8 +416,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Delete, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); await certificateAuthorityDAL.deleteById(ca.id); @@ -428,8 +442,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Create, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); if (ca.internalCa.type === InternalCaType.ROOT) @@ -492,8 +506,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Renew, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); @@ -779,8 +793,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); const caCertChains = await getCaCertChains({ @@ -816,8 +830,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); const { caCert, caCertChain, serialNumber } = await getCaCertChain({ @@ -897,8 +911,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.SignIntermediate, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); @@ -971,9 +985,9 @@ export const internalCertificateAuthorityServiceFactory = ({ const serialNumber = createSerialNumber(); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const intermediateCert = await x509.X509CertificateGenerator.create({ serialNumber, subject: csrObj.subject, @@ -1045,8 +1059,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Create, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); if (ca.internalCa.parentCaId) { @@ -1185,7 +1199,7 @@ export const internalCertificateAuthorityServiceFactory = ({ isFromProfile, internal = false, tx - }: TIssueCertFromCaDTO) => { + }: TIssueCertFromCaDTO): Promise => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; let collectionId = pkiCollectionId; @@ -1302,26 +1316,7 @@ export const internalCertificateAuthorityServiceFactory = ({ const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); if (signatureAlgorithm) { - const caKeyAlgorithm = ca.internalCa.keyAlgorithm; - const requestedKeyType = signatureAlgorithm.split("-")[0]; - - const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]); - const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]); - - if ( - (requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) || - (requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa) - ) { - // eslint-disable-next-line no-nested-ternary - const supportedType = isRsaCa - ? CertSignatureAlgorithm.RSA_SHA256.split("-")[0] - : isEcdsaCa - ? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] - : "unknown"; - throw new BadRequestError({ - message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` - }); - } + $checkSignature(ca.internalCa.keyAlgorithm, signatureAlgorithm.split("-")[0], signatureAlgorithm); } // Determine signing algorithm for certificate signing @@ -1352,8 +1347,8 @@ export const internalCertificateAuthorityServiceFactory = ({ const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const appCfg = getConfig(); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), @@ -1538,10 +1533,11 @@ export const internalCertificateAuthorityServiceFactory = ({ return cert; }; + let cert; if (tx) { - await executeIssueCertOperations(tx); + cert = await executeIssueCertOperations(tx); } else { - await certificateDAL.transaction(executeIssueCertOperations); + cert = await certificateDAL.transaction(executeIssueCertOperations); } return { @@ -1550,6 +1546,8 @@ export const internalCertificateAuthorityServiceFactory = ({ issuingCaCertificate, privateKey: skLeaf, serialNumber, + certificateId: cert.id, + commonName, ca: expandInternalCa(ca) }; }; @@ -1577,15 +1575,16 @@ export const internalCertificateAuthorityServiceFactory = ({ keyUsages, extendedKeyUsages, signatureAlgorithm, - keyAlgorithm + keyAlgorithm, + tx } = dto; let collectionId = pkiCollectionId; if (caId) { - ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); } else if (certificateTemplateId) { - certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId); + certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId, tx); if (!certificateTemplate) { throw new NotFoundError({ message: `Certificate template with ID '${certificateTemplateId}' not found` @@ -1593,7 +1592,7 @@ export const internalCertificateAuthorityServiceFactory = ({ } collectionId = certificateTemplate.pkiCollectionId as string; - ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId); + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId, tx); } if (!ca) { @@ -1642,7 +1641,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // check PKI collection if (pkiCollectionId) { - const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId); + const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId, tx); if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` }); if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); } @@ -1690,22 +1689,7 @@ export const internalCertificateAuthorityServiceFactory = ({ } if (signatureAlgorithm) { - const caKeyAlgorithm = ca.internalCa.keyAlgorithm; - const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) - - const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA); - const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA); - - if ( - (requestedKeyType === CertSignatureType.RSA && !isRsaCa) || - (requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa) - ) { - // eslint-disable-next-line no-nested-ternary - const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown"; - throw new BadRequestError({ - message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` - }); - } + $checkSignature(ca.internalCa.keyAlgorithm, signatureAlgorithm.split("-")[0], signatureAlgorithm); } const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm; @@ -1716,12 +1700,7 @@ export const internalCertificateAuthorityServiceFactory = ({ const csrObj = new x509.Pkcs10CertificateRequest(csr); const dn = parseDistinguishedName(csrObj.subject); - const cn = commonName || dn.commonName; - - if (!cn) - throw new BadRequestError({ - message: "A common name (CN) is required in the CSR or as a parameter to this endpoint" - }); + const cn = (commonName || dn.commonName) ?? ""; const { caPrivateKey, caSecret } = await getCaCredentials({ caId: ca.id, @@ -1733,9 +1712,9 @@ export const internalCertificateAuthorityServiceFactory = ({ }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), @@ -1931,8 +1910,8 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChainPem) }); - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( + const createSignedCert = async (transaction: Knex) => { + const newCert = await certificateDAL.create( { caId: (ca as TCertificateAuthorities).id, caCertId: caCert.id, @@ -1950,36 +1929,44 @@ export const internalCertificateAuthorityServiceFactory = ({ keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm, signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, - tx + transaction ); await certificateBodyDAL.create( { - certId: cert.id, + certId: newCert.id, encryptedCertificate, encryptedCertificateChain }, - tx + transaction ); if (collectionId) { await pkiCollectionItemDAL.create( { pkiCollectionId: collectionId, - certId: cert.id + certId: newCert.id }, - tx + transaction ); } - return cert; - }); + return newCert; + }; + + let cert; + if (tx) { + cert = await createSignedCert(tx); + } else { + cert = await certificateDAL.transaction(createSignedCert); + } return { certificate: leafCert, certificateChain: certificateChainPem, issuingCaCertificate, serialNumber, + certificateId: cert.id, ca: expandInternalCa(ca), commonName: cn }; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index b4b037933..c13f85aa5 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -48,7 +48,6 @@ export type TCreateCaDTO = notAfter?: string; maxPathLength?: number | null; keyAlgorithm: CertKeyAlgorithm; - enableDirectIssuance: boolean; } | ({ isInternal: false; @@ -66,7 +65,6 @@ export type TCreateCaDTO = notAfter?: string; maxPathLength?: number | null; keyAlgorithm: CertKeyAlgorithm; - enableDirectIssuance: boolean; } & Omit); export type TGetCaDTO = { @@ -79,14 +77,12 @@ export type TUpdateCaDTO = caId: string; name?: string; status?: CaStatus; - enableDirectIssuance?: boolean; } | ({ isInternal: false; caId: string; name?: string; status?: CaStatus; - enableDirectIssuance?: boolean; } & Omit); export type TDeleteCaDTO = { @@ -164,6 +160,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } | ({ isInternal: false; @@ -183,6 +180,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -252,3 +250,20 @@ export type TIssueCertWithTemplateDTO = { keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; }; + +type TCaReference = { + id: string; + projectId: string; + dn: string; +}; + +export type TIssueCertFromCaResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + privateKey: string; + serialNumber: string; + certificateId: string; + ca: TCaReference; + commonName: string; +}; diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts index b88f183db..51754955f 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -196,3 +196,62 @@ export const convertExtendedKeyUsageArrayToLegacy = ( ): CertExtendedKeyUsage[] | undefined => { return usages?.map(convertToLegacyExtendedKeyUsage); }; + +/** + * Parses a PEM-formatted certificate chain and returns individual certificates + * @param certificateChain - PEM-formatted certificate chain + * @returns Array of individual PEM certificates + */ +const parseCertificateChain = (certificateChain: string): string[] => { + if (!certificateChain || typeof certificateChain !== "string") { + return []; + } + + const certRegex = new RE2(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + const certificates = certificateChain.match(certRegex); + + return certificates ? certificates.map((cert) => cert.trim()) : []; +}; + +/** + * Removes the root CA certificate from a certificate chain, leaving only intermediate certificates. + * If the chain contains only the root CA certificate, returns an empty string. + * + * @param certificateChain - PEM-formatted certificate chain containing leaf + intermediates + root CA + * @returns PEM-formatted certificate chain with only intermediate certificates (no root CA) + */ +export const removeRootCaFromChain = (certificateChain?: string): string => { + if (!certificateChain || typeof certificateChain !== "string") { + return ""; + } + + const certificates = parseCertificateChain(certificateChain); + + if (certificates.length === 0) { + return ""; + } + + const intermediateCerts = certificates.slice(0, -1); + + return intermediateCerts.join("\n"); +}; + +/** + * Extracts the root CA certificate from a certificate chain. + * + * @param certificateChain - PEM-formatted certificate chain containing leaf + intermediates + root CA + * @returns PEM-formatted root CA certificate, or empty string if not found + */ +export const extractRootCaFromChain = (certificateChain?: string): string => { + if (!certificateChain || typeof certificateChain !== "string") { + return ""; + } + + const certificates = parseCertificateChain(certificateChain); + + if (certificates.length === 0) { + return ""; + } + + return certificates[certificates.length - 1]; +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index 0d8ef30d0..2499a18d2 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -67,6 +67,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -169,6 +175,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -281,6 +293,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index d2f468248..3a8f99f1a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -4,9 +4,14 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { + applyProcessedPermissionRulesToQuery, + type ProcessedPermissionRules +} from "@app/lib/knex/permission-filter-utils"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -21,10 +26,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { try { - const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ - TCertificateProfile - ]; - return certificateProfile; + const dataToInsert = { + ...data, + externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null + }; + + const [insertedProfile] = await (tx || db)(TableName.PkiCertificateProfile).insert(dataToInsert).returning("*"); + + return { + ...insertedProfile, + externalConfigs: insertedProfile.externalConfigs + ? (JSON.parse(insertedProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Create certificate profile" }); } @@ -32,11 +46,25 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { try { - const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + const dataToUpdate: Partial> = { + ...data + }; + + if (data.externalConfigs !== undefined) { + dataToUpdate.externalConfigs = data.externalConfigs ? JSON.stringify(data.externalConfigs) : null; + } + + const [updatedProfile] = await (tx || db)(TableName.PkiCertificateProfile) .where({ id }) - .update(data) - .returning("*")) as [TCertificateProfile]; - return certificateProfile; + .update(dataToUpdate) + .returning("*"); + + return { + ...updatedProfile, + externalConfigs: updatedProfile.externalConfigs + ? (JSON.parse(updatedProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Update certificate profile" }); } @@ -56,10 +84,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex): Promise => { try { - const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as - | TCertificateProfile - | undefined; - return certificateProfile; + const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first(); + + if (!certificateProfile) return undefined; + + return { + ...certificateProfile, + externalConfigs: certificateProfile.externalConfigs + ? (JSON.parse(certificateProfile.externalConfigs) as Record) + : null + } as TCertificateProfile; } catch (error) { throw new DatabaseError({ error, name: "Find certificate profile by id" }); } @@ -198,9 +232,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, acmeConfigId: result.acmeConfigId, + externalConfigs: result.externalConfigs + ? (JSON.parse(result.externalConfigs) as Record) + : null, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, @@ -239,12 +277,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ): Promise => { try { - const { offset = 0, limit = 20, search, enrollmentType, caId } = options; + const { offset = 0, limit = 20, search, enrollmentType, issuerType, caId } = options; let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( `${TableName.PkiCertificateProfile}.projectId`, @@ -269,7 +309,21 @@ export const certificateProfileDALFactory = (db: TDbClient) => { baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); } - const query = baseQuery + if (issuerType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.issuerType`, issuerType); + } + + let query = baseQuery + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.ExternalCertificateAuthority, + `${TableName.CertificateAuthority}.id`, + `${TableName.ExternalCertificateAuthority}.caId` + ) .leftJoin( TableName.PkiEstEnrollmentConfig, `${TableName.PkiCertificateProfile}.estConfigId`, @@ -287,6 +341,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"), + db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalCaType"), db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), db .ref("disableBootstrapCaValidation") @@ -300,6 +359,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") ); + if (processedRules) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.PkiCertificateProfile, + processedRules + ) as typeof query; + } + const results = (await query .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .offset(offset) @@ -330,6 +397,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } : undefined; + const certificateAuthority = result.caId + ? { + id: result.caId as string, + name: result.caName as string, + status: result.caStatus as string, + isExternal: !!result.externalCaId, + externalType: result.externalCaType as string | undefined + } + : undefined; + const baseProfile = { id: result.id, projectId: result.projectId, @@ -338,13 +415,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, + externalConfigs: result.externalConfigs + ? (JSON.parse(result.externalConfigs as string) as Record) + : null, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, apiConfig, - acmeConfig + acmeConfig, + certificateAuthority }; return baseProfile as TCertificateProfileWithConfigs; @@ -359,12 +442,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { options: { search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ): Promise => { try { - const { search, enrollmentType, caId } = options; + const { search, enrollmentType, issuerType, caId } = options; let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); @@ -384,6 +469,18 @@ export const certificateProfileDALFactory = (db: TDbClient) => { query = query.where({ caId }); } + if (issuerType) { + query = query.where({ issuerType }); + } + + if (processedRules) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.PkiCertificateProfile, + processedRules + ) as typeof query; + } + const result = await query.count("*").first(); return parseInt((result as unknown as { count: string }).count || "0", 10); } catch (error) { diff --git a/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts new file mode 100644 index 000000000..2d54d0d4f --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-external-config-schemas.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +/** + * External configuration schema for Azure AD CS Certificate Authority + */ +export const AzureAdCsExternalConfigSchema = z.object({ + template: z + .string() + .min(1, "Template name is required for Azure AD CS") + .describe("Certificate template name for Azure AD CS") +}); + +/** + * External configuration schema for ACME Certificate Authority + */ +export const AcmeExternalConfigSchema = z.object({}); + +/** + * Map of CA types to their corresponding external configuration schemas + */ +export const ExternalConfigSchemaMap = { + [CaType.AZURE_AD_CS]: AzureAdCsExternalConfigSchema, + [CaType.ACME]: AcmeExternalConfigSchema, + [CaType.INTERNAL]: z.object({}).optional() // Internal CAs don't use external configs +} as const; + +export const createExternalConfigSchema = (caType?: CaType | null) => { + if (!caType || caType === CaType.INTERNAL) { + return z.object({}).nullable().optional(); + } + + const schema = ExternalConfigSchemaMap[caType]; + if (!schema) { + return z.object({}).nullable().optional(); + } + + return schema.nullable().optional(); +}; + +/** + * Union type of all possible external configuration schemas + */ +export const ExternalConfigUnionSchema = z + .union([AzureAdCsExternalConfigSchema, AcmeExternalConfigSchema, z.object({})]) + .nullable() + .optional(); + +export type TExternalConfig = z.infer; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index bf88593bd..e6b574dea 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -1,12 +1,13 @@ import RE2 from "re2"; import { z } from "zod"; -import { EnrollmentType } from "./certificate-profile-types"; +import { CertStatus } from "../certificate/certificate-types"; +import { EnrollmentType, IssuerType } from "./certificate-profile-types"; export const createCertificateProfileSchema = z .object({ projectId: z.string().uuid("Project ID must be valid"), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -15,6 +16,7 @@ export const createCertificateProfileSchema = z .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -33,43 +35,100 @@ export const createCertificateProfileSchema = z .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - 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 !!data.estConfig; } 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." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -83,6 +142,7 @@ export const updateCertificateProfileSchema = z .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -100,19 +160,34 @@ export const updateCertificateProfileSchema = z .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (data.apiConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (data.estConfig) { - return false; - } + return !data.apiConfig; } return true; }, { - message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + message: "EST enrollment type cannot have API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.enrollmentType || data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -131,6 +206,7 @@ export const listCertificateProfilesSchema = z.object({ limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }); @@ -142,6 +218,6 @@ export const listCertificatesByProfileSchema = z.object({ profileId: z.string().uuid(), offset: z.coerce.number().min(0).default(0), limit: z.coerce.number().min(1).max(100).default(20), - status: z.enum(["active", "expired", "revoked"]).optional(), + status: z.nativeEnum(CertStatus).optional(), search: z.string().optional() }); 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 1e31d5788..d10188e6f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; -import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import type { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; @@ -22,7 +22,12 @@ import type { TKmsServiceFactory } from "../kms/kms-service"; import type { TProjectDALFactory } from "../project/project-dal"; import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; -import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; +import { + EnrollmentType, + IssuerType, + TCertificateProfile, + TCertificateProfileWithConfigs +} from "./certificate-profile-types"; vi.mock("@app/lib/crypto/cryptography", () => ({ crypto: { @@ -90,10 +95,12 @@ describe("CertificateProfileService", () => { description: "Test certificate profile", slug: "test-profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + externalConfigs: null, createdAt: new Date(), updatedAt: new Date() }; @@ -162,7 +169,8 @@ describe("CertificateProfileService", () => { const mockPermissionService = { getProjectPermission: vi.fn().mockResolvedValue({ permission: { - throwUnlessCan: vi.fn() + throwUnlessCan: vi.fn(), + rules: [] } }) } as unknown as Pick; @@ -223,17 +231,10 @@ describe("CertificateProfileService", () => { delete: vi.fn() } as unknown as TCertificateAuthorityDALFactory; - const mockCertificateAuthorityCertDAL = { - create: vi.fn(), + const mockExternalCertificateAuthorityDAL = { 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; + findOne: vi.fn() + } as unknown as Pick; beforeEach(() => { vi.spyOn(ForbiddenError, "from").mockReturnValue({ @@ -255,7 +256,7 @@ describe("CertificateProfileService", () => { certificateBodyDAL: mockCertificateBodyDAL, certificateSecretDAL: mockCertificateSecretDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL, - certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL, permissionService: mockPermissionService, licenseService: mockLicenseService, kmsService: mockKmsService, @@ -272,6 +273,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -312,6 +314,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", @@ -383,6 +386,7 @@ describe("CertificateProfileService", () => { slug: "invalid-profile", description: "Invalid test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123" }; @@ -401,6 +405,7 @@ describe("CertificateProfileService", () => { slug: "api-profile", description: "Profile with API enrollment", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -428,7 +433,13 @@ describe("CertificateProfileService", () => { service.createProfile({ ...mockActor, projectId: "project-123", - data: validProfileData + data: { + ...validProfileData, + enrollmentType: EnrollmentType.ACME, + acmeConfig: {}, + apiConfig: undefined, + estConfig: undefined + } }) ).rejects.toThrowError( new BadRequestError({ @@ -588,13 +599,18 @@ describe("CertificateProfileService", () => { expect(result.profiles).toEqual(mockProfiles); expect(result.totalCount).toBe(1); - expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { - offset: 0, - limit: 20, - search: undefined, - enrollmentType: undefined, - caId: undefined - }); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith( + "project-123", + { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + issuerType: undefined + }, + { allowRules: [], forbidRules: [] } + ); }); it("should list profiles with filters", async () => { @@ -608,13 +624,18 @@ describe("CertificateProfileService", () => { caId: "ca-123" }); - expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { - offset: 10, - limit: 5, - search: "test", - enrollmentType: EnrollmentType.API, - caId: "ca-123" - }); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith( + "project-123", + { + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + issuerType: undefined + }, + { allowRules: [], forbidRules: [] } + ); }); }); @@ -720,6 +741,7 @@ describe("CertificateProfileService", () => { slug: "est-profile", description: "Profile with EST enrollment", enrollmentType: EnrollmentType.EST, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", estConfig: { @@ -770,6 +792,7 @@ describe("CertificateProfileService", () => { slug: "different-profile-name", description: "Profile with duplicate slug", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -795,6 +818,7 @@ describe("CertificateProfileService", () => { slug: "auto-renew-profile", description: "Profile with auto-renewal", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -959,6 +983,7 @@ describe("CertificateProfileService", () => { slug: "invalid-template-profile", description: "Profile with invalid template", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "nonexistent-template", apiConfig: { @@ -984,6 +1009,7 @@ describe("CertificateProfileService", () => { slug: "concurrent-profile", description: "Profile created concurrently", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1012,6 +1038,7 @@ describe("CertificateProfileService", () => { slug: "cross-project-profile", description: "Profile using template from different project", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-456", apiConfig: { @@ -1041,6 +1068,7 @@ describe("CertificateProfileService", () => { slug: "invalid-slug-profile", description: "Profile with invalid slug format", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 87063c6da..59e3afbd9 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; @@ -10,6 +10,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns"; +import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -19,8 +20,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type"; import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; -import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; +import { CaType } from "../certificate-authority/certificate-authority-enums"; +import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; @@ -32,6 +34,7 @@ import { getProjectKmsCertificateKeyId } from "../project/project-fns"; import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -39,6 +42,83 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; +const validateIssuerTypeConstraints = ( + issuerType: IssuerType, + enrollmentType: EnrollmentType, + caId: string | null, + existingCaId?: string | null +) => { + if (issuerType === IssuerType.CA) { + if (!caId && !existingCaId) { + throw new ForbiddenRequestError({ + message: "CA issuer type requires a Certificate Authority to be selected" + }); + } + } + + if (issuerType === IssuerType.SELF_SIGNED) { + if (caId) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type cannot have a Certificate Authority" + }); + } + if (enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type only supports API enrollment" + }); + } + } +}; + +const validateTemplateByExternalCaType = ( + externalCaType: CaType | undefined, + externalConfigs: Record | null | undefined +) => { + if (!externalCaType) return; + + switch (externalCaType) { + case CaType.AZURE_AD_CS: + if (!externalConfigs?.template || typeof externalConfigs.template !== "string") { + throw new ForbiddenRequestError({ + message: "Azure ADCS Certificate Authority requires a template to be specified in external configs" + }); + } + break; + default: + break; + } +}; + +const validateExternalConfigs = async ( + externalConfigs: Record | null | undefined, + caId: string | null, + certificateAuthorityDAL: Pick, + externalCertificateAuthorityDAL: Pick +) => { + if (!externalConfigs) return; + + if (!caId) { + throw new ForbiddenRequestError({ + message: "External configs can only be specified when a Certificate Authority is selected" + }); + } + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const externalCa = await externalCertificateAuthorityDAL.findOne({ caId }); + + if (!externalCa) { + throw new ForbiddenRequestError({ + message: "External configs can only be specified for external Certificate Authorities" + }); + } + + validateTemplateByExternalCaType(externalCa.type as CaType, externalConfigs); +}; + const generateAndEncryptAcmeEabSecret = async ( projectId: string, kmsService: Pick, @@ -151,7 +231,7 @@ type TCertificateProfileServiceFactoryDep = { certificateBodyDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + externalCertificateAuthorityDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -161,9 +241,22 @@ type TCertificateProfileServiceFactoryDep = { export type TCertificateProfileServiceFactory = ReturnType; const convertDalToService = (dalResult: Record): TCertificateProfile => { + let parsedExternalConfigs: Record | null = null; + if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "string") { + try { + parsedExternalConfigs = JSON.parse(dalResult.externalConfigs) as Record; + } catch { + parsedExternalConfigs = null; + } + } else if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "object") { + parsedExternalConfigs = dalResult.externalConfigs as Record; + } + return { ...dalResult, - enrollmentType: dalResult.enrollmentType as EnrollmentType + enrollmentType: dalResult.enrollmentType as EnrollmentType, + issuerType: dalResult.issuerType as IssuerType, + externalConfigs: parsedExternalConfigs } as TCertificateProfile; }; @@ -175,6 +268,8 @@ export const certificateProfileServiceFactory = ({ acmeEnrollmentConfigDAL, certificateBodyDAL, certificateSecretDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -205,7 +300,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Create, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: data.slug + }) ); const project = await projectDAL.findById(projectId); @@ -213,7 +310,7 @@ export const certificateProfileServiceFactory = ({ throw new NotFoundError({ message: "Project not found" }); } const plan = await licenseService.getPlan(project.orgId); - if (!plan.pkiAcme) { + if (!plan.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { throw new BadRequestError({ message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" }); @@ -240,6 +337,16 @@ export const certificateProfileServiceFactory = ({ }); } + validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); + + // Validate external configs + await validateExternalConfigs( + data.externalConfigs, + data.caId ?? null, + certificateAuthorityDAL, + externalCertificateAuthorityDAL + ); + // Validate enrollment configuration requirements if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { throw new ForbiddenRequestError({ @@ -308,7 +415,8 @@ export const certificateProfileServiceFactory = ({ projectId, estConfigId, apiConfigId, - acmeConfigId + acmeConfigId, + externalConfigs: data.externalConfigs }, tx ); @@ -349,7 +457,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Edit, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: existingProfile.slug + }) ); if (data.certificateTemplateId) { @@ -376,7 +486,26 @@ export const certificateProfileServiceFactory = ({ } } - const { estConfig, apiConfig, ...profileUpdateData } = data; + const finalIssuerType = data.issuerType || existingProfile.issuerType; + const finalEnrollmentType = data.enrollmentType || existingProfile.enrollmentType; + const finalCaId = data.caId !== undefined ? data.caId : existingProfile.caId; + + validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); + + // Validate external configs only if they are provided in the update + if (data.externalConfigs !== undefined) { + await validateExternalConfigs( + data.externalConfigs, + finalCaId ?? null, + certificateAuthorityDAL, + externalCertificateAuthorityDAL + ); + } + + const updatedData = + finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; + + const { estConfig, apiConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -453,7 +582,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const converted = convertDalToService(profile); @@ -489,7 +620,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); if (profile.estConfig && profile.estConfig.caChain) { @@ -517,9 +650,24 @@ export const certificateProfileServiceFactory = ({ } } + // Parse externalConfigs from JSON string to object if it exists + let parsedExternalConfigs: Record | null = null; + if (profile.externalConfigs && typeof profile.externalConfigs === "string") { + try { + parsedExternalConfigs = JSON.parse(profile.externalConfigs) as Record; + } catch { + // If parsing fails, leave as null + parsedExternalConfigs = null; + } + } else if (profile.externalConfigs && typeof profile.externalConfigs === "object") { + // Already an object, use as-is + parsedExternalConfigs = profile.externalConfigs; + } + return { ...profile, - enrollmentType: profile.enrollmentType as EnrollmentType + enrollmentType: profile.enrollmentType as EnrollmentType, + externalConfigs: parsedExternalConfigs }; }; @@ -548,7 +696,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug + }) ); const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); @@ -569,6 +719,7 @@ export const certificateProfileServiceFactory = ({ limit = 20, search, enrollmentType, + issuerType, caId }: { actor: ActorType; @@ -580,6 +731,7 @@ export const certificateProfileServiceFactory = ({ limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; }): Promise<{ profiles: TCertificateProfileWithConfigs[]; @@ -598,19 +750,35 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); - const profiles = await certificateProfileDAL.findByProjectId(projectId, { - offset, - limit, - search, - enrollmentType, - caId - }); + const processedRules = getProcessedPermissionRules( + permission, + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); - const totalCount = await certificateProfileDAL.countByProjectId(projectId, { - search, - enrollmentType, - caId - }); + const profiles = await certificateProfileDAL.findByProjectId( + projectId, + { + offset, + limit, + search, + enrollmentType, + issuerType, + caId + }, + processedRules + ); + + const totalCount = await certificateProfileDAL.countByProjectId( + projectId, + { + search, + enrollmentType, + issuerType, + caId + }, + processedRules + ); const convertedProfiles = await Promise.all( profiles.map(async (profile) => { @@ -695,7 +863,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Delete, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const deletedProfile = await certificateProfileDAL.deleteById(profileId); @@ -741,7 +911,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { @@ -782,17 +954,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles - ); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates - ); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.ReadPrivateKey, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId); @@ -801,6 +965,24 @@ export const certificateProfileServiceFactory = ({ return null; } + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) + ); + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ @@ -894,7 +1076,9 @@ export const certificateProfileServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); } @@ -945,7 +1129,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); if (profile.enrollmentType !== EnrollmentType.ACME) { diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 030548e97..3eca249cd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -10,16 +10,33 @@ export enum EnrollmentType { ACME = "acme" } -export type TCertificateProfile = Omit & { +export enum IssuerType { + CA = "ca", + SELF_SIGNED = "self-signed" +} + +export type TCertificateProfile = Omit & { enrollmentType: EnrollmentType; + issuerType: IssuerType; + externalConfigs?: Record | null; }; -export type TCertificateProfileInsert = Omit & { +export type TCertificateProfileInsert = Omit< + TPkiCertificateProfilesInsert, + "enrollmentType" | "issuerType" | "externalConfigs" +> & { enrollmentType: EnrollmentType; + issuerType: IssuerType; + externalConfigs?: Record | null; }; -export type TCertificateProfileUpdate = Omit & { +export type TCertificateProfileUpdate = Omit< + TPkiCertificateProfilesUpdate, + "enrollmentType" | "issuerType" | "externalConfigs" +> & { enrollmentType?: EnrollmentType; + issuerType?: IssuerType; + externalConfigs?: Record | null; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; @@ -42,6 +59,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { projectId: string; status: string; name: string; + isExternal?: boolean; + externalType?: string; }; certificateTemplate?: { id: string; diff --git a/backend/src/services/certificate-request/certificate-request-dal.ts b/backend/src/services/certificate-request/certificate-request-dal.ts new file mode 100644 index 000000000..df2a4b0c4 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-dal.ts @@ -0,0 +1,92 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TCertificateRequests, TCertificates } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +type TCertificateRequestWithCertificate = TCertificateRequests & { + certificate: TCertificates | null; +}; + +export type TCertificateRequestDALFactory = ReturnType; + +export const certificateRequestDALFactory = (db: TDbClient) => { + const certificateRequestOrm = ormify(db, TableName.CertificateRequests); + + const findByIdWithCertificate = async (id: string): Promise => { + try { + const certificateRequest = await certificateRequestOrm.findById(id); + if (!certificateRequest) return null; + + if (!certificateRequest.certificateId) { + return { + ...certificateRequest, + certificate: null + }; + } + + const certificate = await db(TableName.Certificate) + .where("id", certificateRequest.certificateId) + .select(selectAllTableCols(TableName.Certificate)) + .first(); + + return { + ...certificateRequest, + certificate: certificate || null + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" }); + } + }; + + const findPendingByProjectId = async (projectId: string): Promise => { + try { + return (await db(TableName.CertificateRequests) + .where({ projectId, status: "pending" }) + .orderBy("createdAt", "desc")) as TCertificateRequests[]; + } catch (error) { + throw new DatabaseError({ error, name: "Find pending certificate requests by project ID" }); + } + }; + + const updateStatus = async ( + id: string, + status: string, + errorMessage?: string, + tx?: Knex + ): Promise => { + try { + const updateData: Partial = { status }; + if (errorMessage !== undefined) { + updateData.errorMessage = errorMessage; + } + return await certificateRequestOrm.updateById(id, updateData, tx); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate request status" }); + } + }; + + const attachCertificate = async (id: string, certificateId: string, tx?: Knex): Promise => { + try { + return await certificateRequestOrm.updateById( + id, + { + certificateId, + status: "issued" + }, + tx + ); + } catch (error) { + throw new DatabaseError({ error, name: "Attach certificate to request" }); + } + }; + + return { + ...certificateRequestOrm, + findByIdWithCertificate, + findPendingByProjectId, + updateStatus, + attachCertificate + }; +}; diff --git a/backend/src/services/certificate-request/certificate-request-service.test.ts b/backend/src/services/certificate-request/certificate-request-service.test.ts new file mode 100644 index 000000000..10c8b73cf --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -0,0 +1,627 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { createMongoAbility, ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, + ProjectPermissionSet, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { NotFoundError } from "@app/lib/errors"; +import { ActorType, AuthMethod } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; + +import { TCertificateRequestDALFactory } from "./certificate-request-dal"; +import { certificateRequestServiceFactory, TCertificateRequestServiceFactory } from "./certificate-request-service"; +import { CertificateRequestStatus } from "./certificate-request-types"; + +describe("CertificateRequestService", () => { + let service: TCertificateRequestServiceFactory; + + const mockCertificateRequestDAL: Pick< + TCertificateRequestDALFactory, + "create" | "findById" | "findByIdWithCertificate" | "updateStatus" | "attachCertificate" + > = { + create: vi.fn() as any, + findById: vi.fn() as any, + findByIdWithCertificate: vi.fn() as any, + updateStatus: vi.fn() as any, + attachCertificate: vi.fn() as any + }; + + const mockCertificateDAL: Pick = { + findById: vi.fn() as any + }; + + const mockCertificateService: Pick = { + getCertBody: vi.fn() as any, + getCertPrivateKey: vi.fn() as any + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn() as any + }; + + beforeEach(() => { + vi.clearAllMocks(); + service = certificateRequestServiceFactory({ + certificateRequestDAL: mockCertificateRequestDAL as TCertificateRequestDALFactory, + certificateDAL: mockCertificateDAL, + certificateService: mockCertificateService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createCertificateRequest", () => { + const mockCreateData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com", + status: CertificateRequestStatus.PENDING + }; + + it("should create certificate request successfully", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateProfileActions.IssueCert, + subject: ProjectPermissionSub.CertificateProfiles + } + ]) + }; + const mockCreatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + status: CertificateRequestStatus.PENDING, + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.create as any).mockResolvedValue(mockCreatedRequest); + + const result = await service.createCertificateRequest(mockCreateData); + + expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({ + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + projectId: "550e8400-e29b-41d4-a716-446655440003", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + actionProjectType: ActionProjectType.CertificateManager + }); + expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith( + { + status: CertificateRequestStatus.PENDING, + projectId: "550e8400-e29b-41d4-a716-446655440003", + profileId: "550e8400-e29b-41d4-a716-446655440004", + commonName: "test.example.com" + }, + undefined + ); + expect(result).toEqual(mockCreatedRequest); + }); + + it("should throw ForbiddenError when user lacks permission", async () => { + const mockPermission = { + permission: ForbiddenError.from(createMongoAbility([])) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + + await expect(service.createCertificateRequest(mockCreateData)).rejects.toThrow(); + }); + }); + + describe("getCertificateRequest", () => { + const mockGetData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + certificateRequestId: "550e8400-e29b-41d4-a716-446655440005" + }; + + it("should get certificate request successfully", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.PENDING + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + + const result = await service.getCertificateRequest(mockGetData); + + expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({ + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + projectId: "550e8400-e29b-41d4-a716-446655440003", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + actionProjectType: ActionProjectType.CertificateManager + }); + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440005"); + expect(result).toEqual(mockRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError); + }); + + it("should throw BadRequestError when certificate request belongs to different project", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440099", + status: CertificateRequestStatus.PENDING + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + + await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError); + }); + }); + + describe("getCertificateFromRequest", () => { + const mockGetData = { + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002", + projectId: "550e8400-e29b-41d4-a716-446655440003", + certificateRequestId: "550e8400-e29b-41d4-a716-446655440005" + }; + + it("should get certificate from request successfully when certificate is attached", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + }, + { + action: ProjectPermissionCertificateActions.ReadPrivateKey, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440006", + serialNumber: "123456", + commonName: "test.example.com" + }; + const mockRequestWithCert = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.ISSUED, + certificate: mockCertificate, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const mockCertBody = { + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----" + }; + const mockPrivateKey = { + certPrivateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); + (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); + (mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440005" + ); + expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ + id: "550e8400-e29b-41d4-a716-446655440006", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ + id: "550e8400-e29b-41d4-a716-446655440006", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(result).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----", + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + }); + + it("should get certificate from request successfully when no certificate is attached", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockRequestWithoutCert = { + id: "550e8400-e29b-41d4-a716-446655440007", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.PENDING, + certificate: null, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(result).toEqual({ + status: CertificateRequestStatus.PENDING, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: null, + createdAt: mockRequestWithoutCert.createdAt, + updatedAt: mockRequestWithoutCert.updatedAt + }); + }); + + it("should get certificate from request successfully when user lacks private key permission", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440008", + serialNumber: "123456", + commonName: "test.example.com" + }; + const mockRequestWithCert = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.ISSUED, + certificate: mockCertificate, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const mockCertBody = { + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); + (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440005" + ); + expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ + id: "550e8400-e29b-41d4-a716-446655440008", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(mockCertificateService.getCertPrivateKey).not.toHaveBeenCalled(); + expect(result).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: null, + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + }); + + it("should get certificate from request successfully when user has private key permission but key retrieval fails", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + }, + { + action: ProjectPermissionCertificateActions.ReadPrivateKey, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440009", + serialNumber: "123456", + commonName: "test.example.com" + }; + const mockRequestWithCert = { + id: "550e8400-e29b-41d4-a716-446655440005", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.ISSUED, + certificate: mockCertificate, + errorMessage: null, + createdAt: new Date(), + updatedAt: new Date() + }; + const mockCertBody = { + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----" + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert); + (mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody); + (mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key not found")); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440005" + ); + expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({ + id: "550e8400-e29b-41d4-a716-446655440009", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({ + id: "550e8400-e29b-41d4-a716-446655440009", + actor: ActorType.USER, + actorId: "550e8400-e29b-41d4-a716-446655440001", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "550e8400-e29b-41d4-a716-446655440002" + }); + expect(result).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: null, + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + }); + + it("should get certificate from request with error message when failed", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + const mockFailedRequest = { + id: "550e8400-e29b-41d4-a716-446655440010", + projectId: "550e8400-e29b-41d4-a716-446655440003", + status: CertificateRequestStatus.FAILED, + certificate: null, + errorMessage: "Certificate issuance failed", + createdAt: new Date(), + updatedAt: new Date() + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest); + + const result = await service.getCertificateFromRequest(mockGetData); + + expect(result).toEqual({ + status: CertificateRequestStatus.FAILED, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: "Certificate issuance failed", + createdAt: mockFailedRequest.createdAt, + updatedAt: mockFailedRequest.updatedAt + }); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + const mockPermission = { + permission: createMongoAbility([ + { + action: ProjectPermissionCertificateActions.Read, + subject: ProjectPermissionSub.Certificates + } + ]) + }; + + (mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission); + (mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(null); + + await expect(service.getCertificateFromRequest(mockGetData)).rejects.toThrow(NotFoundError); + }); + }); + + describe("updateCertificateRequestStatus", () => { + it("should update certificate request status successfully", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.PENDING + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.ISSUED + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440011", + status: CertificateRequestStatus.ISSUED + }); + + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440011"); + expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440011", + CertificateRequestStatus.ISSUED, + undefined + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should update certificate request status with error message", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.PENDING + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.FAILED + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440012", + status: CertificateRequestStatus.FAILED, + errorMessage: "Certificate issuance failed" + }); + + expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440012", + CertificateRequestStatus.FAILED, + "Certificate issuance failed" + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateCertificateRequestStatus({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440013", + status: CertificateRequestStatus.ISSUED + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("attachCertificateToRequest", () => { + it("should attach certificate to request successfully", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440014", + status: CertificateRequestStatus.PENDING + }; + const mockCertificate = { + id: "550e8400-e29b-41d4-a716-446655440015" + }; + const mockUpdatedRequest = { + id: "550e8400-e29b-41d4-a716-446655440014", + status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440015" + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateDAL.findById as any).mockResolvedValue(mockCertificate); + (mockCertificateRequestDAL.attachCertificate as any).mockResolvedValue(mockUpdatedRequest); + + const result = await service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440014", + certificateId: "550e8400-e29b-41d4-a716-446655440015" + }); + + expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440014"); + expect(mockCertificateDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440015"); + expect(mockCertificateRequestDAL.attachCertificate).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440014", + "550e8400-e29b-41d4-a716-446655440015" + ); + expect(result).toEqual(mockUpdatedRequest); + }); + + it("should throw NotFoundError when certificate request does not exist", async () => { + (mockCertificateRequestDAL.findById as any).mockResolvedValue(null); + + await expect( + service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440016", + certificateId: "550e8400-e29b-41d4-a716-446655440017" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw NotFoundError when certificate does not exist", async () => { + const mockRequest = { + id: "550e8400-e29b-41d4-a716-446655440018", + status: CertificateRequestStatus.PENDING + }; + + (mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest); + (mockCertificateDAL.findById as any).mockResolvedValue(null); + + await expect( + service.attachCertificateToRequest({ + certificateRequestId: "550e8400-e29b-41d4-a716-446655440018", + certificateId: "550e8400-e29b-41d4-a716-446655440019" + }) + ).rejects.toThrow(NotFoundError); + }); + }); +}); diff --git a/backend/src/services/certificate-request/certificate-request-service.ts b/backend/src/services/certificate-request/certificate-request-service.ts new file mode 100644 index 000000000..46cd49476 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -0,0 +1,290 @@ +import { ForbiddenError } from "@casl/ability"; +import { Knex } from "knex"; +import { z } from "zod"; + +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 { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; + +import { ActorType } from "../auth/auth-type"; +import { TCertificateRequestDALFactory } from "./certificate-request-dal"; +import { + CertificateRequestStatus, + TAttachCertificateToRequestDTO, + TCreateCertificateRequestDTO, + TGetCertificateFromRequestDTO, + TGetCertificateRequestDTO, + TUpdateCertificateRequestStatusDTO +} from "./certificate-request-types"; + +type TCertificateRequestServiceFactoryDep = { + certificateRequestDAL: TCertificateRequestDALFactory; + certificateDAL: Pick; + certificateService: Pick; + permissionService: Pick; +}; + +export type TCertificateRequestServiceFactory = ReturnType; + +const certificateRequestDataSchema = z + .object({ + profileId: z.string().uuid().optional(), + caId: z.string().uuid().optional(), + csr: z.string().min(1).optional(), + commonName: z.string().max(255).optional(), + altNames: z.string().max(1000).optional(), + keyUsages: z.array(z.string()).max(20).optional(), + extendedKeyUsages: z.array(z.string()).max(20).optional(), + notBefore: z.date().optional(), + notAfter: z.date().optional(), + keyAlgorithm: z.string().max(100).optional(), + signatureAlgorithm: z.string().max(100).optional(), + metadata: z.string().max(2000).optional(), + certificateId: z.string().optional() + }) + .refine( + (data) => { + // Must have either profileId or caId + return data.profileId || data.caId; + }, + { + message: "Either profileId or caId must be provided" + } + ) + .refine( + (data) => { + // If notAfter is provided, it must be after notBefore + if (data.notBefore && data.notAfter) { + return data.notAfter > data.notBefore; + } + return true; + }, + { + message: "notAfter must be after notBefore" + } + ); + +const validateCertificateRequestData = (data: unknown) => { + try { + return certificateRequestDataSchema.parse(data); + } catch (error) { + if (error instanceof z.ZodError) { + throw new BadRequestError({ + message: `Invalid certificate request data: ${error.errors.map((e) => e.message).join(", ")}` + }); + } + throw error; + } +}; + +export const certificateRequestServiceFactory = ({ + certificateRequestDAL, + certificateDAL, + certificateService, + permissionService +}: TCertificateRequestServiceFactoryDep) => { + const createCertificateRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + tx, + status, + ...requestData + }: TCreateCertificateRequestDTO & { tx?: Knex }) => { + if (actor !== ActorType.ACME_ACCOUNT) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } + + // Validate input data before creating the request + const validatedData = validateCertificateRequestData(requestData); + + const certificateRequest = await certificateRequestDAL.create( + { + status, + projectId, + ...validatedData + }, + tx + ); + + return certificateRequest; + }; + + const getCertificateRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + certificateRequestId + }: TGetCertificateRequestDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + if (certificateRequest.projectId !== projectId) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + return certificateRequest; + }; + + const getCertificateFromRequest = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + certificateRequestId + }: TGetCertificateFromRequestDTO) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + if (certificateRequest.projectId !== projectId) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + // If no certificate is attached, return basic info + if (!certificateRequest.certificate) { + return { + status: certificateRequest.status as CertificateRequestStatus, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }; + } + + // Get certificate body (PEM data) + const certBody = await certificateService.getCertBody({ + id: certificateRequest.certificate.id, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + const canReadPrivateKey = permission.can( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + let privateKey: string | null = null; + if (canReadPrivateKey) { + try { + const certPrivateKey = await certificateService.getCertPrivateKey({ + id: certificateRequest.certificate.id, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + privateKey = certPrivateKey.certPrivateKey; + } catch (error) { + privateKey = null; + } + } + + return { + status: certificateRequest.status as CertificateRequestStatus, + certificate: certBody.certificate, + privateKey, + serialNumber: certificateRequest.certificate.serialNumber, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }; + }; + + const updateCertificateRequestStatus = async ({ + certificateRequestId, + status, + errorMessage + }: TUpdateCertificateRequestStatusDTO) => { + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + return certificateRequestDAL.updateStatus(certificateRequestId, status, errorMessage); + }; + + const attachCertificateToRequest = async ({ + certificateRequestId, + certificateId + }: TAttachCertificateToRequestDTO) => { + const certificateRequest = await certificateRequestDAL.findById(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId); + }; + + return { + createCertificateRequest, + getCertificateRequest, + getCertificateFromRequest, + updateCertificateRequestStatus, + attachCertificateToRequest + }; +}; diff --git a/backend/src/services/certificate-request/certificate-request-types.ts b/backend/src/services/certificate-request/certificate-request-types.ts new file mode 100644 index 000000000..c8a00de7e --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum CertificateRequestStatus { + PENDING = "pending", + ISSUED = "issued", + FAILED = "failed" +} + +export type TCreateCertificateRequestDTO = TProjectPermission & { + profileId?: string; + caId?: string; + csr?: string; + commonName?: string; + altNames?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + notBefore?: Date; + notAfter?: Date; + keyAlgorithm?: string; + signatureAlgorithm?: string; + metadata?: string; + status: CertificateRequestStatus; + certificateId?: string; +}; + +export type TGetCertificateRequestDTO = TProjectPermission & { + certificateRequestId: string; +}; + +export type TGetCertificateFromRequestDTO = TProjectPermission & { + certificateRequestId: string; +}; + +export type TUpdateCertificateRequestStatusDTO = { + certificateRequestId: string; + status: CertificateRequestStatus; + errorMessage?: string; +}; + +export type TAttachCertificateToRequestDTO = { + certificateRequestId: string; + certificateId: string; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts index 3b935f26a..4951986b1 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -5,6 +5,10 @@ import { TableName } from "@app/db/schemas"; import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2"; import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; +import { + applyProcessedPermissionRulesToQuery, + type ProcessedPermissionRules +} from "@app/lib/knex/permission-filter-utils"; import { TCertificateTemplateV2, @@ -133,6 +137,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { limit?: number; search?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ) => { try { @@ -146,6 +151,14 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { }); } + if (processedRules) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.PkiCertificateTemplateV2, + processedRules + ) as typeof query; + } + const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit); return certificateTemplatesV2.map((template: Record) => parseJsonFields(template)); @@ -159,6 +172,7 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { options: { search?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ) => { try { @@ -172,6 +186,14 @@ export const certificateTemplateV2DALFactory = (db: TDbClient) => { }); } + if (processedRules) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.PkiCertificateTemplateV2, + processedRules + ) as typeof query; + } + const result = await query.count("*").first(); return parseInt((result as unknown as { count: string }).count || "0", 10); } catch (error) { diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts index f73d516a6..3b8f7bc13 100644 --- a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -267,14 +267,22 @@ describe("CertificateTemplateV2Service", () => { limit: 20 }); - expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { - offset: 0, - limit: 20, - search: undefined - }); - expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { - search: undefined - }); + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith( + "project-123", + { + offset: 0, + limit: 20, + search: undefined + }, + { allowRules: [], forbidRules: [] } + ); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith( + "project-123", + { + search: undefined + }, + { allowRules: [], forbidRules: [] } + ); expect(result).toEqual({ templates, totalCount }); }); @@ -291,14 +299,22 @@ describe("CertificateTemplateV2Service", () => { search: "web server" }); - expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { - offset: 0, - limit: 20, - search: "web server" - }); - expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { - search: "web server" - }); + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith( + "project-123", + { + offset: 0, + limit: 20, + search: "web server" + }, + { allowRules: [], forbidRules: [] } + ); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith( + "project-123", + { + search: "web server" + }, + { allowRules: [], forbidRules: [] } + ); }); }); 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 656c12e64..5d24e4b8b 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 @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import RE2 from "re2"; @@ -8,6 +8,7 @@ import { ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils"; import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; @@ -77,12 +78,12 @@ export const certificateTemplateV2ServiceFactory = ({ }; const validateSubjectAttributePolicy = ( - subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + subjectAttributes: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> ) => { - if (!subject || subject.length === 0) return; + if (!subjectAttributes || subjectAttributes.length === 0) return; // Validate each subject attribute policy - for (const attr of subject) { + for (const attr of subjectAttributes) { // Ensure at least one field is provided if (!attr.allowed && !attr.required && !attr.denied) { throw new ForbiddenRequestError({ @@ -634,7 +635,9 @@ export const certificateTemplateV2ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiTemplateActions.Create, - ProjectPermissionSub.CertificateTemplates + subject(ProjectPermissionSub.CertificateTemplates, { + name: data.name + }) ); if (!data) { @@ -711,7 +714,9 @@ export const certificateTemplateV2ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiTemplateActions.Edit, - ProjectPermissionSub.CertificateTemplates + subject(ProjectPermissionSub.CertificateTemplates, { + name: existingTemplate.name + }) ); const consolidatedData = { @@ -784,7 +789,9 @@ export const certificateTemplateV2ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiTemplateActions.Read, - ProjectPermissionSub.CertificateTemplates + subject(ProjectPermissionSub.CertificateTemplates, { + name: template.name + }) ); } @@ -815,16 +822,17 @@ export const certificateTemplateV2ServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Read, - ProjectPermissionSub.CertificateTemplates - ); - const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); if (!template) { throw new NotFoundError({ message: "Certificate template not found" }); } + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + subject(ProjectPermissionSub.CertificateTemplates, { + name: template.name + }) + ); return template; }; @@ -864,13 +872,18 @@ export const certificateTemplateV2ServiceFactory = ({ ProjectPermissionSub.CertificateTemplates ); - const templates = await certificateTemplateV2DAL.findByProjectId(projectId, { - offset, - limit, - search - }); + const processedRules = getProcessedPermissionRules( + permission, + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + const templates = await certificateTemplateV2DAL.findByProjectId( + projectId, + { offset, limit, search }, + processedRules + ); - const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }); + const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }, processedRules); return { templates, @@ -907,7 +920,9 @@ export const certificateTemplateV2ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionSub.CertificateTemplates + subject(ProjectPermissionSub.CertificateTemplates, { + name: template.name + }) ); const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); diff --git a/backend/src/services/certificate-v3/certificate-v3-fns.ts b/backend/src/services/certificate-v3/certificate-v3-fns.ts new file mode 100644 index 000000000..58a3a5d20 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-fns.ts @@ -0,0 +1,40 @@ +import RE2 from "re2"; + +import { BadRequestError } from "@app/lib/errors"; + +export const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const num = parseInt(value, 10); + + switch (unit) { + case "d": + return num; + case "h": + return Math.ceil(num / 24); + case "m": + return Math.ceil(num / (24 * 60)); + default: + throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` }); + } +}; + +export const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (profileRenewBeforeDays === undefined) { + return undefined; + } + + if (profileRenewBeforeDays >= certificateTtlInDays) { + // If renewBeforeDays >= TTL, renew 1 day before expiry + return Math.max(1, certificateTtlInDays - 1); + } + + return profileRenewBeforeDays; +}; 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 6c664e324..9d8d1aebb 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -11,7 +11,7 @@ import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-ac 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"; -import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; +import { CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; @@ -19,10 +19,11 @@ import { CertExtendedKeyUsageType, CertIncludeType, CertKeyUsageType, - CertSubjectAttributeType + CertSubjectAttributeType, + CertSubjectAlternativeNameType } from "@app/services/certificate-common/certificate-constants"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; @@ -40,26 +41,50 @@ vi.mock("../certificate-common/certificate-csr-utils", () => ({ describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; - const mockCertificateDAL: Pick = { + const mockCertificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" + > = { findOne: vi.fn(), findById: vi.fn(), updateById: vi.fn(), + create: vi.fn().mockResolvedValue({ + id: "new-cert-id", + serialNumber: "123456789", + friendlyName: "Test Certificate", + commonName: "test.example.com", + status: "ACTIVE" + }), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }), + find: vi.fn().mockResolvedValue([]) + }; + + const mockCertificateSecretDAL: Pick = { + findOne: vi.fn(), + create: vi.fn() + }; + + const mockCertificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "create" | "updateById" | "findById" | "transaction" | "findWithAssociatedCa" + > = { + findByIdWithAssociatedCa: vi.fn(), + create: vi.fn().mockResolvedValue({ id: "ca-123" }), + updateById: vi.fn().mockResolvedValue({ id: "ca-123" }), + findById: vi.fn().mockResolvedValue({ id: "ca-123" }), + findWithAssociatedCa: vi.fn().mockResolvedValue([]), transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const mockTx = {}; return callback(mockTx); }) }; - const mockCertificateSecretDAL: Pick = { - findOne: vi.fn() - }; - - const mockCertificateAuthorityDAL: Pick = { - findByIdWithAssociatedCa: vi.fn() - }; - - const mockCertificateProfileDAL: Pick = { - findByIdWithConfigs: vi.fn() + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn(), + findById: vi.fn() }; const mockCertificateTemplateV2Service: Pick< @@ -150,6 +175,33 @@ describe("CertificateV3Service", () => { }, pkiSyncQueue: { queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined) + }, + certificateBodyDAL: { + create: vi.fn().mockResolvedValue({ id: "body-123" }) + }, + kmsService: { + generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"), + encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))), + decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))), + createCipherPairWithDataKey: vi.fn().mockResolvedValue({ + cipherTextBlob: Buffer.from("encrypted"), + plainTextKey: Buffer.from("plainkey") + }) + }, + projectDAL: { + findOne: vi.fn().mockResolvedValue({ id: "project-123" }), + findById: vi.fn().mockResolvedValue({ id: "project-123" }), + updateById: vi.fn().mockResolvedValue({ id: "project-123" }), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) + } as any, + certificateIssuanceQueue: { + queueCertificateIssuance: vi.fn().mockResolvedValue(undefined) + }, + certificateRequestService: { + createCertificateRequest: vi.fn().mockResolvedValue({ id: "cert-req-123" }) } }); }); @@ -175,6 +227,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -238,6 +291,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: { id: "ca-123", projectId: "project-123", @@ -297,8 +352,13 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + const result = await service.issueCertificateFromProfile({ profileId, certificateRequest: mockCertificateRequest, @@ -319,6 +379,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -431,6 +492,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: { id: "ca-123", projectId: "project-123", @@ -473,8 +536,34 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + await service.issueCertificateFromProfile({ profileId, certificateRequest: camelCaseRequest, @@ -508,6 +597,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -561,6 +651,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -697,8 +788,13 @@ describe("CertificateV3Service", () => { }); vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); + const result = await service.signCertificateFromProfile({ profileId, csr: mockCSR, @@ -721,6 +817,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -757,7 +854,7 @@ describe("CertificateV3Service", () => { describe("orderCertificateFromProfile", () => { const mockCertificateOrder = { - altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + altNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }], validity: { ttl: "30d" }, commonName: "example.com", keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], @@ -766,173 +863,13 @@ describe("CertificateV3Service", () => { keyAlgorithm: "RSA_2048" }; - it("should create order successfully for API enrollment profile", async () => { - const profileId = "profile-123"; - const mockProfile = { - id: profileId, - projectId: "project-123", - enrollmentType: EnrollmentType.API, - caId: "ca-123", - certificateTemplateId: "template-123", - createdAt: new Date(), - updatedAt: new Date(), - slug: "test-profile-order", - description: "Test order profile", - estConfigId: null, - apiConfigId: null - }; - - const mockCA = { - id: "ca-123", - projectId: "project-123", - externalCa: undefined, - internalCa: { - id: "internal-ca-123", - parentCaId: null, - type: "ROOT", - friendlyName: "Test CA", - organization: "Test Org", - ou: "Test OU", - country: "US", - province: "CA", - locality: "SF", - commonName: "Test CA", - dn: "CN=Test CA", - serialNumber: "123", - maxPathLength: null, - keyAlgorithm: "RSA_2048", - notBefore: undefined, - notAfter: undefined, - activeCaCertId: "cert-123", - caId: "ca-123" - }, - name: "Test CA", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - enableDirectIssuance: true - }; - - const mockTemplate = { - id: "template-123", - name: "Test Order Template", - createdAt: new Date(), - updatedAt: new Date(), - projectId: "project-123", - description: "Test template for ordering certificates", - signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, - keyAlgorithm: { defaultKeyType: "RSA_2048" }, - attributes: [ - { - type: CertSubjectAttributeType.COMMON_NAME, - include: CertIncludeType.OPTIONAL, - value: ["example.com"] - } - ], - subject: undefined, - sans: undefined, - keyUsages: undefined, - extendedKeyUsages: undefined, - algorithms: undefined, - validity: undefined - }; - - const mockCertificateResult = { - certificate: "cert", - certificateChain: "chain", - issuingCaCertificate: "issuing-ca", - privateKey: "key", - serialNumber: "123456", - ca: { - id: "ca-123", - projectId: "project-123", - name: "Test CA", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - enableDirectIssuance: true, - externalCa: undefined, - internalCa: { - id: "internal-ca-123", - parentCaId: null, - type: "ROOT", - friendlyName: "Test CA", - organization: "Test Org", - ou: "Test OU", - country: "US", - province: "CA", - locality: "SF", - commonName: "Test CA", - dn: "CN=Test CA", - serialNumber: "123", - maxPathLength: null, - keyAlgorithm: "RSA_2048", - notBefore: null, - notAfter: null, - activeCaCertId: "cert-123", - caId: "ca-123" - } - } - }; - - const mockCertRecord = { - id: "cert-123", - serialNumber: "123456", - status: "ACTIVE", - createdAt: new Date(), - updatedAt: new Date(), - projectId: "project-123", - commonName: "example.com", - friendlyName: "Test Order Cert", - notBefore: new Date(), - notAfter: new Date(), - caId: "ca-123", - certificateTemplateId: "template-123", - revokedAt: null, - altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), - caCertId: null, - keyUsages: ["DIGITAL_SIGNATURE"], - extendedKeyUsages: ["SERVER_AUTH"], - revocationReason: null, - pkiSubscriberId: null, - profileId: null - }; - - vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); - vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ - isValid: true, - errors: [], - warnings: [] - }); - vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); - vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); - vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); - vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); - vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); - - const result = await service.orderCertificateFromProfile({ - profileId, - certificateOrder: mockCertificateOrder, - ...mockActor - }); - - expect(result).toHaveProperty("orderId"); - expect(result).toHaveProperty("status", "valid"); - expect(result).toHaveProperty("certificate"); - expect(result.subjectAlternativeNames).toHaveLength(1); - expect(result.subjectAlternativeNames[0]).toEqual({ - type: ACMESANType.DNS, - value: "example.com", - status: CertificateOrderStatus.VALID - }); - }); - it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { const profileId = "profile-123"; const mockProfile = { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -971,6 +908,7 @@ describe("CertificateV3Service", () => { caId: "ca-1", certificateTemplateId: "template-1", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, createdAt: new Date(), updatedAt: new Date(), description: "Test profile for algorithm compatibility", @@ -1061,6 +999,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: rsaCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1107,6 +1047,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - RSA CA is compatible with RSA signature algorithms await expect( @@ -1193,6 +1158,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: ecCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1239,6 +1206,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - EC CA is compatible with ECDSA signature algorithms await expect( @@ -1325,6 +1317,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: rsa8192Ca as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1371,6 +1365,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - dynamic check supports new RSA key sizes await expect( @@ -1457,6 +1476,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "ca-cert", privateKey: "key", serialNumber: "123456", + certificateId: "cert-1", + commonName: "test.example.com", ca: newEcCa as any }); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ @@ -1503,6 +1524,31 @@ describe("CertificateV3Service", () => { pkiSubscriberId: null, profileId: null }); + vi.mocked(mockCertificateDAL.findById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + return callback(undefined as any); + }); // Should not throw - dynamic check supports new EC curves await expect( @@ -1552,6 +1598,7 @@ describe("CertificateV3Service", () => { id: "profile-123", projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1635,8 +1682,9 @@ describe("CertificateV3Service", () => { }); it("should successfully renew eligible certificate", async () => { - // Mock the initial findById call - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateDAL.findById) + .mockResolvedValueOnce(mockOriginalCert) + .mockResolvedValueOnce({ ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }); vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); @@ -1652,6 +1700,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); @@ -1733,9 +1783,9 @@ describe("CertificateV3Service", () => { }); }); - it("should reject renewal if certificate is not from a profile", async () => { - const certWithoutProfile = { ...mockOriginalCert, profileId: null }; - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + it("should reject renewal if certificate has no profile and no CA", async () => { + const certWithoutProfileAndCA = { ...mockOriginalCert, profileId: null, caId: null }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfileAndCA); // Set up transaction mock to properly handle errors vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { @@ -1969,6 +2019,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); @@ -2008,6 +2060,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2084,6 +2137,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2129,6 +2183,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2172,6 +2227,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 7b6538ab2..fd46f2c5f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,8 +1,9 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; import { randomUUID } from "crypto"; import RE2 from "re2"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, TCertificates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCertificateActions, @@ -10,13 +11,15 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { CertExtendedKeyUsage, - CertificateOrderStatus, CertKeyAlgorithm, CertKeyType, CertKeyUsage, @@ -28,12 +31,27 @@ import { TCertificateAuthorityWithAssociatedCa } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + createDistinguishedName, + createSerialNumber, + keyAlgorithmToAlgCfg, + signatureAlgorithmToAlgCfg +} from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "../certificate-common/certificate-constants"; import { extractAlgorithmsFromCSR, extractCertificateRequestFromCSR @@ -42,14 +60,16 @@ import { bufferToString, buildCertificateSubjectFromTemplate, buildSubjectAlternativeNamesFromTemplate, - convertExtendedKeyUsageArrayFromLegacy, convertExtendedKeyUsageArrayToLegacy, - convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, - normalizeDateForApi + normalizeDateForApi, + removeRootCaFromChain } from "../certificate-common/certificate-utils"; +import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "../certificate-request/certificate-request-types"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types"; import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils"; @@ -67,10 +87,17 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; - certificateSecretDAL: Pick; - certificateAuthorityDAL: Pick; - certificateProfileDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById" + >; + certificateProfileDAL: Pick; acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, @@ -84,6 +111,16 @@ type TCertificateV3ServiceFactoryDep = { >; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + kmsService: Pick< + TKmsServiceFactory, + "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey" + >; + projectDAL: TProjectDALFactory; + certificateIssuanceQueue: Pick< + import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory, + "queueCertificateIssuance" + >; + certificateRequestService: Pick; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -134,7 +171,9 @@ const validateProfileAndPermissions = async ( ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.IssueCert, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); return profile; @@ -271,16 +310,69 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s return bufferToString(certData as unknown as Buffer); }; -const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { +const parseKeyUsages = (keyUsages: unknown): CertKeyUsageType[] => { if (!keyUsages) return []; - if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[]; - return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage); + + const validKeyUsages = [...Object.values(CertKeyUsageType), ...Object.values(CertKeyUsage)] as string[]; + + const normalize = (usage: string): CertKeyUsageType | null => { + if (validKeyUsages.includes(usage)) { + return mapLegacyKeyUsageToStandard(usage as CertKeyUsageType); + } + return null; + }; + + let raw: string[]; + + if (Array.isArray(keyUsages)) { + raw = keyUsages.filter((u): u is string => typeof u === "string"); + } else if (typeof keyUsages === "string") { + raw = keyUsages.split(",").map((u) => u.trim()); + } else { + return []; + } + + return raw.map((u) => normalize(u)).filter((u): u is CertKeyUsageType => u !== null); }; -const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { +const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsageType[] => { if (!extendedKeyUsages) return []; - if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[]; - return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage); + + const validExtendedKeyUsages = [ + ...Object.values(CertExtendedKeyUsageType), + ...Object.values(CertExtendedKeyUsage) + ] as string[]; + + const normalize = (usage: string): CertExtendedKeyUsageType | null => { + if (validExtendedKeyUsages.includes(usage)) { + return mapLegacyExtendedKeyUsageToStandard(usage as CertExtendedKeyUsageType); + } + return null; + }; + + let raw: string[]; + + if (Array.isArray(extendedKeyUsages)) { + raw = extendedKeyUsages.filter((u): u is string => typeof u === "string"); + } else if (typeof extendedKeyUsages === "string") { + raw = extendedKeyUsages.split(",").map((u) => u.trim()); + } else { + return []; + } + + return raw.map((u) => normalize(u)).filter((u): u is CertExtendedKeyUsageType => u !== null); +}; + +const convertEnumsToStringArray = (enumArray: T[]): string[] => { + return enumArray.map((item) => item as string); +}; + +const combineKeyUsageFlags = (keyUsages: string[]): number => { + return keyUsages.reduce((acc: number, usage) => { + const flag = x509.KeyUsageFlags[usage as keyof typeof x509.KeyUsageFlags]; + // eslint-disable-next-line no-bitwise + return typeof flag === "number" ? acc | flag : acc; + }, 0); }; const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => { @@ -328,6 +420,154 @@ const parseTtlToDays = (ttl: string): number => { } }; +const generateSelfSignedCertificate = async ({ + certificateRequest, + template, + effectiveSignatureAlgorithm, + effectiveKeyAlgorithm +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + effectiveSignatureAlgorithm: CertSignatureAlgorithm; + effectiveKeyAlgorithm: CertKeyAlgorithm; +}): Promise<{ + certificate: Buffer; + privateKey: Buffer; + serialNumber: string; + notBefore: Date; + notAfter: Date; + certificateSubject: Record; + subjectAlternativeNames: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; +}> => { + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template?.sans + ); + + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const keyPair = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + const signatureAlgorithmConfig = signatureAlgorithmToAlgCfg(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + const notBeforeDate = certificateRequest.notBefore ? new Date(certificateRequest.notBefore) : new Date(); + + let notAfterDate: Date; + if (certificateRequest.notAfter) { + notAfterDate = new Date(certificateRequest.notAfter); + } else if (certificateRequest.validity.ttl) { + notAfterDate = new Date(new Date().getTime() + ms(certificateRequest.validity.ttl)); + } else { + throw new BadRequestError({ + message: "Either notAfter date or TTL must be provided for certificate validity" + }); + } + + const serialNumber = createSerialNumber(); + const dn = createDistinguishedName({ + commonName: certificateSubject.common_name, + organization: certificateSubject.organization, + ou: certificateSubject.organizational_unit, + country: certificateSubject.country, + province: certificateSubject.state_or_province_name, + locality: certificateSubject.locality_name + }); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: signatureAlgorithmConfig, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, false), + ...(certificateRequest.keyUsages?.length + ? [ + new x509.KeyUsagesExtension( + combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []), + false + ) + ] + : []), + ...(certificateRequest.extendedKeyUsages?.length + ? [ + new x509.ExtendedKeyUsageExtension( + (convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || []).map( + (eku) => x509.ExtendedKeyUsage[eku] + ), + false + ) + ] + : []), + ...(subjectAlternativeNames + ? [ + new x509.SubjectAlternativeNameExtension( + certificateRequest.altNames?.map((san) => { + switch (san.type) { + case CertSubjectAlternativeNameType.DNS_NAME: + return { type: "dns" as const, value: san.value }; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return { type: "ip" as const, value: san.value }; + case CertSubjectAlternativeNameType.EMAIL: + return { type: "email" as const, value: san.value }; + case CertSubjectAlternativeNameType.URI: + return { type: "url" as const, value: san.value }; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + }) || [], + false + ) + ] + : []) + ] + }); + + const certificatePem = cert.toString("pem"); + const privateKeyObj = crypto.nativeCrypto.KeyObject.from(keyPair.privateKey); + const privateKeyPem = privateKeyObj.export({ format: "pem", type: "pkcs8" }) as string; + + return { + certificate: Buffer.from(certificatePem), + privateKey: Buffer.from(privateKeyPem), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + certificateSubject, + subjectAlternativeNames: certificateRequest.altNames || [] + }; +}; + const calculateFinalRenewBeforeDays = ( profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, ttl: string, @@ -347,8 +587,279 @@ const calculateFinalRenewBeforeDays = ( return isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined; }; +const getEffectiveAlgorithms = ( + requestSignatureAlgorithm?: CertSignatureAlgorithm, + requestKeyAlgorithm?: CertKeyAlgorithm, + originalSignatureAlgorithm?: CertSignatureAlgorithm, + originalKeyAlgorithm?: CertKeyAlgorithm +) => { + return { + signatureAlgorithm: requestSignatureAlgorithm || originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256, + keyAlgorithm: requestKeyAlgorithm || originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048 + }; +}; + +const createSelfSignedCertificateRecord = async ({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal = false +}: { + selfSignedResult: Awaited>; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + }; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + certificateDAL: Pick; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + originalCert?.commonName || + ""; + + const altNamesList = selfSignedResult.subjectAlternativeNames.map((san) => san.value).join(","); + + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const baseRecord = { + serialNumber: selfSignedResult.serialNumber, + friendlyName: originalCert?.friendlyName || subjectCommonName, + commonName: subjectCommonName, + altNames: altNamesList, + status: CertStatus.ACTIVE, + notBefore: selfSignedResult.notBefore, + notAfter: selfSignedResult.notAfter, + projectId, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + profileId: profile?.id || null + }; + + const renewalRecord = + isRenewal && originalCert + ? { + renewedFromCertificateId: originalCert.id + } + : {}; + + return certificateDAL.create( + { + ...baseRecord, + ...renewalRecord + }, + tx + ); +}; + +const createEncryptedCertificateData = async ({ + certificateId, + certificate, + privateKey, + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx +}: { + certificateId: string; + certificate: Buffer; + privateKey: Buffer; + projectId: string; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; +}) => { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ kmsId: certificateManagerKeyId }); + + const encryptedCertificate = await kmsEncryptor({ + plainText: certificate + }); + + await certificateBodyDAL.create( + { + certId: certificateId, + encryptedCertificate: encryptedCertificate.cipherTextBlob + }, + tx + ); + + const encryptedPrivateKey = await kmsEncryptor({ + plainText: privateKey + }); + + await certificateSecretDAL.create( + { + certId: certificateId, + encryptedPrivateKey: encryptedPrivateKey.cipherTextBlob + }, + tx + ); +}; + +const processSelfSignedCertificate = async ({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal = false +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + effectiveAlgorithms: { + signatureAlgorithm: CertSignatureAlgorithm; + keyAlgorithm: CertKeyAlgorithm; + }; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const selfSignedResult = await generateSelfSignedCertificate({ + certificateRequest, + template, + effectiveSignatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + effectiveKeyAlgorithm: effectiveAlgorithms.keyAlgorithm + }); + + const certificateData = await createSelfSignedCertificateRecord({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal + }); + + await certificateDAL.updateById( + certificateData.id, + { + signatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + keyAlgorithm: effectiveAlgorithms.keyAlgorithm + }, + tx + ); + + await createEncryptedCertificateData({ + certificateId: certificateData.id, + certificate: selfSignedResult.certificate, + privateKey: selfSignedResult.privateKey, + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + + return { + selfSignedResult, + certificateData + }; +}; + +const detectSanType = (value: string): { type: CertSubjectAlternativeNameType; value: string } => { + const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(value); + const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(value); + + if (isIpv4 || isIpv6) { + return { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + value + }; + } + + if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(value)) { + return { + type: CertSubjectAlternativeNameType.EMAIL, + value + }; + } + + if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(value)) { + return { + type: CertSubjectAlternativeNameType.URI, + value + }; + } + + return { + type: CertSubjectAlternativeNameType.DNS_NAME, + value + }; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, + certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, @@ -358,7 +869,11 @@ export const certificateV3ServiceFactory = ({ permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL, + certificateIssuanceQueue, + certificateRequestService }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -366,7 +881,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + removeRootsFromChain }: TIssueCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -414,15 +930,6 @@ export const certificateV3ServiceFactory = ({ }); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); - } - - validateCaSupport(ca, "direct certificate issuance"); - - validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; @@ -438,14 +945,125 @@ export const certificateV3ServiceFactory = ({ }); } - const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( { subjectAlternativeNames: certificateRequest.altNames }, - template.sans + template?.sans ); - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = - await internalCaService.issueCertFromCa({ + const issuerType = profile?.issuerType || (profile?.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + if (issuerType === IssuerType.SELF_SIGNED) { + const result = await certificateDAL.transaction(async (tx) => { + const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + const selfSignedResult = await processSelfSignedCertificate({ + certificateRequest, + template, + profile, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + profileId: profile.id, + commonName: certificateRequest.commonName, + altNames: certificateRequest.altNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages), + notBefore: certificateRequest.notBefore, + notAfter: certificateRequest.notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: selfSignedResult.certificateData.id + }); + + return { ...selfSignedResult, certificateRequestId: certRequestResult.id }; + }); + + const { selfSignedResult, certificateData, certificateRequestId } = result; + + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + "Self-signed Certificate"; + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + selfSignedResult.notAfter + ); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById(certificateData.id, { + renewBeforeDays: finalRenewBeforeDays + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const canReadPrivateKey = permission.can( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const privateKeyForResponse = canReadPrivateKey ? selfSignedResult.privateKey.toString("utf8") : undefined; + + return { + certificate: selfSignedResult.certificate.toString("utf8"), + issuingCaCertificate: "", + certificateChain: selfSignedResult.certificate.toString("utf8"), + privateKey: privateKeyForResponse, + serialNumber: selfSignedResult.serialNumber, + certificateId: certificateData.id, + certificateRequestId, + projectId: profile.projectId, + profileName: profile.slug, + commonName: subjectCommonName + }; + } + + if (!profile.caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + validateAlgorithmCompatibility(ca, template); + + const { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber, + cert, + certificateRequestId + } = await certificateDAL.transaction(async (tx) => { + const certResult = await internalCaService.issueCertFromCa({ caId: ca.id, friendlyName: certificateSubject.common_name || "Certificate", commonName: certificateSubject.common_name || "", @@ -461,32 +1079,81 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - isFromProfile: true + isFromProfile: true, + tx }); - const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); - if (!cert) { - throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + const certificateRecord = await certificateDAL.findById(certResult.certificateId, tx); + if (!certificateRecord) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + new Date(certificateRecord.notAfter) + ); + + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(certificateRecord.id, updateData, tx); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + caId: ca.id, + profileId: profile.id, + commonName: certificateRequest.commonName, + altNames: certificateRequest.altNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages), + notBefore: certificateRequest.notBefore, + notAfter: certificateRequest.notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: certResult.certificateId + }); + + return { ...certResult, cert: certificateRecord, certificateRequestId: certRequestResult.id }; + }); + + let finalCertificateChain = bufferToString(certificateChain); + if (removeRootsFromChain) { + finalCertificateChain = removeRootCaFromChain(finalCertificateChain); } - const finalRenewBeforeDays = calculateFinalRenewBeforeDays( - profile, - certificateRequest.validity.ttl, - new Date(cert.notAfter) + // Check if user has permission to read private key + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + const canReadPrivateKey = permission.can( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates ); - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); + const privateKeyForResponse = canReadPrivateKey ? bufferToString(privateKey) : undefined; return { certificate: bufferToString(certificate), issuingCaCertificate: bufferToString(issuingCaCertificate), - certificateChain: bufferToString(certificateChain), - privateKey: bufferToString(privateKey), + certificateChain: finalCertificateChain, + privateKey: privateKeyForResponse, serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -503,7 +1170,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - enrollmentType + enrollmentType, + removeRootsFromChain }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -517,6 +1185,12 @@ export const certificateV3ServiceFactory = ({ enrollmentType ); + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for CSR signing" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -563,34 +1237,69 @@ export const certificateV3ServiceFactory = ({ const effectiveSignatureAlgorithm = extractedSignatureAlgorithm; const effectiveKeyAlgorithm = extractedKeyAlgorithm; - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.signCertFromCa({ - isInternal: true, - caId: ca.id, - csr, - ttl: validity.ttl, - altNames: undefined, - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter), - signatureAlgorithm: effectiveSignatureAlgorithm, - keyAlgorithm: effectiveKeyAlgorithm, - isFromProfile: true + const { certificate, certificateChain, issuingCaCertificate, serialNumber, cert, certificateRequestId } = + await certificateDAL.transaction(async (tx) => { + const certResult = await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true, + tx + }); + + const signedCertRecord = await certificateDAL.findById(certResult.certificateId, tx); + if (!signedCertRecord) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + validity.ttl, + new Date(signedCertRecord.notAfter) + ); + + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(signedCertRecord.id, updateData, tx); + + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: profile.projectId, + tx, + caId: ca.id, + profileId: profile.id, + csr, + commonName: mappedCertificateRequest.commonName, + altNames: mappedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","), + keyUsages: convertKeyUsageArrayToLegacy(mappedCertificateRequest.keyUsages), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(mappedCertificateRequest.extendedKeyUsages), + notBefore, + notAfter, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: effectiveSignatureAlgorithm, + status: CertificateRequestStatus.ISSUED, + certificateId: certResult.certificateId + }); + + return { ...certResult, cert: signedCertRecord, certificateRequestId: certRequestResult.id }; }); - const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); - if (!cert) { - throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); - } - - const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); - - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); - const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); - const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + if (removeRootsFromChain) { + certificateChainString = removeRootCaFromChain(certificateChainString); + } return { certificate: certificateString, @@ -598,6 +1307,7 @@ export const certificateV3ServiceFactory = ({ certificateChain: certificateChainString, serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -624,22 +1334,41 @@ export const certificateV3ServiceFactory = ({ EnrollmentType.API ); - const certificateRequest = { - commonName: certificateOrder.commonName, - keyUsages: certificateOrder.keyUsages, - extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, - value: san.value - })), - validity: certificateOrder.validity, - notBefore: certificateOrder.notBefore, - notAfter: certificateOrder.notAfter, - signatureAlgorithm: certificateOrder.signatureAlgorithm, - keyAlgorithm: certificateOrder.keyAlgorithm - }; + let certificateRequest: TCertificateRequest; + let extractedKeyAlgorithm: string | undefined; + let extractedSignatureAlgorithm: string | undefined; + + if (certificateOrder.csr) { + certificateRequest = extractCertificateRequestFromCSR(certificateOrder.csr); + const algorithms = extractAlgorithmsFromCSR(certificateOrder.csr); + extractedKeyAlgorithm = algorithms.keyAlgorithm; + extractedSignatureAlgorithm = algorithms.signatureAlgorithm; + certificateRequest.validity = certificateOrder.validity; + if (certificateOrder.notBefore && certificateOrder.notAfter) { + certificateRequest.notBefore = certificateOrder.notBefore; + certificateRequest.notAfter = certificateOrder.notAfter; + } + } else { + certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames, + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + } const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + + if (certificateOrder.csr) { + mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm; + mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm; + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( profile.certificateTemplateId, mappedCertificateRequest @@ -651,6 +1380,12 @@ export const certificateV3ServiceFactory = ({ }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for certificate ordering" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -659,41 +1394,61 @@ export const certificateV3ServiceFactory = ({ const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; if (caType === CaType.INTERNAL) { - const certificateResult = await issueCertificateFromProfile({ - profileId, - certificateRequest, + throw new BadRequestError({ + message: "Certificate ordering is not supported for the specified CA type" + }); + } + + if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) { + const orderId = randomUUID(); + + const certRequest = await certificateRequestService.createCertificateRequest({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + projectId: profile.projectId, + caId: ca.id, + profileId: profile.id, + commonName: certificateOrder.commonName || "", + keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [], + extendedKeyUsages: certificateOrder.extendedKeyUsages + ? convertEnumsToStringArray(certificateOrder.extendedKeyUsages) + : [], + keyAlgorithm: certificateOrder.keyAlgorithm || "", + signatureAlgorithm: certificateOrder.signatureAlgorithm || "", + altNames: certificateOrder.altNames?.map((san) => san.value).join(",") || "", + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + status: CertificateRequestStatus.PENDING }); - const orderId = randomUUID(); + await certificateIssuanceQueue.queueCertificateIssuance({ + certificateId: orderId, + profileId: profile.id, + caId: profile.caId || "", + ttl: certificateOrder.validity?.ttl || "1y", + signatureAlgorithm: certificateOrder.signatureAlgorithm || "", + keyAlgorithm: certificateRequest.keyAlgorithm || "", + commonName: certificateRequest.commonName || "", + altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value) || [], + keyUsages: certificateRequest.keyUsages ? convertEnumsToStringArray(certificateRequest.keyUsages) : [], + extendedKeyUsages: certificateRequest.extendedKeyUsages + ? convertEnumsToStringArray(certificateRequest.extendedKeyUsages) + : [], + certificateRequestId: certRequest.id, + csr: certificateOrder.csr + }); return { - orderId, - status: CertificateOrderStatus.VALID, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type, - value: san.value, - status: CertificateOrderStatus.VALID - })), - authorizations: [], - finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`, - certificate: certificateResult.certificate, - projectId: certificateResult.projectId, - profileName: certificateResult.profileName + certificateRequestId: certRequest.id, + projectId: certRequest.projectId, + profileName: profile.slug }; } - if (caType === CaType.ACME) { - throw new BadRequestError({ - message: "ACME certificate ordering via profiles is not yet implemented." - }); - } - throw new BadRequestError({ - message: `Certificate ordering is not supported for CA type: ${caType}` + message: "Certificate ordering is not supported for the specified CA type" }); }; @@ -703,8 +1458,11 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - internal = false - }: TRenewCertificateDTO & { internal?: boolean }): Promise => { + internal = false, + removeRootsFromChain + }: Omit & { + internal?: boolean; + }): Promise => { const renewalResult = await certificateDAL.transaction(async (tx) => { const originalCert = await certificateDAL.findById(certificateId, tx); if (!originalCert) { @@ -717,25 +1475,45 @@ export const certificateV3ServiceFactory = ({ }); } - const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; - const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; + // Validate and cast algorithms with fallbacks + let originalSignatureAlgorithm = Object.values(CertSignatureAlgorithm).includes( + originalCert.signatureAlgorithm as CertSignatureAlgorithm + ) + ? (originalCert.signatureAlgorithm as CertSignatureAlgorithm) + : CertSignatureAlgorithm.RSA_SHA256; + let originalKeyAlgorithm = Object.values(CertKeyAlgorithm).includes(originalCert.keyAlgorithm as CertKeyAlgorithm) + ? (originalCert.keyAlgorithm as CertKeyAlgorithm) + : CertKeyAlgorithm.RSA_2048; + // For external CA certificates without stored algorithm info, extract from certificate if (!originalSignatureAlgorithm || !originalKeyAlgorithm) { - throw new BadRequestError({ - message: - "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." - }); + const isExternalCA = originalCert.caId && !originalCert.caId.startsWith("internal"); + + if (isExternalCA) { + // For external CA certificates, we can extract algorithm info from the cert or use defaults + originalSignatureAlgorithm = originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256; + originalKeyAlgorithm = originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048; + } else { + throw new BadRequestError({ + message: + "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." + }); + } } - const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } + let profile = null; + if (originalCert.profileId) { + profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } - if (profile.enrollmentType !== EnrollmentType.API) { - throw new ForbiddenRequestError({ - message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" - }); + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for renewal: Only certificates issued from an API enrollment profile can be renewed through this endpoint" + }); + } } const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx); @@ -747,48 +1525,67 @@ export const certificateV3ServiceFactory = ({ } if (!internal) { + const projectId = profile?.projectId || originalCert.projectId; const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: profile.projectId, + projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateProfileActions.IssueCert, - ProjectPermissionSub.CertificateProfiles - ); + if (profile) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + subject(ProjectPermissionSub.CertificateProfiles, { slug: profile.slug }) + ); + } } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); + const issuerType = profile?.issuerType || (originalCert.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + let ca; + if (issuerType === IssuerType.CA) { + const caId = profile?.caId || originalCert.caId; + if (!caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const eligibilityCheck = validateRenewalEligibility(originalCert, ca); + if (!eligibilityCheck.isEligible) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + throw new BadRequestError({ + message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType === CaType.INTERNAL) { + validateCaSupport(ca, "direct certificate issuance"); + } } - const eligibilityCheck = validateRenewalEligibility(originalCert, ca); - if (!eligibilityCheck.isEligible) { - await certificateDAL.updateById(originalCert.id, { - renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` - }); - throw new BadRequestError({ - message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` - }); - } + const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; + const template = templateId + ? await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + internal + }) + : null; - validateCaSupport(ca, "direct certificate issuance"); - - const template = await certificateTemplateV2Service.getTemplateV2ById({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - templateId: profile.certificateTemplateId, - internal - }); - - if (!template) { + if (!template && profile) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); } @@ -799,42 +1596,10 @@ export const certificateV3ServiceFactory = ({ const certificateRequest = { commonName: originalCert.commonName || undefined, - keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), - extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( - parseExtendedKeyUsages(originalCert.extendedKeyUsages) - ), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), subjectAlternativeNames: originalCert.altNames - ? originalCert.altNames.split(",").map((san) => { - const trimmed = san.trim(); - - const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed); - const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed); - if (isIpv4 || isIpv6) { - return { - type: CertSubjectAlternativeNameType.IP_ADDRESS, - value: trimmed - }; - } - - if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) { - return { - type: CertSubjectAlternativeNameType.EMAIL, - value: trimmed - }; - } - - if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) { - return { - type: CertSubjectAlternativeNameType.URI, - value: trimmed - }; - } - - return { - type: CertSubjectAlternativeNameType.DNS_NAME, - value: trimmed - }; - }) + ? originalCert.altNames.split(",").map((san) => detectSanType(san.trim())) : [], validity: { ttl @@ -843,10 +1608,13 @@ export const certificateV3ServiceFactory = ({ keyAlgorithm: originalCert.keyAlgorithm || undefined }; - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - profile.certificateTemplateId, - certificateRequest - ); + let validationResult: { isValid: boolean; errors: string[] } = { isValid: true, errors: [] }; + if (profile?.certificateTemplateId) { + validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + certificateRequest + ); + } if (!validationResult.isValid) { await certificateDAL.updateById(originalCert.id, { @@ -858,48 +1626,139 @@ export const certificateV3ServiceFactory = ({ }); } - validateAlgorithmCompatibility(ca, template); const notBefore = new Date(); const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); - const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter); + const finalRenewBeforeDays = profile ? calculateFinalRenewBeforeDays(profile, ttl, notAfter) : undefined; - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.issueCertFromCa({ - caId: ca.id, - friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", - commonName: originalCert.commonName || "", - altNames: originalCert.altNames || "", - ttl, - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter), - keyUsages: parseKeyUsages(originalCert.keyUsages), - extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), - signatureAlgorithm: originalSignatureAlgorithm, - keyAlgorithm: originalKeyAlgorithm, - isFromProfile: true, - actor, - actorId, - actorAuthMethod, - actorOrgId, - internal: true, - tx + let certificate: string; + let certificateChain: string; + let issuingCaCertificate: string; + let serialNumber: string; + let newCert: TCertificates; + + if (issuerType === IssuerType.CA) { + // CA-signed certificate renewal + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + + // Only validate algorithm compatibility for internal CAs + if (caType === CaType.INTERNAL) { + validateAlgorithmCompatibility(ca, { + algorithms: template?.algorithms + } as { algorithms?: { signature?: string[] } }); + } + + if (caType === CaType.INTERNAL) { + // Internal CA renewal - existing logic + const caResult = await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", + commonName: originalCert.commonName || "", + altNames: originalCert.altNames || "", + ttl, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + keyUsages: convertKeyUsageArrayToLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + isFromProfile: true, + actor, + actorId, + actorAuthMethod, + actorOrgId, + internal: true, + tx + }); + + certificate = caResult.certificate; + certificateChain = caResult.certificateChain; + issuingCaCertificate = caResult.issuingCaCertificate; + serialNumber = caResult.serialNumber; + + const foundCert = await certificateDAL.findById(caResult.certificateId, tx); + if (!foundCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + newCert = foundCert; + } else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) { + // External CA renewal - mark for async processing outside transaction + return { + isExternalCA: true, + ca, + profile, + originalCert, + originalSignatureAlgorithm, + originalKeyAlgorithm, + ttl + }; + } else { + throw new BadRequestError({ + message: `CA type ${String(caType)} does not support certificate renewal` + }); + } + } else { + // Self-signed certificate renewal + const effectiveAlgorithms = getEffectiveAlgorithms( + undefined, + undefined, + originalSignatureAlgorithm, + originalKeyAlgorithm + ); + + const selfSignedRenewalResult = await processSelfSignedCertificate({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal: true }); - const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + certificate = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); + certificateChain = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); // Self-signed has no chain + issuingCaCertificate = ""; // No issuing CA for self-signed + serialNumber = selfSignedRenewalResult.selfSignedResult.serialNumber; + newCert = selfSignedRenewalResult.certificateData; + } + if (!newCert) { throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } - await certificateDAL.updateById( - newCert.id, - { - profileId: originalCert.profileId, - renewBeforeDays: finalRenewBeforeDays, + // For self-signed certificates, we already set the renewal data during creation + // For CA-signed certificates, we need to set it now + if (issuerType === IssuerType.CA) { + const renewalUpdateData: { + profileId: string | null; + renewedFromCertificateId: string; + renewBeforeDays?: number; + } = { + profileId: originalCert.profileId || null, renewedFromCertificateId: originalCert.id - }, - tx - ); + }; + + if (finalRenewBeforeDays !== undefined) { + renewalUpdateData.renewBeforeDays = finalRenewBeforeDays; + } + + await certificateDAL.updateById(newCert.id, renewalUpdateData, tx); + } else if (finalRenewBeforeDays !== undefined) { + // For self-signed certificates, just update the renewBeforeDays if needed + await certificateDAL.updateById(newCert.id, { renewBeforeDays: finalRenewBeforeDays }, tx); + } await certificateDAL.updateById( originalCert.id, @@ -912,6 +1771,28 @@ export const certificateV3ServiceFactory = ({ await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx); + const certRequestResult = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: originalCert.projectId, + tx, + caId: ca?.id || originalCert.caId || undefined, + profileId: originalCert.profileId || undefined, + commonName: originalCert.commonName || undefined, + altNames: originalCert.altNames || undefined, + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + notBefore: new Date(newCert.notBefore), + notAfter: new Date(newCert.notAfter), + keyAlgorithm: originalKeyAlgorithm, + signatureAlgorithm: originalSignatureAlgorithm, + metadata: `Renewed from certificate ID: ${originalCert.id}`, + status: CertificateRequestStatus.ISSUED, + certificateId: newCert.id + }); + return { certificate, certificateChain, @@ -919,24 +1800,95 @@ export const certificateV3ServiceFactory = ({ serialNumber, newCert, originalCert, - profile + profile, + certRequestResult }; }); + let certificateRequestId: string = renewalResult.certRequestResult?.id || ""; + + // Handle external CA renewals separately + if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) { + const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult; + + const renewalOrderId = randomUUID(); + const altNamesArray = originalCert.altNames + ? originalCert.altNames.split(",").map((san: string) => san.trim()) + : []; + + const certificateRequest = await certificateRequestService.createCertificateRequest({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId: originalCert.projectId, + profileId: profile?.id, + caId: ca.id, + commonName: originalCert.commonName || undefined, + altNames: originalCert.altNames || undefined, + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + keyAlgorithm: originalKeyAlgorithm, + signatureAlgorithm: originalSignatureAlgorithm, + metadata: `Renewed from certificate ID: ${originalCert.id}`, + status: CertificateRequestStatus.PENDING + }); + + certificateRequestId = certificateRequest.id; + + await certificateIssuanceQueue.queueCertificateIssuance({ + certificateId: renewalOrderId, + profileId: profile?.id || "", + caId: ca.id, + commonName: originalCert.commonName || "", + altNames: altNamesArray, + ttl, + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + keyUsages: convertEnumsToStringArray(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)), + isRenewal: true, + originalCertificateId: certificateId, + certificateRequestId: certificateRequest.id + }); + + return { + certificate: "", // External CA renewal is async + certificateChain: "", + issuingCaCertificate: "", + serialNumber: "", + certificateId: renewalOrderId, + certificateRequestId: certificateRequest.id, + projectId: originalCert.projectId, + profileName: profile?.slug || "External CA Profile", + commonName: originalCert.commonName || "" + }; + } + + // Type check to ensure we have internal CA renewal result + if ("isExternalCA" in renewalResult) { + throw new BadRequestError({ message: "External CA renewals should be handled asynchronously" }); + } + await triggerAutoSyncForCertificate(renewalResult.newCert.id, { certificateSyncDAL, pkiSyncDAL, pkiSyncQueue }); + let finalCertificateChain = renewalResult.certificateChain; + if (removeRootsFromChain) { + finalCertificateChain = removeRootCaFromChain(finalCertificateChain); + } return { certificate: renewalResult.certificate, issuingCaCertificate: renewalResult.issuingCaCertificate, - certificateChain: renewalResult.certificateChain, + certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, - projectId: renewalResult.profile.projectId, - profileName: renewalResult.profile.slug, + certificateRequestId, + projectId: renewalResult.originalCert.projectId, + profileName: renewalResult.profile?.slug || "Self-signed Certificate", commonName: renewalResult.originalCert.commonName || "" }; }; @@ -965,7 +1917,11 @@ export const certificateV3ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Edit, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: certificate.commonName, + altNames: certificate.altNames ?? undefined, + serialNumber: certificate.serialNumber + }) ); if (!certificate.profileId) { @@ -1068,7 +2024,11 @@ export const certificateV3ServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Edit, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: certificate.commonName, + altNames: certificate.altNames ?? undefined, + serialNumber: certificate.serialNumber + }) ); if (!certificate.profileId) { diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 8a2cf70f7..50d710406 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -1,6 +1,5 @@ import { TProjectPermission } from "@app/lib/types"; -import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; import { CertExtendedKeyUsageType, CertKeyUsageType, @@ -26,6 +25,7 @@ export type TIssueCertificateFromProfileDTO = { signatureAlgorithm?: string; keyAlgorithm?: string; }; + removeRootsFromChain?: boolean; } & Omit; export type TSignCertificateFromProfileDTO = { @@ -37,13 +37,14 @@ export type TSignCertificateFromProfileDTO = { notBefore?: Date; notAfter?: Date; enrollmentType: EnrollmentType; + removeRootsFromChain?: boolean; } & Omit; export type TOrderCertificateFromProfileDTO = { profileId: string; certificateOrder: { altNames: Array<{ - type: ACMESANType; + type: CertSubjectAlternativeNameType; value: string; }>; validity: { @@ -56,7 +57,10 @@ export type TOrderCertificateFromProfileDTO = { notAfter?: Date; signatureAlgorithm?: string; keyAlgorithm?: string; + template?: string; + csr?: string; }; + removeRootsFromChain?: boolean; } & Omit; export type TCertificateFromProfileResponse = { @@ -66,34 +70,14 @@ export type TCertificateFromProfileResponse = { privateKey?: string; serialNumber: string; certificateId: string; + certificateRequestId: string; projectId: string; profileName: string; commonName: string; }; export type TCertificateOrderResponse = { - orderId: string; - status: CertificateOrderStatus; - subjectAlternativeNames: Array<{ - type: ACMESANType; - value: string; - status: CertificateOrderStatus; - }>; - authorizations: Array<{ - identifier: { - type: ACMESANType; - value: string; - }; - status: CertificateOrderStatus; - expires?: string; - challenges: Array<{ - type: string; - status: CertificateOrderStatus; - url: string; - token: string; - }>; - }>; - finalize: string; + certificateRequestId: string; certificate?: string; projectId: string; profileName: string; @@ -101,6 +85,8 @@ export type TCertificateOrderResponse = { export type TRenewCertificateDTO = { certificateId: string; + removeRootsFromChain?: boolean; + certificateRequestId?: string; } & Omit; export type TUpdateRenewalConfigDTO = { diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 7af79319b..72cef90fa 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -4,6 +4,10 @@ import { TDbClient } from "@app/db"; import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { + applyProcessedPermissionRulesToQuery, + type ProcessedPermissionRules +} from "@app/lib/knex/permission-filter-utils"; import { CertStatus } from "./certificate-types"; @@ -140,7 +144,8 @@ export const certificateDALFactory = (db: TDbClient) => { const findActiveCertificatesForSync = async ( filter: Partial, - options?: { limit?: number; offset?: number } + options?: { limit?: number; offset?: number }, + permissionFilters?: ProcessedPermissionRules ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { try { let query = db @@ -163,6 +168,10 @@ export const certificateDALFactory = (db: TDbClient) => { } }); + if (permissionFilters) { + query = applyProcessedPermissionRulesToQuery(query, TableName.Certificate, permissionFilters) as typeof query; + } + if (options?.offset) { query = query.offset(options.offset); } @@ -267,7 +276,8 @@ export const certificateDALFactory = (db: TDbClient) => { const findWithPrivateKeyInfo = async ( filter: Partial, - options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } + options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] }, + permissionFilters?: ProcessedPermissionRules ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { try { let query = db @@ -287,6 +297,10 @@ export const certificateDALFactory = (db: TDbClient) => { } }); + if (permissionFilters) { + query = applyProcessedPermissionRulesToQuery(query, TableName.Certificate, permissionFilters) as typeof query; + } + if (options?.offset) { query = query.offset(options.offset); } diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index b632e76fb..7ea6d03cd 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; @@ -52,7 +52,10 @@ import { } from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "deleteById" | "update" | "find" | "transaction" | "create" | "findById" + >; certificateSecretDAL: Pick; certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; @@ -91,8 +94,8 @@ export const certificateServiceFactory = ({ /** * Return details for certificate with serial number [serialNumber] */ - const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const getCert = async ({ id, serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -105,7 +108,11 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) ); return { @@ -117,13 +124,14 @@ export const certificateServiceFactory = ({ * Get certificate private key. */ const getCertPrivateKey = async ({ + id, serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertPrivateKeyDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -136,7 +144,11 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.ReadPrivateKey, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) ); const { certPrivateKey } = await getCertificateCredentials({ @@ -156,8 +168,8 @@ export const certificateServiceFactory = ({ /** * Delete certificate with serial number [serialNumber] */ - const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const deleteCert = async ({ id, serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -170,7 +182,11 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Delete, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) ); const deletedCert = await certificateDAL.deleteById(cert.id); @@ -193,6 +209,7 @@ export const certificateServiceFactory = ({ * of its issuing CA */ const revokeCert = async ({ + id, serialNumber, revocationReason, actorId, @@ -200,7 +217,7 @@ export const certificateServiceFactory = ({ actor, actorOrgId }: TRevokeCertDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); if (!cert.caId) { throw new BadRequestError({ @@ -229,7 +246,13 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Delete, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber, + friendlyName: cert.friendlyName, + status: cert.status + }) ); if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); @@ -290,8 +313,8 @@ export const certificateServiceFactory = ({ * Return certificate body and certificate chain for certificate with * serial number [serialNumber] */ - const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const getCertBody = async ({ id, serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -304,11 +327,23 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber + }) ); const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, @@ -384,7 +419,7 @@ export const certificateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Import, ProjectPermissionSub.Certificates ); @@ -576,8 +611,15 @@ export const certificateServiceFactory = ({ * Return certificate body and certificate chain for certificate with * serial number [serialNumber] */ - const getCertBundle = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBundleDTO) => { - const cert = await certificateDAL.findOne({ serialNumber }); + const getCertBundle = async ({ + id, + serialNumber, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCertBundleDTO) => { + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -590,15 +632,35 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber, + friendlyName: cert.friendlyName, + status: cert.status + }) ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.ReadPrivateKey, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber, + friendlyName: cert.friendlyName, + status: cert.status + }) ); const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, @@ -657,12 +719,13 @@ export const certificateServiceFactory = ({ certificate, certificateChain, privateKey, - serialNumber, + serialNumber: cert.serialNumber, cert }; }; const getCertPkcs12 = async ({ + id, serialNumber, password, alias, @@ -684,7 +747,7 @@ export const certificateServiceFactory = ({ if (!alias || alias.trim() === "") { throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" }); } - const cert = await certificateDAL.findOne({ serialNumber }); + const cert = id ? await certificateDAL.findById(id) : await certificateDAL.findOne({ serialNumber }); const { permission } = await permissionService.getProjectPermission({ actor, @@ -697,12 +760,18 @@ export const certificateServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateActions.ReadPrivateKey, - ProjectPermissionSub.Certificates + subject(ProjectPermissionSub.Certificates, { + commonName: cert.commonName, + altNames: cert.altNames ?? undefined, + serialNumber: cert.serialNumber, + friendlyName: cert.friendlyName, + status: cert.status + }) ); // Get certificate bundle (certificate, chain, private key) const { certificate, certificateChain, privateKey } = await getCertBundle({ - serialNumber, + id: cert.id, actor, actorId, actorAuthMethod, diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 085bb9588..6c9d8b6bc 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -84,20 +84,24 @@ export enum CrlReason { } export type TGetCertDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; } & Omit; export type TDeleteCertDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; } & Omit; export type TRevokeCertDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; revocationReason: CrlReason; } & Omit; export type TGetCertBodyDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; } & Omit; export type TImportCertDTO = { @@ -112,15 +116,18 @@ export type TImportCertDTO = { } & Omit; export type TGetCertPrivateKeyDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; } & Omit; export type TGetCertBundleDTO = { - serialNumber: string; + id?: string; + serialNumber?: string; } & Omit; export type TGetCertPkcs12DTO = { - serialNumber: string; + id?: string; + serialNumber?: string; password: string; alias: string; } & Omit; diff --git a/backend/src/services/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index ffdb78645..2721596d2 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -18,8 +18,8 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .where(filter) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) - .select(db.ref("name").withSchema(TableName.Identity)) .select(db.ref("orgId").withSchema(TableName.Identity).as("identityScopeOrgId")) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")) .first(); return doc; 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 9322e48cb..212cb0894 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 @@ -270,7 +270,13 @@ export const identityKubernetesAuthServiceFactory = ({ } ) .catch((err) => { + const tokenReviewerJwtSnippet = `${tokenReviewerJwt?.substring?.(0, 10) || ""}...${tokenReviewerJwt?.substring?.(tokenReviewerJwt.length - 10) || ""}`; + const serviceAccountJwtSnippet = `${serviceAccountJwt?.substring?.(0, 10) || ""}...${serviceAccountJwt?.substring?.(serviceAccountJwt.length - 10) || ""}`; if (err instanceof AxiosError) { + logger.error( + { response: err.response, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (request error)" + ); if (err.response) { const { message } = err?.response?.data as unknown as { message?: string }; @@ -281,6 +287,11 @@ export const identityKubernetesAuthServiceFactory = ({ }); } } + } else { + logger.error( + { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" + ); } throw err; }); 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 a253c1e95..a5178f36d 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 @@ -99,13 +99,28 @@ export const identityOidcAuthServiceFactory = ({ } const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); - const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( - `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, - { - httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined - } - ); + + let discoveryDoc: { jwks_uri: string }; + try { + const response = await axios.get<{ jwks_uri: string }>( + `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, + { + httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined + } + ); + discoveryDoc = response.data; + } catch (error) { + throw new UnauthorizedError({ + message: `Access denied: Failed to fetch OIDC discovery document from ${identityOidcAuth.oidcDiscoveryUrl}. ${error instanceof Error ? error.message : String(error)}` + }); + } + const jwksUri = discoveryDoc.jwks_uri; + if (!jwksUri) { + throw new UnauthorizedError({ + message: `Access denied: OIDC discovery document does not contain a jwks_uri. The identity provider may be misconfigured.` + }); + } const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true }); if (!decodedToken) { 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 20c692134..1a20b1192 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 @@ -38,6 +38,7 @@ import { TAttachTokenAuthDTO, TCreateTokenAuthTokenDTO, TGetTokenAuthDTO, + TGetTokenAuthTokenByIdDTO, TGetTokenAuthTokensDTO, TRevokeTokenAuthDTO, TRevokeTokenAuthTokenDTO, @@ -618,6 +619,65 @@ export const identityTokenAuthServiceFactory = ({ return { tokens, identityMembershipOrg }; }; + const getTokenAuthTokenById = async ({ + tokenId, + actorId, + actor, + actorAuthMethod, + actorOrgId + }: TGetTokenAuthTokenByIdDTO) => { + const foundToken = await identityAccessTokenDAL.findOne({ + [`${TableName.IdentityAccessToken}.id` as "id"]: tokenId, + [`${TableName.IdentityAccessToken}.authMethod` as "authMethod"]: IdentityAuthMethod.TOKEN_AUTH + }); + if (!foundToken) throw new NotFoundError({ message: `Token with ID ${tokenId} not found` }); + + const identityMembershipOrg = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId: foundToken.identityId + }); + if (!identityMembershipOrg) { + throw new NotFoundError({ message: `Failed to find identity with ID ${foundToken.identityId}` }); + } + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have Token Auth" + }); + } + + 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: identityMembershipOrg.identity.id }) + ); + } 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 { token: foundToken, identityMembershipOrg }; + }; + const updateTokenAuthToken = async ({ tokenId, name, @@ -797,6 +857,7 @@ export const identityTokenAuthServiceFactory = ({ revokeIdentityTokenAuth, createTokenAuthToken, getTokenAuthTokens, + getTokenAuthTokenById, updateTokenAuthToken, revokeTokenAuthToken }; diff --git a/backend/src/services/identity-token-auth/identity-token-auth-types.ts b/backend/src/services/identity-token-auth/identity-token-auth-types.ts index 16cd60db7..6be2c5fe0 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-types.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-types.ts @@ -40,6 +40,10 @@ export type TGetTokenAuthTokensDTO = { isActorSuperAdmin?: boolean; } & Omit; +export type TGetTokenAuthTokenByIdDTO = { + tokenId: string; +} & Omit; + export type TUpdateTokenAuthTokenDTO = { tokenId: string; name?: string; diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e4e1d3126..b76e90470 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -105,7 +105,9 @@ export enum IntegrationUrls { GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations", - CHEF_API_URL = "https://api.chef.io" + CHEF_API_URL = "https://api.chef.io", + DNS_MADE_EASY_API_URL = "https://api.dnsmadeeasy.com", + DNS_MADE_EASY_SANDBOX_API_URL = "https://api.sandbox.dnsmadeeasy.com" } export const getIntegrationOptions = async () => { diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 8f868978d..a63f0d41d 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -253,7 +253,7 @@ export const kmsServiceFactory = ({ } if (!org.kmsDefaultKeyId) { - throw new Error("Invalid organization KMS"); + throw new BadRequestError({ message: "Invalid organization KMS" }); } return org.kmsDefaultKeyId; @@ -292,7 +292,7 @@ export const kmsServiceFactory = ({ let externalKms: TExternalKmsProviderFns; if (!kmsDoc.orgKms.id || !kmsDoc.orgKms.encryptedDataKey) { - throw new Error("Invalid organization KMS"); + throw new BadRequestError({ message: "Invalid organization KMS" }); } // The idea is external kms connection info is encrypted by an org default KMS @@ -338,7 +338,7 @@ export const kmsServiceFactory = ({ break; } default: - throw new Error("Invalid KMS provider."); + throw new BadRequestError({ message: "Invalid KMS provider." }); } return async ({ cipherTextBlob }: Pick) => { @@ -509,7 +509,7 @@ export const kmsServiceFactory = ({ if (kmsDoc.externalKms) { let externalKms: TExternalKmsProviderFns; if (!kmsDoc.orgKms.id || !kmsDoc.orgKms.encryptedDataKey) { - throw new Error("Invalid organization KMS"); + throw new BadRequestError({ message: "Invalid organization KMS" }); } const orgKmsDecryptor = await decryptWithKmsKey({ @@ -550,7 +550,7 @@ export const kmsServiceFactory = ({ break; } default: - throw new Error("Invalid KMS provider."); + throw new BadRequestError({ message: "Invalid KMS provider." }); } return async ({ plainText }: Pick) => { @@ -651,7 +651,7 @@ export const kmsServiceFactory = ({ } if (!org.kmsEncryptedDataKey) { - throw new Error("Invalid organization KMS"); + throw new BadRequestError({ message: "Invalid organization KMS" }); } const kmsDecryptor = await decryptWithKmsKey({ @@ -723,7 +723,7 @@ export const kmsServiceFactory = ({ } if (!project.kmsSecretManagerKeyId) { - throw new Error("Missing project KMS key ID"); + throw new BadRequestError({ message: "Missing project KMS key ID" }); } return project.kmsSecretManagerKeyId; @@ -832,9 +832,10 @@ export const kmsServiceFactory = ({ const isBase64 = !envConfig.ENCRYPTION_KEY; if (!encryptionKey) - throw new Error( - "Root encryption key not found for KMS service. Did you set the ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY environment variables?" - ); + throw new BadRequestError({ + message: + "Root encryption key not found for KMS service. Did you set the ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY environment variables?" + }); const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); @@ -846,7 +847,9 @@ export const kmsServiceFactory = ({ if (kmsRootConfig.encryptionStrategy === RootKeyEncryptionStrategy.HSM) { const hsmIsActive = await hsmService.isActive(); if (!hsmIsActive) { - throw new Error("Unable to decrypt root KMS key. HSM service is inactive. Did you configure the HSM?"); + throw new BadRequestError({ + message: "Unable to decrypt root KMS key. HSM service is inactive. Did you configure the HSM?" + }); } const decryptedKey = await hsmService.decrypt(kmsRootConfig.encryptedRootKey); @@ -861,14 +864,16 @@ export const kmsServiceFactory = ({ return cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); } - throw new Error(`Invalid root key encryption strategy: ${kmsRootConfig.encryptionStrategy}`); + throw new BadRequestError({ message: `Invalid root key encryption strategy: ${kmsRootConfig.encryptionStrategy}` }); }; const $encryptRootKey = async (plainKeyBuffer: Buffer, strategy: RootKeyEncryptionStrategy) => { if (strategy === RootKeyEncryptionStrategy.HSM) { const hsmIsActive = await hsmService.isActive(); if (!hsmIsActive) { - throw new Error("Unable to encrypt root KMS key. HSM service is inactive. Did you configure the HSM?"); + throw new BadRequestError({ + message: "Unable to encrypt root KMS key. HSM service is inactive. Did you configure the HSM?" + }); } const encrypted = await hsmService.encrypt(plainKeyBuffer); return encrypted; @@ -882,7 +887,7 @@ export const kmsServiceFactory = ({ } // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Invalid root key encryption strategy: ${strategy}`); + throw new BadRequestError({ message: `Invalid root key encryption strategy: ${strategy}` }); }; // by keeping the decrypted data key in inner scope @@ -1130,7 +1135,7 @@ export const kmsServiceFactory = ({ if (!encryptedRootKey) { logger.error("KMS: Failed to re-encrypt ROOT Key with selected strategy"); - throw new Error("Failed to re-encrypt ROOT Key with selected strategy"); + throw new BadRequestError({ message: "Failed to re-encrypt ROOT Key with selected strategy" }); } await kmsRootConfigDAL.updateById(KMS_ROOT_CONFIG_UUID, { diff --git a/backend/src/services/membership-group/membership-group-service.ts b/backend/src/services/membership-group/membership-group-service.ts index 0aedccd15..767daab31 100644 --- a/backend/src/services/membership-group/membership-group-service.ts +++ b/backend/src/services/membership-group/membership-group-service.ts @@ -93,6 +93,7 @@ export const membershipGroupServiceFactory = ({ } const scopeDatabaseFields = factory.getScopeDatabaseFields(dto.scopeData); + await factory.onCreateMembershipGroupGuard(dto); const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); @@ -112,6 +113,19 @@ export const membershipGroupServiceFactory = ({ const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); const membership = await membershipGroupDAL.transaction(async (tx) => { + const existingMembership = await membershipGroupDAL.findOne( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorGroupId: dto.data.groupId + }, + tx + ); + if (existingMembership) + throw new BadRequestError({ + message: "Group is already a member" + }); + const doc = await membershipGroupDAL.create( { scope: scopeData.scope, diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 4a90e1edd..bcf855c88 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -94,6 +94,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("name").withSchema(TableName.Role).as("roleName"), db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), @@ -180,6 +181,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { label: "roles" as const, mapper: ({ roleSlug, + roleName, membershipRoleId, membershipRole, membershipRoleIsTemporary, @@ -193,6 +195,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { id: membershipRoleId, role: membershipRole, customRoleSlug: roleSlug, + customRoleName: roleName, temporaryRange: membershipRoleTemporaryRange, temporaryMode: membershipRoleTemporaryMode, temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index ab63c4508..b1dd6e238 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -105,6 +105,19 @@ export const membershipIdentityServiceFactory = ({ const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); const membership = await membershipIdentityDAL.transaction(async (tx) => { + const existingMembership = await membershipIdentityDAL.findOne( + { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorIdentityId: dto.data.identityId + }, + tx + ); + if (existingMembership) + throw new BadRequestError({ + message: "Identity is already a member" + }); + const doc = await membershipIdentityDAL.create( { scope: scopeData.scope, diff --git a/backend/src/services/membership-user/org/org-membership-user-factory.ts b/backend/src/services/membership-user/org/org-membership-user-factory.ts index d21b27b69..deb819c7a 100644 --- a/backend/src/services/membership-user/org/org-membership-user-factory.ts +++ b/backend/src/services/membership-user/org/org-membership-user-factory.ts @@ -129,7 +129,7 @@ export const newOrgMembershipUserFactory = ({ recipients: emails as string[], substitutions: { subOrganizationName: orgDetails.slug, - callback_url: `${appCfg.SITE_URL}/organization/projects?subOrganization=${orgDetails.slug}` + callback_url: `${appCfg.SITE_URL}/organizations/${dto.permission.orgId}/projects?subOrganization=${orgDetails.slug}` } }); } else { diff --git a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts index e940fda54..0fcfbbe01 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-fns.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-fns.ts @@ -357,7 +357,7 @@ export const isBotInstalledInTenant = async ( } }; -export const buildTeamsPayload = (notification: TNotification) => { +export const buildTeamsPayload = (orgId: string, notification: TNotification) => { const appCfg = getConfig(); switch (notification.type) { @@ -402,7 +402,7 @@ export const buildTeamsPayload = (notification: TNotification) => { { type: "Action.OpenUrl", title: "View request in Infisical", - url: `${appCfg.SITE_URL}/projects/secret-management/${payload.projectId}/approval?requestId=${payload.requestId}` + url: `${appCfg.SITE_URL}/organizations/${orgId}/projects/secret-management/${payload.projectId}/approval?requestId=${payload.requestId}` } ] }; @@ -590,10 +590,11 @@ export class TeamsBot extends TeamsActivityHandler { tenantId: string, channelId: string, teamId: string, + orgId: string, notification: TNotification ) { try { - const { adaptiveCard } = buildTeamsPayload(notification); + const { adaptiveCard } = buildTeamsPayload(orgId, notification); const adaptiveCardActivity = { type: "message", diff --git a/backend/src/services/microsoft-teams/microsoft-teams-service.ts b/backend/src/services/microsoft-teams/microsoft-teams-service.ts index ff17daa75..a9d1af840 100644 --- a/backend/src/services/microsoft-teams/microsoft-teams-service.ts +++ b/backend/src/services/microsoft-teams/microsoft-teams-service.ts @@ -759,7 +759,7 @@ export const microsoftTeamsServiceFactory = ({ }); for await (const channelId of target.channelIds) { - await teamsBot.sendMessageToChannel(botAccessToken, tenantId, channelId, target.teamId, notification); + await teamsBot.sendMessageToChannel(botAccessToken, tenantId, channelId, target.teamId, orgId, notification); } }; diff --git a/backend/src/services/offline-usage-report/offline-usage-report-service.ts b/backend/src/services/offline-usage-report/offline-usage-report-service.ts index 179232aa4..1c34425a2 100644 --- a/backend/src/services/offline-usage-report/offline-usage-report-service.ts +++ b/backend/src/services/offline-usage-report/offline-usage-report-service.ts @@ -1,7 +1,8 @@ import crypto from "crypto"; +import { getLicenseKeyConfig } from "@app/ee/services/license/license-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; -import { getConfig } from "@app/lib/config/env"; +import { LicenseType } from "@app/ee/services/license/license-types"; import { BadRequestError } from "@app/lib/errors"; import { TOfflineUsageReportDALFactory } from "./offline-usage-report-dal"; @@ -30,10 +31,13 @@ export const offlineUsageReportServiceFactory = ({ }; const generateUsageReportCSV = async () => { - const cfg = getConfig(); - if (!cfg.LICENSE_KEY_OFFLINE) { + const licenseKeyConfig = getLicenseKeyConfig(); + const hasOfflineLicense = licenseKeyConfig.isValid && licenseKeyConfig.type === LicenseType.Offline; + + if (!hasOfflineLicense) { throw new BadRequestError({ - message: "Offline usage reports are not enabled. LICENSE_KEY_OFFLINE must be configured." + message: + "Offline usage reports are not enabled. Usage reports are only available for self-hosted offline instances" }); } diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index ebb1ef599..738819fc6 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -524,8 +524,8 @@ export const pkiSubscriberServiceFactory = ({ }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts index 3b9f5c881..4dee71b82 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), preserveArn: z.boolean().default(true), certificateNameSchema: z .string() diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-constants.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-constants.ts new file mode 100644 index 000000000..fd325a3f5 --- /dev/null +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-constants.ts @@ -0,0 +1,71 @@ +import RE2 from "re2"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +/** + * AWS Secrets Manager naming constraints for secrets + */ +export const AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING = { + /** + * Regular expression pattern for valid AWS Secrets Manager secret names + * Must contain only alphanumeric characters, hyphens, and underscores + * Must be 1-512 characters long + */ + NAME_PATTERN: new RE2("^[\\w-]+$"), + + /** + * String of characters that are forbidden in AWS Secrets Manager secret names + */ + FORBIDDEN_CHARACTERS: " @#$%^&*()+=[]{}|;':\"<>?,./", + + /** + * Minimum length for secret names in AWS Secrets Manager + */ + MIN_LENGTH: 1, + + /** + * Maximum length for secret names in AWS Secrets Manager + */ + MAX_LENGTH: 512, + + /** + * String representation of the allowed character pattern (for UI display) + */ + ALLOWED_CHARACTER_PATTERN: "^[\\w-]+$" +} as const; + +export const AWS_SECRETS_MANAGER_PKI_SYNC_DEFAULTS = { + INFISICAL_PREFIX: "infisical-", + DEFAULT_ENVIRONMENT: "production", + DEFAULT_CERTIFICATE_NAME_SCHEMA: "infisical-{{certificateId}}", + DEFAULT_FIELD_MAPPINGS: { + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + } +}; + +export const AWS_SECRETS_MANAGER_PKI_SYNC_OPTIONS = { + DEFAULT_CAN_REMOVE_CERTIFICATES: true, + DEFAULT_PRESERVE_SECRET_ON_RENEWAL: true, + DEFAULT_UPDATE_EXISTING_CERTIFICATES: true, + DEFAULT_CAN_IMPORT_CERTIFICATES: false +}; + +/** + * AWS Secrets Manager PKI Sync list option configuration + */ +export const AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION = { + name: "AWS Secrets Manager" as const, + connection: AppConnection.AWS, + destination: PkiSync.AwsSecretsManager, + canImportCertificates: false, + canRemoveCertificates: true, + defaultCertificateNameSchema: "infisical-{{certificateId}}", + forbiddenCharacters: AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS, + allowedCharacterPattern: AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN, + maxCertificateNameLength: AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.MAX_LENGTH, + minCertificateNameLength: AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.MIN_LENGTH +} as const; diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts new file mode 100644 index 000000000..a385247a8 --- /dev/null +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-fns.ts @@ -0,0 +1,555 @@ +/* eslint-disable no-continue */ +/* eslint-disable no-await-in-loop */ +import { + CreateSecretCommand, + DeleteSecretCommand, + ListSecretsCommand, + SecretsManagerClient, + UpdateSecretCommand +} from "@aws-sdk/client-secrets-manager"; +import RE2 from "re2"; + +import { TCertificateSyncs } from "@app/db/schemas"; +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { crypto } from "@app/lib/crypto"; +import { logger } from "@app/lib/logger"; +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; +import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; +import { TCertificateMap, TPkiSyncWithCredentials } from "@app/services/pki-sync/pki-sync-types"; + +import { AWS_SECRETS_MANAGER_PKI_SYNC_DEFAULTS } from "./aws-secrets-manager-pki-sync-constants"; +import { + AwsSecretsManagerCertificateSecret, + SyncCertificatesResult, + TAwsSecretsManagerPkiSyncWithCredentials +} from "./aws-secrets-manager-pki-sync-types"; + +const AWS_SECRETS_MANAGER_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 10, + BASE_DELAY: 1000, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const awsSecretsManagerConnectionQueue = createConnectionQueue(AWS_SECRETS_MANAGER_RATE_LIMIT_CONFIG); +const { withRateLimitRetry } = awsSecretsManagerConnectionQueue; + +const MAX_RETRIES = 10; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + +const isInfisicalManagedCertificate = (secretName: string, pkiSync: TPkiSyncWithCredentials): boolean => { + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = AWS_SECRETS_MANAGER_PKI_SYNC_DEFAULTS.DEFAULT_ENVIRONMENT; + return matchesCertificateNameSchema(secretName, environment, certificateNameSchema); + } + + return secretName.startsWith(AWS_SECRETS_MANAGER_PKI_SYNC_DEFAULTS.INFISICAL_PREFIX); +}; + +const parseErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + const { message } = error as { message: unknown }; + if (typeof message === "string") { + return message; + } + } + + return "Unknown error occurred"; +}; + +const getSecretsManagerClient = async (pkiSync: TAwsSecretsManagerPkiSyncWithCredentials) => { + const { destinationConfig, connection } = pkiSync; + + const config = await getAwsConnectionConfig( + connection as TAwsConnectionConfig, + destinationConfig.region as AWSRegion + ); + + if (!config.credentials) { + throw new Error("AWS credentials not found in connection configuration"); + } + + const secretsManagerClient = new SecretsManagerClient({ + region: config.region, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher, + credentials: config.credentials + }); + + return secretsManagerClient; +}; + +type TAwsSecretsManagerPkiSyncFactoryDeps = { + certificateDAL: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "removeCertificates" + | "addCertificates" + | "findByPkiSyncAndCertificate" + | "updateById" + | "findByPkiSyncId" + | "updateSyncStatus" + >; +}; + +export const awsSecretsManagerPkiSyncFactory = ({ + certificateDAL, + certificateSyncDAL +}: TAwsSecretsManagerPkiSyncFactoryDeps) => { + const $getSecretsManagerSecrets = async ( + pkiSync: TAwsSecretsManagerPkiSyncWithCredentials, + syncId = "unknown" + ): Promise> => { + const client = await getSecretsManagerClient(pkiSync); + const secrets: Record = {}; + let hasNext = true; + let nextToken: string | undefined; + let attempt = 0; + + while (hasNext) { + try { + const currentToken = nextToken; + const output = await withRateLimitRetry( + () => client.send(new ListSecretsCommand({ NextToken: currentToken })), + { + operation: "list-secrets-manager-secrets", + syncId + } + ); + + attempt = 0; + + if (output.SecretList) { + output.SecretList.forEach((secretEntry) => { + if ( + secretEntry.Name && + isInfisicalManagedCertificate(secretEntry.Name, pkiSync as unknown as TPkiSyncWithCredentials) + ) { + secrets[secretEntry.Name] = secretEntry.ARN || secretEntry.Name; + } + }); + } + + hasNext = Boolean(output.NextToken); + nextToken = output.NextToken; + } catch (e) { + if ( + e && + typeof e === "object" && + "name" in e && + (e as { name: string }).name === "ThrottlingException" && + attempt < MAX_RETRIES + ) { + attempt += 1; + await sleep(); + continue; + } + throw e; + } + } + + return secrets; + }; + + const syncCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap + ): Promise => { + const awsPkiSync = pkiSync as unknown as TAwsSecretsManagerPkiSyncWithCredentials; + const client = await getSecretsManagerClient(awsPkiSync); + + const existingSecrets = await $getSecretsManagerSecrets(awsPkiSync, pkiSync.id); + + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + const syncRecordsByCertId = new Map(); + const syncRecordsByExternalId = new Map(); + + existingSyncRecords.forEach((record: TCertificateSyncs) => { + if (record.certificateId) { + syncRecordsByCertId.set(record.certificateId, record); + } + if (record.externalIdentifier) { + syncRecordsByExternalId.set(record.externalIdentifier, record); + } + }); + + type CertificateUploadData = { + secretName: string; + certificateData: AwsSecretsManagerCertificateSecret; + certificateId: string; + isUpdate: boolean; + targetSecretName: string; + oldCertificateIdToRemove?: string; + }; + + const setCertificates: CertificateUploadData[] = []; + const validationErrors: Array<{ name: string; error: string }> = []; + + const syncOptions = pkiSync.syncOptions as + | { + canRemoveCertificates?: boolean; + preserveSecretOnRenewal?: boolean; + fieldMappings?: { + certificate?: string; + privateKey?: string; + certificateChain?: string; + caCertificate?: string; + }; + certificateNameSchema?: string; + } + | undefined; + + const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; + const preserveSecretOnRenewal = syncOptions?.preserveSecretOnRenewal ?? true; + + const fieldMappings = { + certificate: syncOptions?.fieldMappings?.certificate ?? "certificate", + privateKey: syncOptions?.fieldMappings?.privateKey ?? "private_key", + certificateChain: syncOptions?.fieldMappings?.certificateChain ?? "certificate_chain", + caCertificate: syncOptions?.fieldMappings?.caCertificate ?? "ca_certificate" + }; + + const activeExternalIdentifiers = new Set(); + + for (const [certName, certData] of Object.entries(certificateMap)) { + const { cert, privateKey: certPrivateKey, certificateChain, caCertificate, certificateId } = certData; + + if (!cert || cert.trim().length === 0) { + validationErrors.push({ + name: certName, + error: "Certificate content is empty or missing" + }); + continue; + } + + if (!certPrivateKey || certPrivateKey.trim().length === 0) { + validationErrors.push({ + name: certName, + error: "Private key content is empty or missing" + }); + continue; + } + + if (!certificateId || typeof certificateId !== "string") { + continue; + } + + const certificateData: AwsSecretsManagerCertificateSecret = { + [fieldMappings.certificate]: cert, + [fieldMappings.privateKey]: certPrivateKey + }; + + if (certificateChain && certificateChain.trim().length > 0) { + certificateData[fieldMappings.certificateChain] = certificateChain; + } + + if (caCertificate && typeof caCertificate === "string" && caCertificate.trim().length > 0) { + certificateData[fieldMappings.caCertificate] = caCertificate; + } + + let targetSecretName = certName; + if (syncOptions?.certificateNameSchema) { + const extendedCertData = certData as Record; + const safeCommonName = typeof extendedCertData.commonName === "string" ? extendedCertData.commonName : ""; + + targetSecretName = syncOptions.certificateNameSchema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), certificateId) + .replace(new RE2("\\{\\{commonName\\}\\}", "g"), safeCommonName); + } else { + targetSecretName = `${AWS_SECRETS_MANAGER_PKI_SYNC_DEFAULTS.INFISICAL_PREFIX}${certificateId}`; + } + + const certificate = await certificateDAL.findById(certificateId); + + if (certificate?.renewedByCertificateId) { + continue; + } + + const syncRecordLookupId = certificate?.renewedFromCertificateId || certificateId; + const existingRecord = syncRecordsByCertId.get(syncRecordLookupId); + + let shouldProcess = true; + let isUpdate = false; + + if (existingRecord?.externalIdentifier) { + const existingSecret = existingSecrets[existingRecord.externalIdentifier]; + + if (existingSecret) { + if (certificate?.renewedFromCertificateId && preserveSecretOnRenewal) { + targetSecretName = existingRecord.externalIdentifier; + isUpdate = true; + } else if (certificate?.renewedFromCertificateId && !preserveSecretOnRenewal) { + activeExternalIdentifiers.add(existingRecord.externalIdentifier); + } else if (!certificate?.renewedFromCertificateId) { + activeExternalIdentifiers.add(existingRecord.externalIdentifier); + shouldProcess = false; + } + } + } + + if (!shouldProcess) { + continue; + } + + if (existingSecrets[targetSecretName]) { + isUpdate = true; + } + + activeExternalIdentifiers.add(targetSecretName); + + setCertificates.push({ + secretName: certName, + certificateData, + certificateId, + isUpdate, + targetSecretName, + oldCertificateIdToRemove: + certificate?.renewedFromCertificateId && preserveSecretOnRenewal + ? certificate.renewedFromCertificateId + : undefined + }); + } + + const result: SyncCertificatesResult = { + uploaded: 0, + updated: 0, + removed: 0, + failedRemovals: 0, + skipped: 0, + details: { + failedUploads: [], + failedRemovals: [], + validationErrors + } + }; + + for (const certData of setCertificates) { + const { secretName, certificateData, certificateId, isUpdate, targetSecretName, oldCertificateIdToRemove } = + certData; + + try { + const secretValue = JSON.stringify(certificateData); + const configKeyId: unknown = awsPkiSync.destinationConfig.keyId; + const keyId: string = typeof configKeyId === "string" ? configKeyId : "alias/aws/secretsmanager"; + + if (isUpdate) { + await withRateLimitRetry( + () => + client.send( + new UpdateSecretCommand({ + SecretId: targetSecretName, + SecretString: secretValue, + KmsKeyId: keyId + }) + ), + { + operation: "update-secret", + syncId: pkiSync.id + } + ); + result.updated += 1; + } else { + await withRateLimitRetry( + () => + client.send( + new CreateSecretCommand({ + Name: targetSecretName, + SecretString: secretValue, + KmsKeyId: keyId, + Description: `Certificate managed by Infisical` + }) + ), + { + operation: "create-secret", + syncId: pkiSync.id + } + ); + result.uploaded += 1; + } + + const existingRecord = syncRecordsByCertId.get(certificateId); + if (existingRecord?.id) { + await certificateSyncDAL.updateById(existingRecord.id, { + externalIdentifier: targetSecretName, + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date(), + lastSyncMessage: "Certificate successfully synced to AWS Secrets Manager" + }); + + if (oldCertificateIdToRemove && oldCertificateIdToRemove !== certificateId) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateIdToRemove]); + } + } else { + await certificateSyncDAL.addCertificates(pkiSync.id, [ + { + certificateId, + externalIdentifier: targetSecretName + } + ]); + + const newCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId); + if (newCertSync?.id) { + await certificateSyncDAL.updateById(newCertSync.id, { + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date(), + lastSyncMessage: "Certificate successfully synced to AWS Secrets Manager" + }); + } + } + } catch (error) { + result.details?.failedUploads?.push({ + name: secretName, + error: parseErrorMessage(error) + }); + logger.error( + { + secretName, + certificateId, + error: parseErrorMessage(error), + pkiSyncId: pkiSync.id + }, + "Failed to sync certificate" + ); + + const existingRecord = syncRecordsByCertId.get(certificateId); + if (existingRecord?.id) { + await certificateSyncDAL.updateById(existingRecord.id, { + syncStatus: CertificateSyncStatus.Failed, + lastSyncMessage: parseErrorMessage(error) + }); + } + } + } + + if (canRemoveCertificates) { + for (const [secretName] of Object.entries(existingSecrets)) { + if (!activeExternalIdentifiers.has(secretName)) { + try { + await withRateLimitRetry( + () => + client.send( + new DeleteSecretCommand({ + SecretId: secretName, + ForceDeleteWithoutRecovery: true + }) + ), + { + operation: "delete-secret", + syncId: pkiSync.id + } + ); + + result.removed += 1; + } catch (error) { + result.failedRemovals += 1; + result.details?.failedRemovals?.push({ + name: secretName, + error: parseErrorMessage(error) + }); + logger.error( + { + secretName, + error: parseErrorMessage(error), + pkiSyncId: pkiSync.id + }, + "Failed to remove certificate secret" + ); + } + } + } + } + + return result; + }; + + const removeCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap + ): Promise<{ removed: number; failed: number }> => { + const awsPkiSync = pkiSync as unknown as TAwsSecretsManagerPkiSyncWithCredentials; + const client = await getSecretsManagerClient(awsPkiSync); + + const existingSecrets = await $getSecretsManagerSecrets(awsPkiSync, pkiSync.id); + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + + let removed = 0; + let failed = 0; + + for (const [, certData] of Object.entries(certificateMap)) { + if (!certData.certificateId) continue; + + const syncRecord = existingSyncRecords.find((record) => record.certificateId === certData.certificateId); + if (!syncRecord?.externalIdentifier) continue; + + const secretName = syncRecord.externalIdentifier; + + if (existingSecrets[secretName]) { + try { + await withRateLimitRetry( + () => + client.send( + new DeleteSecretCommand({ + SecretId: secretName, + ForceDeleteWithoutRecovery: true + }) + ), + { + operation: "delete-secret", + syncId: pkiSync.id + } + ); + + if (syncRecord.id) { + await certificateSyncDAL.updateById(syncRecord.id, { + syncStatus: CertificateSyncStatus.Failed + }); + } + + removed += 1; + } catch (error) { + failed += 1; + logger.error( + { + secretName, + certificateId: certData.certificateId, + error: parseErrorMessage(error), + pkiSyncId: pkiSync.id + }, + "Failed to remove certificate secret" + ); + } + } + } + + return { removed, failed }; + }; + + return { + syncCertificates, + removeCertificates + }; +}; + +export type TAwsSecretsManagerPkiSyncFactory = ReturnType; diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts new file mode 100644 index 000000000..3005357a5 --- /dev/null +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-schemas.ts @@ -0,0 +1,104 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING } from "./aws-secrets-manager-pki-sync-constants"; + +export const AwsSecretsManagerPkiSyncConfigSchema = z.object({ + region: z.nativeEnum(AWSRegion), + keyId: z.string().trim().optional() +}); + +export const AwsSecretsManagerFieldMappingsSchema = z.object({ + certificate: z.string().min(1, "Certificate field name is required").default("certificate"), + privateKey: z.string().min(1, "Private key field name is required").default("private_key"), + certificateChain: z.string().min(1, "Certificate chain field name is required").default("certificate_chain"), + caCertificate: z.string().min(1, "CA certificate field name is required").default("ca_certificate") +}); + +const AwsSecretsManagerPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), + preserveSecretOnRenewal: z.boolean().default(true), + updateExistingCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + if (!schema.includes("{{certificateId}}")) { + return false; + } + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id") + .replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name") + .replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); + + const hasForbiddenChars = AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( + (char) => testName.includes(char) + ); + + return ( + AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && + !hasForbiddenChars && + testName.length >= AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.MIN_LENGTH && + testName.length <= AWS_SECRETS_MANAGER_PKI_SYNC_CERTIFICATE_NAMING.MAX_LENGTH + ); + }, + { + message: + "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, underscores, and hyphens and be 1-512 characters long for AWS Secrets Manager." + } + ), + fieldMappings: AwsSecretsManagerFieldMappingsSchema.optional().default({ + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + }) +}); + +export const AwsSecretsManagerPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.AwsSecretsManager), + destinationConfig: AwsSecretsManagerPkiSyncConfigSchema, + syncOptions: AwsSecretsManagerPkiSyncOptionsSchema +}); + +export const CreateAwsSecretsManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: AwsSecretsManagerPkiSyncConfigSchema, + syncOptions: AwsSecretsManagerPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().nullish(), + connectionId: z.string(), + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() +}); + +export const UpdateAwsSecretsManagerPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: AwsSecretsManagerPkiSyncConfigSchema.optional(), + syncOptions: AwsSecretsManagerPkiSyncOptionsSchema.optional(), + subscriberId: z.string().nullish(), + connectionId: z.string().optional() +}); + +export const AwsSecretsManagerPkiSyncListItemSchema = z.object({ + name: z.literal("AWS Secrets Manager"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(PkiSync.AwsSecretsManager), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(true) +}); diff --git a/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-types.ts new file mode 100644 index 000000000..7c4a5f8a1 --- /dev/null +++ b/backend/src/services/pki-sync/aws-secrets-manager/aws-secrets-manager-pki-sync-types.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; + +import { + AwsSecretsManagerFieldMappingsSchema, + AwsSecretsManagerPkiSyncConfigSchema, + AwsSecretsManagerPkiSyncSchema, + CreateAwsSecretsManagerPkiSyncSchema, + UpdateAwsSecretsManagerPkiSyncSchema +} from "./aws-secrets-manager-pki-sync-schemas"; + +export type TAwsSecretsManagerPkiSyncConfig = z.infer; + +export type TAwsSecretsManagerFieldMappings = z.infer; + +export type TAwsSecretsManagerPkiSync = z.infer; + +export type TAwsSecretsManagerPkiSyncInput = z.infer; + +export type TAwsSecretsManagerPkiSyncUpdate = z.infer; + +export type TAwsSecretsManagerPkiSyncWithCredentials = TAwsSecretsManagerPkiSync & { + connection: TAwsConnection; + appConnectionName: string; + appConnectionApp: string; +}; + +export interface AwsSecretsManagerCertificateSecret { + [key: string]: string; +} + +export interface SyncCertificatesResult { + uploaded: number; + updated: number; + removed: number; + failedRemovals: number; + skipped: number; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; + }; +} + +export interface RemoveCertificatesResult { + removed: number; + failed: number; + skipped: number; +} + +export interface CertificateImportRequest { + name: string; + certificate: string; + privateKey: string; + certificateChain?: string; + caCertificate?: string; + certificateId?: string; +} diff --git a/backend/src/services/pki-sync/aws-secrets-manager/index.ts b/backend/src/services/pki-sync/aws-secrets-manager/index.ts new file mode 100644 index 000000000..7f6e11664 --- /dev/null +++ b/backend/src/services/pki-sync/aws-secrets-manager/index.ts @@ -0,0 +1,4 @@ +export * from "./aws-secrets-manager-pki-sync-constants"; +export * from "./aws-secrets-manager-pki-sync-fns"; +export * from "./aws-secrets-manager-pki-sync-schemas"; +export * from "./aws-secrets-manager-pki-sync-types"; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts index 90f4a119b..ab66d9b4a 100644 --- a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), enableVersioning: z.boolean().default(true), certificateNameSchema: z .string() diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-constants.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-constants.ts new file mode 100644 index 000000000..c466bbd56 --- /dev/null +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-constants.ts @@ -0,0 +1,23 @@ +import RE2 from "re2"; + +export const CHEF_PKI_SYNC_CERTIFICATE_NAMING = { + NAME_PATTERN: new RE2("^[a-zA-Z0-9_-]+$"), + FORBIDDEN_CHARACTERS: "[]{}()<>|\\:;\"'=+*&^%$#@!~`?/", + MIN_LENGTH: 1, + MAX_LENGTH: 255, + DEFAULT_SCHEMA: "{{certificateId}}" +}; + +export const CHEF_PKI_SYNC_DATA_BAG_NAMING = { + NAME_PATTERN: new RE2("^[a-zA-Z0-9_-]+$"), + FORBIDDEN_CHARACTERS: "[]{}()<>|\\:;\"'=+*&^%$#@!~`?/.", + MIN_LENGTH: 1, + MAX_LENGTH: 255 +}; + +export const CHEF_PKI_SYNC_DEFAULTS = { + CERTIFICATE_DATA_BAG: "ssl_certificates", + ITEM_NAME_TEMPLATE: "{{certificateId}}", + INFISICAL_PREFIX: "Infisical-", + DEFAULT_ENVIRONMENT: "global" +} as const; diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts new file mode 100644 index 000000000..bf740c660 --- /dev/null +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-fns.ts @@ -0,0 +1,595 @@ +/* eslint-disable no-continue */ +/* eslint-disable no-await-in-loop */ +import { TCertificateSyncs } from "@app/db/schemas"; +import { + createChefDataBagItem, + listChefDataBagItems, + removeChefDataBagItem, + updateChefDataBagItem +} from "@app/ee/services/app-connections/chef"; +import { TChefDataBagItemContent } from "@app/ee/services/secret-sync/chef"; +import { logger } from "@app/lib/logger"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; +import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; +import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; +import { TCertificateMap, TPkiSyncWithCredentials } from "@app/services/pki-sync/pki-sync-types"; + +import { CHEF_PKI_SYNC_DEFAULTS } from "./chef-pki-sync-constants"; +import { ChefCertificateDataBagItem, SyncCertificatesResult, TChefPkiSyncWithCredentials } from "./chef-pki-sync-types"; + +const CHEF_RATE_LIMIT_CONFIG: RateLimitConfig = { + MAX_CONCURRENT_REQUESTS: 5, // Chef servers generally have lower rate limits + BASE_DELAY: 1500, + MAX_DELAY: 30000, + MAX_RETRIES: 3, + RATE_LIMIT_STATUS_CODES: [429, 503] +}; + +const chefConnectionQueue = createConnectionQueue(CHEF_RATE_LIMIT_CONFIG); +const { withRateLimitRetry } = chefConnectionQueue; + +const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { + const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const certificateNameSchema = syncOptions?.certificateNameSchema; + + if (certificateNameSchema) { + const environment = CHEF_PKI_SYNC_DEFAULTS.DEFAULT_ENVIRONMENT; + return matchesCertificateNameSchema(certificateName, environment, certificateNameSchema); + } + + return certificateName.startsWith(CHEF_PKI_SYNC_DEFAULTS.INFISICAL_PREFIX); +}; + +const parseErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + if (error && typeof error === "object" && "message" in error) { + const { message } = error as { message: unknown }; + if (typeof message === "string") { + return message; + } + } + + return "Unknown error occurred"; +}; + +type TChefPkiSyncFactoryDeps = { + certificateDAL: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "removeCertificates" + | "addCertificates" + | "findByPkiSyncAndCertificate" + | "updateById" + | "findByPkiSyncId" + | "updateSyncStatus" + >; +}; + +export const chefPkiSyncFactory = ({ certificateDAL, certificateSyncDAL }: TChefPkiSyncFactoryDeps) => { + const $getChefDataBagItems = async ( + pkiSync: TChefPkiSyncWithCredentials, + syncId = "unknown" + ): Promise> => { + const { + connection, + destinationConfig: { dataBagName } + } = pkiSync; + const { serverUrl, userName, privateKey, orgName } = connection.credentials; + + const dataBagItems = await withRateLimitRetry( + () => + listChefDataBagItems( + { + credentials: { serverUrl, userName, privateKey, orgName } + } as Parameters[0], + dataBagName + ), + { + operation: "list-chef-data-bag-items", + syncId + } + ); + + const chefDataBagItems: Record = {}; + dataBagItems.forEach((item) => { + chefDataBagItems[item.name] = true; + }); + + return chefDataBagItems; + }; + + const syncCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap + ): Promise => { + const chefPkiSync = pkiSync as unknown as TChefPkiSyncWithCredentials; + const { + connection, + destinationConfig: { dataBagName } + } = chefPkiSync; + const { serverUrl, userName, privateKey, orgName } = connection.credentials; + + const chefDataBagItems = await $getChefDataBagItems(chefPkiSync, pkiSync.id); + + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + const syncRecordsByCertId = new Map(); + const syncRecordsByExternalId = new Map(); + + existingSyncRecords.forEach((record: TCertificateSyncs) => { + if (record.certificateId) { + syncRecordsByCertId.set(record.certificateId, record); + } + if (record.externalIdentifier) { + syncRecordsByExternalId.set(record.externalIdentifier, record); + } + }); + + type CertificateUploadData = { + key: string; + name: string; + cert: string; + privateKey: string; + certificateChain?: string; + caCertificate?: string; + certificateId: string; + isUpdate: boolean; + targetItemName: string; + oldCertificateIdToRemove?: string; + }; + + const setCertificates: CertificateUploadData[] = []; + + const validationErrors: Array<{ name: string; error: string }> = []; + + const syncOptions = pkiSync.syncOptions as + | { + canRemoveCertificates?: boolean; + preserveItemOnRenewal?: boolean; + fieldMappings?: { + certificate?: string; + privateKey?: string; + certificateChain?: string; + caCertificate?: string; + metadata?: string; + }; + } + | undefined; + const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; + const preserveItemOnRenewal = syncOptions?.preserveItemOnRenewal ?? true; + + const fieldMappings = { + certificate: syncOptions?.fieldMappings?.certificate ?? "certificate", + privateKey: syncOptions?.fieldMappings?.privateKey ?? "private_key", + certificateChain: syncOptions?.fieldMappings?.certificateChain ?? "certificate_chain", + caCertificate: syncOptions?.fieldMappings?.caCertificate ?? "ca_certificate" + }; + + const activeExternalIdentifiers = new Set(); + + for (const [certName, certData] of Object.entries(certificateMap)) { + const { cert, privateKey: certPrivateKey, certificateChain, caCertificate, certificateId } = certData; + + if (!cert || cert.trim().length === 0) { + validationErrors.push({ + name: certName, + error: "Certificate content is empty or missing" + }); + continue; + } + + if (!certPrivateKey || certPrivateKey.trim().length === 0) { + validationErrors.push({ + name: certName, + error: "Private key content is empty or missing" + }); + continue; + } + + if (!certificateId || typeof certificateId !== "string") { + continue; + } + + const targetCertificateName = certName; + + const certificate = await certificateDAL.findById(certificateId); + + if (certificate?.renewedByCertificateId) { + continue; + } + + const syncRecordLookupId = certificate?.renewedFromCertificateId || certificateId; + const existingSyncRecord = syncRecordsByCertId.get(syncRecordLookupId); + + let shouldProcess = true; + let isUpdate = false; + let targetItemName = targetCertificateName; + + if (existingSyncRecord?.externalIdentifier) { + const existingChefItem = chefDataBagItems[existingSyncRecord.externalIdentifier]; + + if (existingChefItem) { + if (certificate?.renewedFromCertificateId && preserveItemOnRenewal) { + targetItemName = existingSyncRecord.externalIdentifier; + isUpdate = true; + } else if (!certificate?.renewedFromCertificateId) { + shouldProcess = false; + } + } + } + + if (!shouldProcess) { + continue; + } + + setCertificates.push({ + key: certName, + name: certName, + cert, + privateKey: certPrivateKey, + certificateChain, + caCertificate, + certificateId, + isUpdate, + targetItemName, + oldCertificateIdToRemove: + certificate?.renewedFromCertificateId && preserveItemOnRenewal + ? certificate.renewedFromCertificateId + : undefined + }); + + activeExternalIdentifiers.add(targetItemName); + } + + type UploadResult = + | { status: "fulfilled"; certificate: CertificateUploadData } + | { status: "rejected"; certificate: CertificateUploadData; error: unknown }; + + const uploadPromises = setCertificates.map(async (certificateData): Promise => { + const { + targetItemName, + cert, + privateKey: certPrivateKey, + certificateChain, + caCertificate, + certificateId + } = certificateData; + + try { + const chefDataBagItem: ChefCertificateDataBagItem = { + id: targetItemName, + [fieldMappings.certificate]: cert, + [fieldMappings.privateKey]: certPrivateKey, + ...(certificateChain && { [fieldMappings.certificateChain]: certificateChain }), + ...(caCertificate && { [fieldMappings.caCertificate]: caCertificate }) + }; + + const itemExists = chefDataBagItems[targetItemName] === true; + + if (itemExists) { + await withRateLimitRetry( + () => + updateChefDataBagItem({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName: targetItemName, + data: chefDataBagItem as unknown as TChefDataBagItemContent + }), + { + operation: "update-chef-data-bag-item", + syncId: pkiSync.id + } + ); + } else { + await withRateLimitRetry( + () => + createChefDataBagItem({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + data: chefDataBagItem as unknown as TChefDataBagItemContent + }), + { + operation: "create-chef-data-bag-item", + syncId: pkiSync.id + } + ); + } + + return { status: "fulfilled" as const, certificate: certificateData }; + } catch (error) { + logger.error( + { + syncId: pkiSync.id, + certificateId, + targetItemName, + error: error instanceof Error ? error.message : String(error) + }, + "Failed to sync certificate to Chef" + ); + return { status: "rejected" as const, certificate: certificateData, error }; + } + }); + + const uploadResults = await Promise.allSettled(uploadPromises); + + const successfulUploads = uploadResults.filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled" && result.value.status === "fulfilled" + ); + const failedUploads = uploadResults.filter( + ( + result + ): result is + | PromiseRejectedResult + | PromiseFulfilledResult<{ status: "rejected"; certificate: CertificateUploadData; error: unknown }> => + result.status === "rejected" || (result.status === "fulfilled" && result.value.status === "rejected") + ); + + let removedCount = 0; + let failedRemovals: Array<{ name: string; error: string }> = []; + + if (canRemoveCertificates) { + const itemsToRemove: string[] = []; + + Object.keys(chefDataBagItems).forEach((itemName) => { + if (!activeExternalIdentifiers.has(itemName) && isInfisicalManagedCertificate(itemName, pkiSync)) { + itemsToRemove.push(itemName); + } + }); + + if (itemsToRemove.length > 0) { + const removalPromises = itemsToRemove.map(async (itemName) => { + try { + await withRateLimitRetry( + () => + removeChefDataBagItem({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName: itemName + }), + { + operation: "remove-chef-data-bag-item", + syncId: pkiSync.id + } + ); + + const syncRecord = syncRecordsByExternalId.get(itemName); + if (syncRecord?.certificateId) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [syncRecord.certificateId]); + } + + return { status: "fulfilled" as const, itemName }; + } catch (error) { + logger.error( + { + syncId: pkiSync.id, + itemName, + error: error instanceof Error ? error.message : String(error) + }, + "Failed to remove Chef data bag item" + ); + return { status: "rejected" as const, itemName, error }; + } + }); + + const removalResults = await Promise.allSettled(removalPromises); + + const successfulRemovals = removalResults.filter( + (result): result is PromiseFulfilledResult<{ status: "fulfilled"; itemName: string }> => + result.status === "fulfilled" && result.value.status === "fulfilled" + ); + removedCount = successfulRemovals.length; + + const failedRemovalPromises = removalResults.filter( + ( + result + ): result is + | PromiseRejectedResult + | PromiseFulfilledResult<{ status: "rejected"; itemName: string; error: unknown }> => + result.status === "rejected" || (result.status === "fulfilled" && result.value.status === "rejected") + ); + + failedRemovals = failedRemovalPromises.map((result) => { + if (result.status === "rejected") { + return { + name: "unknown", + error: parseErrorMessage(result.reason) + }; + } + const { itemName, error } = result.value; + return { + name: String(itemName), + error: parseErrorMessage(error) + }; + }); + } + } + + for (const result of successfulUploads) { + const { certificateId, targetItemName, oldCertificateIdToRemove } = result.value.certificate; + + if (certificateId && typeof certificateId === "string") { + const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId); + if (existingCertSync) { + await certificateSyncDAL.updateById(existingCertSync.id, { + externalIdentifier: targetItemName, + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date(), + lastSyncMessage: "Certificate successfully synced to destination" + }); + } else { + await certificateSyncDAL.addCertificates(pkiSync.id, [ + { + certificateId, + externalIdentifier: targetItemName + } + ]); + + const newCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId); + if (newCertSync) { + await certificateSyncDAL.updateById(newCertSync.id, { + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date(), + lastSyncMessage: "Certificate successfully synced to destination" + }); + } + } + + if (oldCertificateIdToRemove) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateIdToRemove]); + } + } + } + + await Promise.all( + failedUploads.map(async (result) => { + let certificateId: string; + let errorMessage: string; + + if (result.status === "rejected") { + certificateId = "unknown"; + errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason); + return; + } + + const { certificate, error } = result.value; + certificateId = certificate.certificateId; + errorMessage = error instanceof Error ? error.message : String(error); + + const existingSyncRecord = syncRecordsByCertId.get(certificateId); + if (existingSyncRecord) { + await certificateSyncDAL.updateSyncStatus( + pkiSync.id, + certificateId, + CertificateSyncStatus.Failed, + errorMessage + ); + } + }) + ); + + return { + uploaded: successfulUploads.filter((result) => !result.value.certificate.isUpdate).length, + updated: successfulUploads.filter((result) => result.value.certificate.isUpdate).length, + removed: removedCount, + failedRemovals: failedRemovals.length, + skipped: validationErrors.length, + details: { + failedUploads: failedUploads.map((result) => { + if (result.status === "rejected") { + return { + name: "unknown", + error: result.reason instanceof Error ? result.reason.message : String(result.reason) + }; + } + const { certificate, error } = result.value; + return { + name: certificate.name, + error: error instanceof Error ? error.message : String(error) + }; + }), + failedRemovals, + validationErrors + } + }; + }; + + const importCertificates = async (): Promise => { + throw new Error("Chef PKI Sync does not support importing certificates from Chef data bags"); + }; + + const removeCertificates = async ( + sync: TPkiSyncWithCredentials, + certificateNames: string[], + deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap } + ): Promise => { + const chefPkiSync = sync as unknown as TChefPkiSyncWithCredentials; + const { + connection, + destinationConfig: { dataBagName } + } = chefPkiSync; + const { serverUrl, userName, privateKey, orgName } = connection.credentials; + + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(sync.id); + const certificateIdsToRemove: string[] = []; + const itemsToRemove: string[] = []; + + for (const certName of certificateNames) { + const certificateData = deps?.certificateMap?.[certName]; + if (certificateData?.certificateId && typeof certificateData.certificateId === "string") { + const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateData.certificateId); + if (syncRecord) { + certificateIdsToRemove.push(certificateData.certificateId); + if (syncRecord.externalIdentifier) { + itemsToRemove.push(syncRecord.externalIdentifier); + } + } + } else { + const targetName = certName; + const syncRecord = existingSyncRecords.find((record) => record.externalIdentifier === targetName); + if (syncRecord && syncRecord.certificateId) { + certificateIdsToRemove.push(syncRecord.certificateId); + itemsToRemove.push(targetName); + } + } + } + + const removalPromises = itemsToRemove.map(async (itemName) => { + try { + await withRateLimitRetry( + () => + removeChefDataBagItem({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName: itemName + }), + { + operation: "remove-chef-data-bag-item", + syncId: sync.id + } + ); + } catch (error) { + logger.error( + { + syncId: sync.id, + itemName, + error: error instanceof Error ? error.message : String(error) + }, + "Failed to remove Chef data bag item during certificate removal" + ); + } + }); + + await Promise.allSettled(removalPromises); + + if (certificateIdsToRemove.length > 0) { + await certificateSyncDAL.removeCertificates(sync.id, certificateIdsToRemove); + } + }; + + return { + syncCertificates, + importCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-list-constants.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-list-constants.ts new file mode 100644 index 000000000..1142da2ac --- /dev/null +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-list-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; + +export const CHEF_PKI_SYNC_LIST_OPTION = { + name: "Chef" as const, + connection: AppConnection.Chef, + destination: PkiSync.Chef, + canImportCertificates: false, + canRemoveCertificates: true +} as const; diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts new file mode 100644 index 000000000..d52a20408 --- /dev/null +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-schemas.ts @@ -0,0 +1,113 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; + +import { CHEF_PKI_SYNC_CERTIFICATE_NAMING, CHEF_PKI_SYNC_DATA_BAG_NAMING } from "./chef-pki-sync-constants"; + +export const ChefPkiSyncConfigSchema = z.object({ + dataBagName: z + .string() + .trim() + .min(1, "Data bag name required") + .max(255, "Data bag name cannot exceed 255 characters") + .refine( + (name) => CHEF_PKI_SYNC_DATA_BAG_NAMING.NAME_PATTERN.test(name), + "Data bag name can only contain alphanumeric characters, underscores, and hyphens" + ) +}); + +const ChefFieldMappingsSchema = z.object({ + certificate: z.string().min(1, "Certificate field name is required").default("certificate"), + privateKey: z.string().min(1, "Private key field name is required").default("private_key"), + certificateChain: z.string().min(1, "Certificate chain field name is required").default("certificate_chain"), + caCertificate: z.string().min(1, "CA certificate field name is required").default("ca_certificate") +}); + +const ChefPkiSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), + preserveItemOnRenewal: z.boolean().default(true), + updateExistingCertificates: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (schema) => { + if (!schema) return true; + + if (!schema.includes("{{certificateId}}")) { + return false; + } + + const testName = schema + .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id") + .replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name") + .replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name") + .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); + + const hasForbiddenChars = CHEF_PKI_SYNC_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some((char) => + testName.includes(char) + ); + + return ( + CHEF_PKI_SYNC_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && + !hasForbiddenChars && + testName.length >= CHEF_PKI_SYNC_CERTIFICATE_NAMING.MIN_LENGTH && + testName.length <= CHEF_PKI_SYNC_CERTIFICATE_NAMING.MAX_LENGTH + ); + }, + { + message: + "Certificate item name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, underscores, and hyphens and be 1-255 characters long for Chef data bag items." + } + ), + fieldMappings: ChefFieldMappingsSchema.optional().default({ + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + }) +}); + +export const ChefPkiSyncSchema = PkiSyncSchema.extend({ + destination: z.literal(PkiSync.Chef), + destinationConfig: ChefPkiSyncConfigSchema, + syncOptions: ChefPkiSyncOptionsSchema +}); + +export const CreateChefPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: ChefPkiSyncConfigSchema, + syncOptions: ChefPkiSyncOptionsSchema.optional().default({}), + subscriberId: z.string().nullish(), + connectionId: z.string(), + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() +}); + +export const UpdateChefPkiSyncSchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: ChefPkiSyncConfigSchema.optional(), + syncOptions: ChefPkiSyncOptionsSchema.optional(), + subscriberId: z.string().nullish(), + connectionId: z.string().optional() +}); + +export const ChefPkiSyncListItemSchema = z.object({ + name: z.literal("Chef"), + connection: z.literal(AppConnection.Chef), + destination: z.literal(PkiSync.Chef), + canImportCertificates: z.literal(false), + canRemoveCertificates: z.literal(true) +}); + +export { ChefFieldMappingsSchema }; diff --git a/backend/src/services/pki-sync/chef/chef-pki-sync-types.ts b/backend/src/services/pki-sync/chef/chef-pki-sync-types.ts new file mode 100644 index 000000000..52ea83ea6 --- /dev/null +++ b/backend/src/services/pki-sync/chef/chef-pki-sync-types.ts @@ -0,0 +1,59 @@ +import { z } from "zod"; + +import { TChefConnection } from "@app/ee/services/app-connections/chef/chef-connection-types"; + +import { + ChefFieldMappingsSchema, + ChefPkiSyncConfigSchema, + ChefPkiSyncSchema, + CreateChefPkiSyncSchema, + UpdateChefPkiSyncSchema +} from "./chef-pki-sync-schemas"; + +export type TChefPkiSyncConfig = z.infer; + +export type TChefFieldMappings = z.infer; + +export type TChefPkiSync = z.infer; + +export type TChefPkiSyncInput = z.infer; + +export type TChefPkiSyncUpdate = z.infer; + +export type TChefPkiSyncWithCredentials = TChefPkiSync & { + connection: TChefConnection; +}; + +export interface ChefCertificateDataBagItem { + id: string; + [key: string]: string; +} + +export interface SyncCertificatesResult { + uploaded: number; + updated: number; + removed: number; + failedRemovals: number; + skipped: number; + details?: { + failedUploads?: Array<{ name: string; error: string }>; + failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; + }; +} + +export interface RemoveCertificatesResult { + removed: number; + failed: number; + skipped: number; +} + +export interface CertificateImportRequest { + id: string; + name: string; + certificate: string; + privateKey: string; + certificateChain?: string; + alternativeNames?: string[]; + certificateId?: string; +} diff --git a/backend/src/services/pki-sync/chef/index.ts b/backend/src/services/pki-sync/chef/index.ts new file mode 100644 index 000000000..0ccd62c70 --- /dev/null +++ b/backend/src/services/pki-sync/chef/index.ts @@ -0,0 +1,4 @@ +export * from "./chef-pki-sync-constants"; +export * from "./chef-pki-sync-fns"; +export * from "./chef-pki-sync-schemas"; +export * from "./chef-pki-sync-types"; diff --git a/backend/src/services/pki-sync/pki-sync-dal.ts b/backend/src/services/pki-sync/pki-sync-dal.ts index 460bbfc42..d04344b1e 100644 --- a/backend/src/services/pki-sync/pki-sync-dal.ts +++ b/backend/src/services/pki-sync/pki-sync-dal.ts @@ -4,6 +4,10 @@ import { TDbClient } from "@app/db"; import { TableName, TPkiSyncs } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; +import { + applyProcessedPermissionRulesToQuery, + type ProcessedPermissionRules +} from "@app/lib/knex/permission-filter-utils"; import { PkiSync } from "./pki-sync-enums"; @@ -45,13 +49,15 @@ const basePkiSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: PkiSyncF const basePkiSyncWithSubscriberQuery = ({ filter, db, - tx + tx, + processedRules }: { db: TDbClient; filter?: PkiSyncFindFilter; tx?: Knex; + processedRules?: ProcessedPermissionRules; }) => { - const query = (tx || db.replicaNode())(TableName.PkiSync) + let query = (tx || db.replicaNode())(TableName.PkiSync) .leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`) .leftJoin(TableName.PkiSubscriber, `${TableName.PkiSync}.subscriberId`, `${TableName.PkiSubscriber}.id`) .select(selectAllTableCols(TableName.PkiSync)) @@ -82,6 +88,10 @@ const basePkiSyncWithSubscriberQuery = ({ void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); } + if (processedRules) { + query = applyProcessedPermissionRulesToQuery(query, TableName.PkiSync, processedRules) as typeof query; + } + return query; }; @@ -184,9 +194,18 @@ export const pkiSyncDALFactory = (db: TDbClient) => { } }; - const findByProjectIdWithSubscribers = async (projectId: string, tx?: Knex) => { + const findByProjectIdWithSubscribers = async ( + projectId: string, + processedRules?: ProcessedPermissionRules, + tx?: Knex + ) => { try { - const pkiSyncs = await basePkiSyncWithSubscriberQuery({ filter: { projectId }, db, tx }); + const pkiSyncs = await basePkiSyncWithSubscriberQuery({ + filter: { projectId }, + db, + tx, + processedRules + }); return pkiSyncs.map(expandPkiSyncWithSubscriber); } catch (error) { throw new DatabaseError({ error, name: "Find By Project ID With Subscribers - PKI Sync" }); diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts index 46a7fc975..bea444372 100644 --- a/backend/src/services/pki-sync/pki-sync-enums.ts +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -1,6 +1,8 @@ export enum PkiSync { AzureKeyVault = "azure-key-vault", - AwsCertificateManager = "aws-certificate-manager" + AwsCertificateManager = "aws-certificate-manager", + AwsSecretsManager = "aws-secrets-manager", + Chef = "chef" } export enum PkiSyncStatus { diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts index 961687f85..169cb328f 100644 --- a/backend/src/services/pki-sync/pki-sync-fns.ts +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -10,8 +10,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; import { awsCertificateManagerPkiSyncFactory } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-fns"; +import { AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-secrets-manager/aws-secrets-manager-pki-sync-constants"; +import { awsSecretsManagerPkiSyncFactory } from "./aws-secrets-manager/aws-secrets-manager-pki-sync-fns"; import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants"; import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns"; +import { chefPkiSyncFactory } from "./chef/chef-pki-sync-fns"; +import { CHEF_PKI_SYNC_LIST_OPTION } from "./chef/chef-pki-sync-list-constants"; import { PkiSync } from "./pki-sync-enums"; import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; @@ -19,7 +23,9 @@ const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; const PKI_SYNC_LIST_OPTIONS = { [PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION, - [PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION + [PkiSync.AwsCertificateManager]: AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION, + [PkiSync.AwsSecretsManager]: AWS_SECRETS_MANAGER_PKI_SYNC_LIST_OPTION, + [PkiSync.Chef]: CHEF_PKI_SYNC_LIST_OPTION }; export const enterprisePkiSyncCheck = async ( @@ -162,6 +168,8 @@ export const PkiSyncFns = { dependencies: { appConnectionDAL: Pick; kmsService: Pick; + certificateDAL: TCertificateDALFactory; + certificateSyncDAL: TCertificateSyncDALFactory; } ): Promise => { switch (pkiSync.destination) { @@ -175,6 +183,14 @@ export const PkiSyncFns = { "AWS Certificate Manager does not support importing certificates into Infisical (private keys cannot be extracted)" ); } + case PkiSync.AwsSecretsManager: { + throw new Error("AWS Secrets Manager does not support importing certificates into Infisical"); + } + case PkiSync.Chef: { + throw new Error( + "Chef does not support importing certificates into Infisical (private keys cannot be extracted securely)" + ); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -203,7 +219,7 @@ export const PkiSyncFns = { }> => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault as PkiSync); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({ appConnectionDAL: dependencies.appConnectionDAL, kmsService: dependencies.kmsService, @@ -213,7 +229,7 @@ export const PkiSyncFns = { return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); } case PkiSync.AwsCertificateManager: { - checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager as PkiSync); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({ appConnectionDAL: dependencies.appConnectionDAL, kmsService: dependencies.kmsService, @@ -222,6 +238,22 @@ export const PkiSyncFns = { }); return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); } + case PkiSync.AwsSecretsManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsSecretsManager as PkiSync); + const awsSecretsManagerPkiSync = awsSecretsManagerPkiSyncFactory({ + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + return awsSecretsManagerPkiSync.syncCertificates(pkiSync, certificateMap); + } + case PkiSync.Chef: { + checkPkiSyncDestination(pkiSync, PkiSync.Chef as PkiSync); + const chefPkiSync = chefPkiSyncFactory({ + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + return chefPkiSync.syncCertificates(pkiSync, certificateMap); + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } @@ -240,7 +272,7 @@ export const PkiSyncFns = { ): Promise => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { - checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); + checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault as PkiSync); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({ appConnectionDAL: dependencies.appConnectionDAL, kmsService: dependencies.kmsService, @@ -254,7 +286,7 @@ export const PkiSyncFns = { break; } case PkiSync.AwsCertificateManager: { - checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); + checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager as PkiSync); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({ appConnectionDAL: dependencies.appConnectionDAL, kmsService: dependencies.kmsService, @@ -267,6 +299,27 @@ export const PkiSyncFns = { }); break; } + case PkiSync.AwsSecretsManager: { + checkPkiSyncDestination(pkiSync, PkiSync.AwsSecretsManager as PkiSync); + const awsSecretsManagerPkiSync = awsSecretsManagerPkiSyncFactory({ + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + await awsSecretsManagerPkiSync.removeCertificates(pkiSync, dependencies.certificateMap); + break; + } + case PkiSync.Chef: { + checkPkiSyncDestination(pkiSync, PkiSync.Chef as PkiSync); + const chefPkiSync = chefPkiSyncFactory({ + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + await chefPkiSync.removeCertificates(pkiSync, certificateNames, { + certificateSyncDAL: dependencies.certificateSyncDAL, + certificateMap: dependencies.certificateMap + }); + break; + } default: throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); } diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts index 5c416b513..a7edcbc11 100644 --- a/backend/src/services/pki-sync/pki-sync-maps.ts +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -4,10 +4,14 @@ import { PkiSync } from "./pki-sync-enums"; export const PKI_SYNC_NAME_MAP: Record = { [PkiSync.AzureKeyVault]: "Azure Key Vault", - [PkiSync.AwsCertificateManager]: "AWS Certificate Manager" + [PkiSync.AwsCertificateManager]: "AWS Certificate Manager", + [PkiSync.AwsSecretsManager]: "AWS Secrets Manager", + [PkiSync.Chef]: "Chef" }; export const PKI_SYNC_CONNECTION_MAP: Record = { [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault, - [PkiSync.AwsCertificateManager]: AppConnection.AWS + [PkiSync.AwsCertificateManager]: AppConnection.AWS, + [PkiSync.AwsSecretsManager]: AppConnection.AWS, + [PkiSync.Chef]: AppConnection.Chef }; diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts index 608162ead..c264176f9 100644 --- a/backend/src/services/pki-sync/pki-sync-queue.ts +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -26,6 +26,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; +import { extractRootCaFromChain, removeRootCaFromChain } from "../certificate-common/certificate-utils"; import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; @@ -180,11 +181,16 @@ export const pkiSyncQueueFactory = ({ (cert, index, self) => self.findIndex((c) => c.id === cert.id) === index ); - if (uniqueCertificates.length === 0) { + const activeCertificates = uniqueCertificates.filter((cert) => { + const typedCert = cert as TCertificates; + return !typedCert.renewedByCertificateId; + }); + + if (activeCertificates.length === 0) { return { certificateMap, certificateMetadata }; } - certificates = uniqueCertificates; + certificates = activeCertificates; for (const certificate of certificates) { const cert = certificate as TCertificates; @@ -231,13 +237,15 @@ export const pkiSyncQueueFactory = ({ } let certificateChain: string | undefined; + let caCertificate: string | undefined; try { if (certBody.encryptedCertificateChain) { const decryptedCertChain = await kmsDecryptor({ cipherTextBlob: certBody.encryptedCertificateChain }); certificateChain = decryptedCertChain.toString(); - } else if (certificate.caCertId) { + } + if (certificate.caCertId) { const { caCert, caCertChain } = await getCaCertChain({ caCertId: certificate.caCertId, certificateAuthorityDAL, @@ -245,7 +253,10 @@ export const pkiSyncQueueFactory = ({ projectDAL, kmsService }); - certificateChain = `${caCert}\n${caCertChain}`.trim(); + if (!certBody.encryptedCertificateChain) { + certificateChain = `${caCert}\n${caCertChain}`.trim(); + } + caCertificate = certificateChain ? extractRootCaFromChain(certificateChain) : caCert; } } catch (chainError) { logger.warn( @@ -254,10 +265,16 @@ export const pkiSyncQueueFactory = ({ ); // Continue without certificate chain certificateChain = undefined; + caCertificate = undefined; } let certificateName: string; - const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const syncOptions = pkiSync.syncOptions as + | { + certificateNameSchema?: string; + includeRootCa?: boolean; + } + | undefined; const certificateNameSchema = syncOptions?.certificateNameSchema; if (certificateNameSchema) { @@ -289,10 +306,16 @@ export const pkiSyncQueueFactory = ({ alternativeNames.push(originalLegacyName); } + let processedCertificateChain = certificateChain; + if (certificateChain && syncOptions?.includeRootCa === false) { + processedCertificateChain = removeRootCaFromChain(certificateChain); + } + certificateMap[certificateName] = { cert: certificatePem, privateKey: certPrivateKey || "", - certificateChain, + certificateChain: processedCertificateChain, + caCertificate, alternativeNames, certificateId: certificate.id }; diff --git a/backend/src/services/pki-sync/pki-sync-schemas.ts b/backend/src/services/pki-sync/pki-sync-schemas.ts index 95023002e..635103a89 100644 --- a/backend/src/services/pki-sync/pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -7,6 +7,7 @@ import { PkiSync } from "./pki-sync-enums"; export const PkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean(), canRemoveCertificates: z.boolean().optional(), + includeRootCa: z.boolean().optional().default(false), certificateNameSchema: z .string() .optional() diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts index 02a76db2a..f5e574213 100644 --- a/backend/src/services/pki-sync/pki-sync-service.ts +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -4,6 +4,7 @@ import { ActionProjectType, TCertificateSyncs } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; @@ -145,9 +146,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.Create, - subscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: subscriber?.name, + name + }) ); // Get the destination app type based on PKI sync destination @@ -235,9 +237,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.Edit, - currentSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: currentSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: currentSubscriber?.name, + name: pkiSync.name + }) ); if (name && name !== pkiSync.name) { @@ -331,9 +334,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.Delete, - pkiSyncSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: pkiSyncSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: pkiSyncSubscriber?.name, + name: pkiSync.name + }) ); return pkiSyncDAL.deleteById(id); @@ -354,7 +358,13 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); - const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); + const processedRules = getProcessedPermissionRules( + permission, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionSub.PkiSyncs + ); + + const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId, processedRules); if (certificateId) { const syncsWithCertificateInfo = await Promise.all( @@ -406,9 +416,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.Read, - findSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: findSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: findSubscriber?.name, + name: pkiSync.name + }) ); const result = { @@ -442,9 +453,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.SyncCertificates, - syncSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: syncSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: syncSubscriber?.name, + name: pkiSync.name + }) ); await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id }); @@ -483,9 +495,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.ImportCertificates, - importSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: importSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: importSubscriber?.name, + name: pkiSync.name + }) ); await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id }); @@ -516,9 +529,10 @@ export const pkiSyncServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionPkiSyncActions.RemoveCertificates, - removeSubscriber - ? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: removeSubscriber.name }) - : ProjectPermissionSub.PkiSyncs + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: removeSubscriber?.name, + name: pkiSync.name + }) ); await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id }); @@ -549,7 +563,18 @@ export const pkiSyncServiceFactory = ({ projectId: pkiSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs); + let pkiSyncSubscriber; + if (pkiSync.subscriberId) { + pkiSyncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId); + } + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + subject(ProjectPermissionSub.PkiSyncs, { + subscriberName: pkiSyncSubscriber?.name, + name: pkiSync.name + }) + ); await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId); @@ -588,7 +613,12 @@ export const pkiSyncServiceFactory = ({ projectId: pkiSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + subject(ProjectPermissionSub.PkiSyncs, { + name: pkiSync.name + }) + ); const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds); @@ -626,7 +656,12 @@ export const pkiSyncServiceFactory = ({ projectId: pkiSync.projectId }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + subject(ProjectPermissionSub.PkiSyncs, { + name: pkiSync.name + }) + ); const result = await certificateSyncDAL.findWithDetails({ pkiSyncId, diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts index f42f64a1b..fa76ddb55 100644 --- a/backend/src/services/pki-sync/pki-sync-types.ts +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -73,7 +73,14 @@ export type TPkiSyncListItem = TPkiSync & { export type TCertificateMap = Record< string, - { cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[]; certificateId?: string } + { + cert: string; + privateKey: string; + certificateChain?: string; + caCertificate?: string; + alternativeNames?: string[]; + certificateId?: string; + } >; export type TCreatePkiSyncDTO = { diff --git a/backend/src/services/pki-templates/pki-templates-service.ts b/backend/src/services/pki-templates/pki-templates-service.ts index e648ab88f..82d856e25 100644 --- a/backend/src/services/pki-templates/pki-templates-service.ts +++ b/backend/src/services/pki-templates/pki-templates-service.ts @@ -466,8 +466,8 @@ export const pkiTemplatesServiceFactory = ({ }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); - const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`; - const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`; + const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/crl/${caCrl.id}/der`; + const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/cert-manager/ca/internal/${ca.id}/certificates/${caCert.id}/der`; const extensions: x509.Extension[] = [ new x509.BasicConstraintsExtension(false), diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 188c985fb..cf1771aba 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -20,6 +20,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, @@ -39,6 +40,7 @@ import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certific import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getProcessedPermissionRules } from "@app/lib/casl/permission-filter-utils"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { DatabaseErrorCode } from "@app/lib/error-codes"; @@ -911,7 +913,7 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, + ProjectPermissionCertificateAuthorityActions.Read, ProjectPermissionSub.CertificateAuthorities ); @@ -963,35 +965,38 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); + const regularFilters = { + projectId, + ...(friendlyName && { friendlyName }), + ...(commonName && { commonName }) + }; + const permissionFilters = getProcessedPermissionRules( + permission, + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + const certificates = forPkiSync - ? await certificateDAL.findActiveCertificatesForSync( - { - projectId, - ...(friendlyName && { friendlyName }), - ...(commonName && { commonName }) - }, - { offset, limit } - ) + ? await certificateDAL.findActiveCertificatesForSync(regularFilters, { offset, limit }, permissionFilters) : await certificateDAL.findWithPrivateKeyInfo( + regularFilters, { - projectId, - ...(friendlyName && { friendlyName }), - ...(commonName && { commonName }) + offset, + limit, + sort: [["notAfter", "desc"]] }, - { offset, limit, sort: [["notAfter", "desc"]] } + permissionFilters ); + const countFilter = { + projectId, + ...(regularFilters.friendlyName && { friendlyName: String(regularFilters.friendlyName) }), + ...(regularFilters.commonName && { commonName: String(regularFilters.commonName) }) + }; + const count = forPkiSync - ? await certificateDAL.countActiveCertificatesForSync({ - projectId, - friendlyName, - commonName - }) - : await certificateDAL.countCertificatesInProject({ - projectId, - friendlyName, - commonName - }); + ? await certificateDAL.countActiveCertificatesForSync(countFilter) + : await certificateDAL.countCertificatesInProject(countFilter); return { certificates, @@ -1984,7 +1989,7 @@ export const projectServiceFactory = ({ projectTypeUrl = "cert-management"; } - const callbackPath = `/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`; + const callbackPath = `/organizations/${project.orgId}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`; await notificationService.createUserNotifications( projectMembers diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 185ab5e94..60310765b 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,4 +1,5 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; +import { TScimServiceFactory } from "@app/ee/services/scim/scim-types"; import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { TKeyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { getConfig } from "@app/lib/config/env"; @@ -29,6 +30,7 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { orgService: TOrgServiceFactory; userNotificationDAL: Pick; keyValueStoreDAL: Pick; + scimService: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -44,6 +46,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ secretVersionV2DAL, identityUniversalAuthClientSecretDAL, serviceTokenService, + scimService, orgService, userNotificationDAL, keyValueStoreDAL @@ -86,6 +89,7 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await secretVersionV2DAL.pruneExcessVersions(); await secretFolderVersionDAL.pruneExcessVersions(); await serviceTokenService.notifyExpiringTokens(); + await scimService.notifyExpiringTokens(); await orgService.notifyInvitedUsers(); await auditLogDAL.pruneAuditLog(); await userNotificationDAL.pruneNotifications(); diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts index 87dd207f1..67170e5f6 100644 --- a/backend/src/services/secret-sharing/secret-sharing-service.ts +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -391,7 +391,7 @@ export const secretSharingServiceFactory = ({ substitutions: { name: secretRequest.name, respondentUsername, - secretRequestUrl: `${appCfg.SITE_URL}/organization/secret-sharing?selectedTab=request-secret` + secretRequestUrl: `${appCfg.SITE_URL}/organizations/${secretRequest.orgId}/secret-sharing?selectedTab=request-secret` }, template: SmtpTemplates.SecretRequestCompleted }); diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 529634a6d..8829d4622 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -112,7 +112,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.AWSParameterStore]: [], - [SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"], + [SecretSync.AWSSecretsManager]: ["mappingBehavior"], [SecretSync.GitHub]: [], [SecretSync.GCPSecretManager]: [], [SecretSync.AzureKeyVault]: [], diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index f6e23dded..fd15dc029 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -932,7 +932,7 @@ export const secretSyncQueueFactory = ({ break; } - const baseProjectPath = `/projects/secret-management/${projectId}`; + const baseProjectPath = `/organizations/${project.orgId}/projects/secret-management/${projectId}`; const overviewPath = `${baseProjectPath}/overview`; const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`; 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 01a7f6210..c18a51ea4 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 @@ -421,11 +421,12 @@ export const fnSecretBulkDelete = async ({ ); const changes = deletedSecrets - .filter(({ type }) => type === SecretType.Shared) + .filter(({ type, id }) => type === SecretType.Shared && secretVersions[id]) .map(({ id }) => ({ type: CommitType.DELETE, - secretVersionId: secretVersions[id].id + secretVersionId: secretVersions[id]?.id })); + if (changes.length > 0) { if (commitChanges) { commitChanges.push(...changes); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 1f27d1c7b..09647f2d0 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -2263,7 +2263,8 @@ export const secretV2BridgeServiceFactory = ({ ] } }); - if (secretsToDelete.length !== inputSecrets.length) + const secretsToDeleteSet = new Set(secretsToDelete.map((el) => el.key)); + if (secretsToDeleteSet.size !== inputSecrets.length) throw new NotFoundError({ message: `One or more secrets does not exist: ${secretsToDelete.map((el) => el.key).join(", ")}` }); diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 61507d127..5246aa8d1 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -64,6 +64,8 @@ import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; +import { TTelemetryServiceFactory } from "../telemetry/telemetry-service"; +import { PostHogEventTypes } from "../telemetry/telemetry-types"; import { TUserDALFactory } from "../user/user-dal"; import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; @@ -120,6 +122,7 @@ type TSecretQueueFactoryDep = { reminderService: Pick; eventBusService: TEventBusService; licenseService: Pick; + telemetryService: Pick; }; export type TGetSecrets = { @@ -184,7 +187,8 @@ export const secretQueueFactory = ({ eventBusService, licenseService, membershipUserDAL, - membershipRoleDAL + membershipRoleDAL, + telemetryService }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", { @@ -742,7 +746,7 @@ export const secretQueueFactory = ({ environment: jobPayload.environmentName, count: jobPayload.count, projectName: project.name, - integrationUrl: `${appCfg.SITE_URL}/projects/secret-management/${project.id}/integrations?selectedTab=native-integrations` + integrationUrl: `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/integrations?selectedTab=native-integrations` } }); } @@ -1029,6 +1033,29 @@ export const secretQueueFactory = ({ isSynced: response?.isSynced ?? true }); + await telemetryService.sendPostHogEvents({ + event: PostHogEventTypes.IntegrationSynced, + distinctId: `project/${projectId}`, + organizationId: project.orgId, + properties: { + integrationId: integration.id, + integration: integration.integration, + environment, + secretPath, + projectId, + url: integration.url ?? undefined, + app: integration.app ?? undefined, + appId: integration.appId ?? undefined, + targetEnvironment: integration.targetEnvironment ?? undefined, + targetEnvironmentId: integration.targetEnvironmentId ?? undefined, + targetService: integration.targetService ?? undefined, + targetServiceId: integration.targetServiceId ?? undefined, + path: integration.path ?? undefined, + region: integration.region ?? undefined, + isManualSync: isManual ?? false + } + }); + // May be undefined, if it's undefined we assume the sync was successful, hence the strict equality type check. if (response?.isSynced === false) { integrationsFailedToSync.push({ diff --git a/backend/src/services/service-token/service-token-dal.ts b/backend/src/services/service-token/service-token-dal.ts index adb2f325a..ae2cd3574 100644 --- a/backend/src/services/service-token/service-token-dal.ts +++ b/backend/src/services/service-token/service-token-dal.ts @@ -30,28 +30,35 @@ export const serviceTokenDALFactory = (db: TDbClient) => { const findExpiringTokens = async (tx?: Knex, batchSize = 500, offset = 0) => { try { - const batch: { name: string; projectName: string; createdByEmail: string; id: string; projectId: string }[] = - await (tx || db.replicaNode())(TableName.ServiceToken) - .leftJoin( - TableName.Users, - `${TableName.Users}.id`, - db.raw(`${TableName.ServiceToken}."createdBy"::uuid`) - ) - .join(TableName.Project, `${TableName.Project}.id`, `${TableName.ServiceToken}.projectId`) - .whereRaw( - `${TableName.ServiceToken}."expiresAt" < NOW() + INTERVAL '1 day' AND ${TableName.ServiceToken}."expiryNotificationSent" = false` - ) - .whereNotNull(`${TableName.Users}.email`) - .select( - db.ref("id").withSchema(TableName.ServiceToken), - db.ref("name").withSchema(TableName.ServiceToken), - db.ref("projectId").withSchema(TableName.ServiceToken), - db.ref("createdBy").withSchema(TableName.ServiceToken), - db.ref("email").withSchema(TableName.Users).as("createdByEmail"), - db.ref("name").withSchema(TableName.Project).as("projectName") - ) - .limit(batchSize) - .offset(offset); + const batch: { + name: string; + projectName: string; + createdByEmail: string; + id: string; + projectId: string; + orgId: string; + }[] = await (tx || db.replicaNode())(TableName.ServiceToken) + .leftJoin( + TableName.Users, + `${TableName.Users}.id`, + db.raw(`${TableName.ServiceToken}."createdBy"::uuid`) + ) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.ServiceToken}.projectId`) + .whereRaw( + `${TableName.ServiceToken}."expiresAt" < NOW() + INTERVAL '1 day' AND ${TableName.ServiceToken}."expiryNotificationSent" = false` + ) + .whereNotNull(`${TableName.Users}.email`) + .select( + db.ref("id").withSchema(TableName.ServiceToken), + db.ref("name").withSchema(TableName.ServiceToken), + db.ref("projectId").withSchema(TableName.ServiceToken), + db.ref("createdBy").withSchema(TableName.ServiceToken), + db.ref("email").withSchema(TableName.Users).as("createdByEmail"), + db.ref("name").withSchema(TableName.Project).as("projectName"), + db.ref("orgId").withSchema(TableName.Project).as("orgId") + ) + .limit(batchSize) + .offset(offset); return batch; } catch (err) { diff --git a/backend/src/services/service-token/service-token-service.ts b/backend/src/services/service-token/service-token-service.ts index 081b99208..8f7b0a1b7 100644 --- a/backend/src/services/service-token/service-token-service.ts +++ b/backend/src/services/service-token/service-token-service.ts @@ -214,6 +214,8 @@ export const serviceTokenServiceFactory = ({ break; } + const successfullyNotifiedTokenIds: string[] = []; + // eslint-disable-next-line no-await-in-loop await Promise.all( expiringTokens.map(async (token) => { @@ -225,16 +227,22 @@ export const serviceTokenServiceFactory = ({ substitutions: { tokenName: token.name, projectName: token.projectName, - url: `${appCfg.SITE_URL}/projects/secret-management/${token.projectId}/access-management?selectedTab=service-tokens` + url: `${appCfg.SITE_URL}/organizations/${token.orgId}/projects/secret-management/${token.projectId}/access-management?selectedTab=service-tokens` } }); - await serviceTokenDAL.update({ id: token.id }, { expiryNotificationSent: true }); + successfullyNotifiedTokenIds.push(token.id); } catch (error) { logger.error(error, `Failed to send expiration notification for token ${token.id}:`); } }) ); + // Batch update all successfully notified tokens in a single query + if (successfullyNotifiedTokenIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await serviceTokenDAL.update({ $in: { id: successfullyNotifiedTokenIds } }, { expiryNotificationSent: true }); + } + processedCount += expiringTokens.length; offset += batchSize; } diff --git a/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx b/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx new file mode 100644 index 000000000..94e2e8f6a --- /dev/null +++ b/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx @@ -0,0 +1,68 @@ +import { Heading, Section, Text } from "@react-email/components"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface DynamicSecretLeaseRevocationFailedTemplateProps + extends Omit { + siteUrl: string; + dynamicSecretLeaseUrl: string; + dynamicSecretName: string; + projectName: string; + environmentSlug: string; + errorMessage: string; +} + +export const DynamicSecretLeaseRevocationFailedTemplate = ({ + siteUrl, + dynamicSecretLeaseUrl, + dynamicSecretName, + projectName, + environmentSlug, + errorMessage +}: DynamicSecretLeaseRevocationFailedTemplateProps) => { + return ( + + + Dynamic Secret Lease Revocation Failed + +
+ + One or more leases for the dynamic secret {dynamicSecretName} in project{" "} + {projectName} and environment {environmentSlug} have failed to revoke after + multiple attempts. + + + Please review the dynamic secret leases and attempt to revoke them again. + +
+ +
+ + Latest error message + + {errorMessage} +
+ +
+ View Dynamic Secret Leases +
+
+ ); +}; + +export default DynamicSecretLeaseRevocationFailedTemplate; + +DynamicSecretLeaseRevocationFailedTemplate.PreviewProps = { + errorMessage: 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "[REDACTED]" - tuple concurrently updated.', + dynamicSecretLeaseUrl: "https://infisical.com/test", + leaseId: "717d5013-7194-49d9-b6ac-6192328c2914", + dynamicSecretName: "postgres-prod-db", + projectName: "Development Team", + environmentSlug: "dev", + siteUrl: "https://infisical.com" +} as DynamicSecretLeaseRevocationFailedTemplateProps; diff --git a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx index ee09574b6..97ce9b522 100644 --- a/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx +++ b/backend/src/services/smtp/emails/OrgAdminBreakglassAccessTemplate.tsx @@ -7,6 +7,7 @@ import { BaseLink } from "./BaseLink"; interface OrgAdminBreakglassAccessTemplateProps extends Omit { email: string; timestamp: string; + orgId: string; ip: string; userAgent: string; } @@ -15,6 +16,7 @@ export const OrgAdminBreakglassAccessTemplate = ({ email, siteUrl, timestamp, + orgId, ip, userAgent }: OrgAdminBreakglassAccessTemplateProps) => { @@ -36,7 +38,7 @@ export const OrgAdminBreakglassAccessTemplate = ({ {userAgent} If you'd like to disable Admin SSO Bypass, please visit{" "} - Organization Security Settings. + Organization Security Settings. @@ -51,5 +53,6 @@ OrgAdminBreakglassAccessTemplate.PreviewProps = { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15", timestamp: "Tue Apr 29 2025 23:03:27 GMT+0000 (Coordinated Universal Time)", siteUrl: "https://infisical.com", - email: "august@infisical.com" + email: "august@infisical.com", + orgId: "123" } as OrgAdminBreakglassAccessTemplateProps; diff --git a/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx b/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx new file mode 100644 index 000000000..84b9395b2 --- /dev/null +++ b/backend/src/services/smtp/emails/ScimTokenExpiryNoticeTemplate.tsx @@ -0,0 +1,71 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface ScimTokenExpiryNoticeTemplateProps extends Omit { + tokenDescription?: string; + orgName: string; + createdOn: Date; + expiringOn: Date; + url: string; +} + +export const ScimTokenExpiryNoticeTemplate = ({ + tokenDescription, + siteUrl, + orgName, + url, + createdOn, + expiringOn +}: ScimTokenExpiryNoticeTemplateProps) => { + const formatDate = (date: Date) => + date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + + const createdOnDisplay = formatDate(createdOn); + const expiringOnDisplay = formatDate(expiringOn); + + return ( + + + SCIM token expiry notice + +
+ + {tokenDescription ? ( + <> + Your SCIM token {tokenDescription} + + ) : ( + "One of your SCIM tokens" + )}{" "} + for {orgName}, created on {createdOnDisplay}, is scheduled to expire on{" "} + {expiringOnDisplay}. + + + If this token is still needed for your external platform sync, please create a new one before it expires to + avoid disruption to your workflow. + +
+
+ Manage SCIM Tokens +
+
+ ); +}; + +export default ScimTokenExpiryNoticeTemplate; + +ScimTokenExpiryNoticeTemplate.PreviewProps = { + orgName: "Example Organization", + siteUrl: "https://infisical.com", + url: "https://infisical.com", + tokenDescription: "Example SCIM Token", + createdOn: new Date("2025-11-27T00:00:00Z"), + expiringOn: new Date("2025-12-27T00:00:00Z") +} as ScimTokenExpiryNoticeTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 692cacbaf..376f6780e 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -19,6 +19,7 @@ export * from "./PasswordSetupTemplate"; export * from "./PkiExpirationAlertTemplate"; export * from "./ProjectAccessRequestTemplate"; export * from "./ProjectInvitationTemplate"; +export * from "./ScimTokenExpiryNoticeTemplate"; export * from "./ScimUserProvisionedTemplate"; export * from "./SecretApprovalRequestBypassedTemplate"; export * from "./SecretApprovalRequestNeedsReviewTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index cef22009a..e1e2e6041 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -28,6 +28,7 @@ import { PkiExpirationAlertTemplate, ProjectAccessRequestTemplate, ProjectInvitationTemplate, + ScimTokenExpiryNoticeTemplate, ScimUserProvisionedTemplate, SecretApprovalRequestBypassedTemplate, SecretApprovalRequestNeedsReviewTemplate, @@ -43,6 +44,7 @@ import { SubOrganizationInvitationTemplate, UnlockAccountTemplate } from "./emails"; +import DynamicSecretLeaseRevocationFailedTemplate from "./emails/DynamicSecretLeaseRevocationFailedTemplate"; export type TSmtpConfig = SMTPTransport.Options; export type TSmtpSendMail = { @@ -74,6 +76,7 @@ export enum SmtpTemplates { SecretLeakIncident = "secretLeakIncident", WorkspaceInvite = "workspaceInvitation", ScimUserProvisioned = "scimUserProvisioned", + ScimTokenExpired = "scimTokenExpired", PkiExpirationAlert = "pkiExpirationAlert", IntegrationSyncFailed = "integrationSyncFailed", SecretSyncFailed = "secretSyncFailed", @@ -89,7 +92,8 @@ export enum SmtpTemplates { SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected", AccountDeletionConfirmation = "accountDeletionConfirmation", - HealthAlert = "healthAlert" + HealthAlert = "healthAlert", + DynamicSecretLeaseRevocationFailed = "dynamicSecretLeaseRevocationFailed" } export enum SmtpHost { @@ -121,6 +125,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretLeakIncident]: SecretLeakIncidentTemplate, [SmtpTemplates.WorkspaceInvite]: ProjectInvitationTemplate, [SmtpTemplates.ScimUserProvisioned]: ScimUserProvisionedTemplate, + [SmtpTemplates.ScimTokenExpired]: ScimTokenExpiryNoticeTemplate, [SmtpTemplates.SecretRequestCompleted]: SecretRequestCompletedTemplate, [SmtpTemplates.UnlockAccount]: UnlockAccountTemplate, [SmtpTemplates.ServiceTokenExpired]: ServiceTokenExpiryNoticeTemplate, @@ -137,7 +142,8 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate, [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate, - [SmtpTemplates.HealthAlert]: HealthAlertTemplate + [SmtpTemplates.HealthAlert]: HealthAlertTemplate, + [SmtpTemplates.DynamicSecretLeaseRevocationFailed]: DynamicSecretLeaseRevocationFailedTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index de466614a..d2e977605 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -21,6 +21,8 @@ export enum PostHogEventTypes { SecretScannerPush = "cloud secret scan", ProjectCreated = "Project Created", IntegrationCreated = "Integration Created", + IntegrationSynced = "Integration Synced", + IntegrationDeleted = "Integration Deleted", MachineIdentityCreated = "Machine Identity Created", UserOrgInvitation = "User Org Invitation", TelemetryInstanceStats = "Self Hosted Instance Stats", @@ -126,6 +128,47 @@ export type TIntegrationCreatedEvent = { }; }; +export type TIntegrationSyncedEvent = { + event: PostHogEventTypes.IntegrationSynced; + properties: { + projectId: string; + integrationId: string; + integration: string; + environment: string; + secretPath: string; + isManualSync: boolean; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; +}; + +export type TIntegrationDeletedEvent = { + event: PostHogEventTypes.IntegrationDeleted; + properties: { + projectId: string; + integrationId: string; + integration: string; + environment: string; + secretPath: string; + url?: string; + app?: string; + appId?: string; + targetEnvironment?: string; + targetEnvironmentId?: string; + targetService?: string; + targetServiceId?: string; + path?: string; + region?: string; + }; +}; + export type TUserOrgInvitedEvent = { event: PostHogEventTypes.UserOrgInvitation; properties: { @@ -249,6 +292,8 @@ export type TPostHogEvent = { distinctId: string; organizationId?: string } & ( | TUserOrgInvitedEvent | TMachineIdentityCreatedEvent | TIntegrationCreatedEvent + | TIntegrationSyncedEvent + | TIntegrationDeletedEvent | TProjectCreateEvent | TTelemetryInstanceStatsEvent | TSecretRequestCreatedEvent diff --git a/backend/tsconfig.dev.json b/backend/tsconfig.dev.json new file mode 100644 index 000000000..4bcbcd5e1 --- /dev/null +++ b/backend/tsconfig.dev.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@app/*": ["./src/*"], + "@bdd_routes/bdd-nock-router": ["./src/server/routes/bdd/bdd-nock-router.dev.ts"] + } + } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 523e6de5b..db076a30d 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -24,7 +24,8 @@ "skipLibCheck": true, "baseUrl": ".", "paths": { - "@app/*": ["./src/*"] + "@app/*": ["./src/*"], + "@bdd_routes/*": ["./src/server/routes/bdd/*"] }, "jsx": "react-jsx" }, diff --git a/backend/tsup.config.js b/backend/tsup.config.js index e09a21ff2..80ec73a14 100644 --- a/backend/tsup.config.js +++ b/backend/tsup.config.js @@ -2,8 +2,8 @@ import path from "node:path"; import fs from "fs/promises"; -import {replaceTscAliasPaths} from "tsc-alias"; -import {defineConfig} from "tsup"; +import { replaceTscAliasPaths } from "tsc-alias"; +import { defineConfig } from "tsup"; // Instead of using tsx or tsc for building, consider using tsup. // TSX serves as an alternative to Node.js, allowing you to build directly on the Node.js runtime. @@ -29,7 +29,7 @@ export default defineConfig({ external: ["../../../frontend/node_modules/next/dist/server/next-server.js"], outDir: "dist", tsconfig: "./tsconfig.json", - entry: ["./src"], + entry: ["./src", "!./src/**/*.dev.ts"], sourceMap: true, skipNodeModulesBundle: true, esbuildPlugins: [ @@ -45,22 +45,22 @@ export default defineConfig({ const isRelativePath = args.path.startsWith("."); const absPath = isRelativePath ? path.join(args.resolveDir, args.path) - : path.join(args.path.replace("@app", "./src")); + : path.join(args.path.replace("@app", "./src").replace("@bdd_routes", "./src/server/routes/bdd")); const isFile = await fs .stat(`${absPath}.ts`) .then((el) => el.isFile) - .catch(async (err) => { - if (err.code === "ENOTDIR") { - return true; - } + .catch(async (err) => { + if (err.code === "ENOTDIR") { + return true; + } - // If .ts file doesn't exist, try checking for .tsx file - return fs - .stat(`${absPath}.tsx`) - .then((el) => el.isFile) - .catch((err) => err.code === "ENOTDIR"); - }); + // If .ts file doesn't exist, try checking for .tsx file + return fs + .stat(`${absPath}.tsx`) + .then((el) => el.isFile) + .catch((err) => err.code === "ENOTDIR"); + }); return { path: isFile ? `${args.path}.mjs` : `${args.path}/index.mjs`, diff --git a/backend/vitest.e2e.config.mts b/backend/vitest.e2e.config.mts index 83554b818..a37ca9518 100644 --- a/backend/vitest.e2e.config.mts +++ b/backend/vitest.e2e.config.mts @@ -28,7 +28,8 @@ export default defineConfig({ }, resolve: { alias: { - "@app": path.resolve(__dirname, "./src") + "@app": path.resolve(__dirname, "./src"), + "@bdd_routes/bdd-nock-router": path.resolve(__dirname, "./src/server/routes/bdd/bdd-nock-router.dev.ts") } } }); diff --git a/backend/vitest.unit.config.mts b/backend/vitest.unit.config.mts index 97862d288..aa56063a9 100644 --- a/backend/vitest.unit.config.mts +++ b/backend/vitest.unit.config.mts @@ -11,7 +11,8 @@ export default defineConfig({ }, resolve: { alias: { - "@app": path.resolve(__dirname, "./src") + "@app": path.resolve(__dirname, "./src"), + "@bdd_routes/bdd-nock-router": path.resolve(__dirname, "./src/server/routes/bdd/bdd-nock-router.dev.ts") } } }); diff --git a/company/documentation/getting-started/introduction.mdx b/company/documentation/getting-started/introduction.mdx index 55b483194..74694703e 100644 --- a/company/documentation/getting-started/introduction.mdx +++ b/company/documentation/getting-started/introduction.mdx @@ -95,12 +95,4 @@ Depending on your use case, it might be helpful to look into some of the resourc > Fetch secrets via HTTP request. - - Explore integrations for GitHub, Vercel, AWS, and more. - diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e60ef1ba5..b75b6df22 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -71,6 +71,7 @@ services: ports: - 4000:4000 - 9464:9464 # for OTEL collection of Prometheus metrics + - 9229:9229 # For debugger access environment: - NODE_ENV=development - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable diff --git a/docker-swarm/.env-example b/docker-swarm/.env-example index a30e3bba6..8a132914b 100644 --- a/docker-swarm/.env-example +++ b/docker-swarm/.env-example @@ -25,22 +25,6 @@ SMTP_FROM_NAME= SMTP_USERNAME= SMTP_PASSWORD= -# Integration -# Optional only if integration is used -CLIENT_ID_HEROKU= -CLIENT_ID_VERCEL= -CLIENT_ID_NETLIFY= -CLIENT_ID_GITHUB= -CLIENT_ID_GITLAB= -CLIENT_ID_BITBUCKET= -CLIENT_SECRET_HEROKU= -CLIENT_SECRET_VERCEL= -CLIENT_SECRET_NETLIFY= -CLIENT_SECRET_GITHUB= -CLIENT_SECRET_GITLAB= -CLIENT_SECRET_BITBUCKET= -CLIENT_SLUG_VERCEL= - # Sentry (optional) for monitoring errors SENTRY_DSN= diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx index 9cc42ed7f..ef98a7fa1 100644 --- a/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/acme/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/pki/ca/acme" +openapi: "POST /api/v1/cert-manager/ca/acme" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx index 9decc3b6e..eac21ef03 100644 --- a/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/acme/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/pki/ca/acme/{caName}" +openapi: "DELETE /api/v1/cert-manager/ca/acme/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx index 35bd70727..569efb9af 100644 --- a/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/acme/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/pki/ca/acme" +openapi: "GET /api/v1/cert-manager/ca/acme" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx index a80e31f9a..55f022a3c 100644 --- a/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/acme/read.mdx @@ -1,4 +1,4 @@ --- title: "Read" -openapi: "GET /api/v1/pki/ca/acme/{caName}" +openapi: "GET /api/v1/cert-manager/ca/acme/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx b/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx index 69f758771..f9be04fda 100644 --- a/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/acme/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/pki/ca/acme/{caName}" +openapi: "PATCH /api/v1/cert-manager/ca/acme/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/cert.mdx b/docs/api-reference/endpoints/certificate-authorities/cert.mdx deleted file mode 100644 index 3706e0b11..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve certificate / chain" -openapi: "GET /api/v1/pki/ca/{caId}/certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/create.mdx b/docs/api-reference/endpoints/certificate-authorities/create.mdx deleted file mode 100644 index 276015228..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/create.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Create (Deprecated)" -openapi: "POST /api/v1/pki/ca" ---- - - - This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/create). - \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/crl.mdx deleted file mode 100644 index 428c3377e..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/crl.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List CRLs" -openapi: "GET /api/v1/pki/ca/{caId}/crls" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/csr.mdx b/docs/api-reference/endpoints/certificate-authorities/csr.mdx deleted file mode 100644 index 2477a629e..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/csr.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get CSR" -openapi: "GET /api/v1/pki/ca/{caId}/csr" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/delete.mdx deleted file mode 100644 index c4ded070d..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/delete.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Delete (Deprecated)" -openapi: "DELETE /api/v1/pki/ca/{caId}" ---- - - - This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/delete). - \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx deleted file mode 100644 index 7f0e40f95..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Import certificate" -openapi: "POST /api/v1/pki/ca/{caId}/import-certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/cert.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/cert.mdx new file mode 100644 index 000000000..476746c55 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve certificate / chain" +openapi: "GET /api/v1/cert-manager/ca/internal/{caId}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx index babc144f2..9f1567c61 100644 --- a/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/internal/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/pki/ca/internal" +openapi: "POST /api/v1/cert-manager/ca/internal" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/crl.mdx new file mode 100644 index 000000000..3a9bb4c62 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/crl.mdx @@ -0,0 +1,4 @@ +--- +title: "List CRLs" +openapi: "GET /api/v1/cert-manager/ca/internal/{caId}/crls" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/csr.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/csr.mdx new file mode 100644 index 000000000..4a2e72505 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/csr.mdx @@ -0,0 +1,4 @@ +--- +title: "Get CSR" +openapi: "GET /api/v1/cert-manager/ca/internal/{caId}/csr" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx index b1b7f20a7..7e38781ec 100644 --- a/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/internal/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/pki/ca/internal/{caName}" +openapi: "DELETE /api/v1/cert-manager/ca/internal/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/import-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/import-cert.mdx new file mode 100644 index 000000000..ba4aeb2d0 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/import-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Import certificate" +openapi: "POST /api/v1/cert-manager/ca/internal/{caId}/import-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/list-ca-certs.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/list-ca-certs.mdx new file mode 100644 index 000000000..b29444b73 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/list-ca-certs.mdx @@ -0,0 +1,4 @@ +--- +title: "List CA certificates" +openapi: "GET /api/v1/cert-manager/ca/internal/{caId}/ca-certificates" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx index 43f2b7108..bfced601b 100644 --- a/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/internal/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/pki/ca/internal" +openapi: "GET /api/v1/cert-manager/ca/internal" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx index d269564cf..85f9582df 100644 --- a/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/internal/read.mdx @@ -1,4 +1,4 @@ --- title: "Read" -openapi: "GET /api/v1/pki/ca/internal/{caName}" +openapi: "GET /api/v1/cert-manager/ca/internal/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/renew.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/renew.mdx new file mode 100644 index 000000000..d32963d3f --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/renew.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew" +openapi: "POST /api/v1/cert-manager/ca/internal/{caId}/renew" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/sign-intermediate.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/sign-intermediate.mdx new file mode 100644 index 000000000..e6d185f95 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/internal/sign-intermediate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign intermediate certificate" +openapi: "POST /api/v1/cert-manager/ca/internal/{caId}/sign-intermediate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx b/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx index b01899884..770704e4c 100644 --- a/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx +++ b/docs/api-reference/endpoints/certificate-authorities/internal/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/pki/ca/internal/{caName}" +openapi: "PATCH /api/v1/cert-manager/ca/internal/{id}" --- diff --git a/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx b/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx deleted file mode 100644 index ce253807c..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/list-ca-certs.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List CA certificates" -openapi: "GET /api/v1/pki/ca/{caId}/ca-certificates" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/list.mdx b/docs/api-reference/endpoints/certificate-authorities/list.mdx deleted file mode 100644 index 81dd64af6..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/list.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "List (Deprecated)" -openapi: "GET /api/v2/workspace/{slug}/cas" ---- - - - This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/list). - \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/read.mdx b/docs/api-reference/endpoints/certificate-authorities/read.mdx deleted file mode 100644 index bca5121bd..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/read.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Retrieve (Deprecated)" -openapi: "GET /api/v1/pki/ca/{caId}" ---- - - - This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/read). - \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/renew.mdx b/docs/api-reference/endpoints/certificate-authorities/renew.mdx deleted file mode 100644 index 901811f2d..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/renew.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Renew" -openapi: "POST /api/v1/pki/ca/{caId}/renew" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx deleted file mode 100644 index 310bbea26..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign intermediate certificate" -openapi: "POST /api/v1/pki/ca/{caId}/sign-intermediate" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/update.mdx b/docs/api-reference/endpoints/certificate-authorities/update.mdx deleted file mode 100644 index 0cd88ebf6..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/update.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Update (Deprecated)" -openapi: "PATCH /api/v1/pki/ca/{caId}" ---- - - - This endpoint is deprecated. Please use the internal CA endpoint [here](/api-reference/endpoints/certificate-authorities/internal/update). - \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/create.mdx b/docs/api-reference/endpoints/certificate-profiles/create.mdx index e24e42207..c6ed780e7 100644 --- a/docs/api-reference/endpoints/certificate-profiles/create.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/create.mdx @@ -1,4 +1,4 @@ --- title: "Create" -openapi: "POST /api/v1/pki/certificate-profiles" +openapi: "POST /api/v1/cert-manager/certificate-profiles" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/delete.mdx b/docs/api-reference/endpoints/certificate-profiles/delete.mdx index a1762640a..966fce508 100644 --- a/docs/api-reference/endpoints/certificate-profiles/delete.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/pki/certificate-profiles/{id}" +openapi: "DELETE /api/v1/cert-manager/certificate-profiles/{id}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx index 38e0c20f8..c3f73e6ac 100644 --- a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get by ID" -openapi: "GET /api/v1/pki/certificate-profiles/{id}" +openapi: "GET /api/v1/cert-manager/certificate-profiles/{id}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx index 9013020d6..4bcf7b73a 100644 --- a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx @@ -1,4 +1,4 @@ --- title: "Get by Slug" -openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}" +openapi: "GET /api/v1/cert-manager/certificate-profiles/slug/{slug}" --- \ No newline at end of file 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 index aa033418d..c1f3daebd 100644 --- a/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx @@ -1,4 +1,4 @@ --- title: "Get Latest Active Certificate Bundle" -openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates/latest-active-bundle" +openapi: "GET /api/v1/cert-manager/certificate-profiles/{id}/certificates/latest-active-bundle" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx index d0a690f76..2fac15d1d 100644 --- a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx @@ -1,4 +1,4 @@ --- title: "List Certificates" -openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates" +openapi: "GET /api/v1/cert-manager/certificate-profiles/{id}/certificates" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list.mdx b/docs/api-reference/endpoints/certificate-profiles/list.mdx index c0f461512..869b0d805 100644 --- a/docs/api-reference/endpoints/certificate-profiles/list.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/list.mdx @@ -1,4 +1,4 @@ --- title: "List" -openapi: "GET /api/v1/pki/certificate-profiles" +openapi: "GET /api/v1/cert-manager/certificate-profiles" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/update.mdx b/docs/api-reference/endpoints/certificate-profiles/update.mdx index e483cf030..c62af15e0 100644 --- a/docs/api-reference/endpoints/certificate-profiles/update.mdx +++ b/docs/api-reference/endpoints/certificate-profiles/update.mdx @@ -1,4 +1,4 @@ --- title: "Update" -openapi: "PATCH /api/v1/pki/certificate-profiles/{id}" +openapi: "PATCH /api/v1/cert-manager/certificate-profiles/{id}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-syncs/list.mdx b/docs/api-reference/endpoints/certificate-syncs/list.mdx index 6de2c2d1b..718a07379 100644 --- a/docs/api-reference/endpoints/certificate-syncs/list.mdx +++ b/docs/api-reference/endpoints/certificate-syncs/list.mdx @@ -1,4 +1,4 @@ --- title: "List PKI Syncs" -openapi: "GET /api/v1/pki/syncs" +openapi: "GET /api/v1/cert-manager/syncs" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-syncs/options.mdx b/docs/api-reference/endpoints/certificate-syncs/options.mdx index ab2d11e48..148476128 100644 --- a/docs/api-reference/endpoints/certificate-syncs/options.mdx +++ b/docs/api-reference/endpoints/certificate-syncs/options.mdx @@ -1,4 +1,4 @@ --- title: "Options" -openapi: "GET /api/v1/pki/syncs/options" +openapi: "GET /api/v1/cert-manager/syncs/options" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx deleted file mode 100644 index 2fb4da177..000000000 --- a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v2/certificate-templates" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx deleted file mode 100644 index dc92ca55a..000000000 --- a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v2/certificate-templates/{id}" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx deleted file mode 100644 index c97389a1d..000000000 --- a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get by ID" -openapi: "GET /api/v2/certificate-templates/{id}" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx deleted file mode 100644 index ab752e851..000000000 --- a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List" -openapi: "GET /api/v2/certificate-templates" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx deleted file mode 100644 index 7bdeca14e..000000000 --- a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v2/certificate-templates/{id}" ---- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx new file mode 100644 index 000000000..af59acd8d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/cert-manager/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx new file mode 100644 index 000000000..9232cdef8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/cert-manager/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx new file mode 100644 index 000000000..8691cadb0 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/cert-manager/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/list.mdx b/docs/api-reference/endpoints/certificate-templates/list.mdx new file mode 100644 index 000000000..5cdedb2f8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/cert-manager/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx new file mode 100644 index 000000000..229bf6d14 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/cert-manager/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/bundle.mdx b/docs/api-reference/endpoints/certificates/bundle.mdx index 60d37a2d8..5f5b5a8b8 100644 --- a/docs/api-reference/endpoints/certificates/bundle.mdx +++ b/docs/api-reference/endpoints/certificates/bundle.mdx @@ -1,6 +1,6 @@ --- title: "Get Certificate Bundle" -openapi: "GET /api/v1/pki/certificates/{serialNumber}/bundle" +openapi: "GET /api/v1/cert-manager/certificates/{id}/bundle" --- diff --git a/docs/api-reference/endpoints/certificates/cert-body.mdx b/docs/api-reference/endpoints/certificates/cert-body.mdx index e4c3b0123..6437ef847 100644 --- a/docs/api-reference/endpoints/certificates/cert-body.mdx +++ b/docs/api-reference/endpoints/certificates/cert-body.mdx @@ -1,4 +1,4 @@ --- title: "Get Certificate Body / Chain" -openapi: "GET /api/v1/pki/certificates/{serialNumber}/certificate" +openapi: "GET /api/v1/cert-manager/certificates/{id}/certificate" --- diff --git a/docs/api-reference/endpoints/certificates/delete.mdx b/docs/api-reference/endpoints/certificates/delete.mdx index 27042af42..2b3d0a74d 100644 --- a/docs/api-reference/endpoints/certificates/delete.mdx +++ b/docs/api-reference/endpoints/certificates/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete" -openapi: "DELETE /api/v1/pki/certificates/{serialNumber}" +openapi: "DELETE /api/v1/cert-manager/certificates/{id}" --- diff --git a/docs/api-reference/endpoints/certificates/issue-certificate.mdx b/docs/api-reference/endpoints/certificates/issue-certificate.mdx index 13a464b67..b77b3caac 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/v3/pki/certificates/issue-certificate" +openapi: "POST /api/v1/cert-manager/certificates/issue-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/private-key.mdx b/docs/api-reference/endpoints/certificates/private-key.mdx index d0b93e65c..858baf347 100644 --- a/docs/api-reference/endpoints/certificates/private-key.mdx +++ b/docs/api-reference/endpoints/certificates/private-key.mdx @@ -1,4 +1,4 @@ --- title: "Get Certificate Private Key" -openapi: "GET /api/v1/pki/certificates/{serialNumber}/private-key" +openapi: "GET /api/v1/cert-manager/certificates/{id}/private-key" --- diff --git a/docs/api-reference/endpoints/certificates/read.mdx b/docs/api-reference/endpoints/certificates/read.mdx index ce6463dde..d54d05d09 100644 --- a/docs/api-reference/endpoints/certificates/read.mdx +++ b/docs/api-reference/endpoints/certificates/read.mdx @@ -1,4 +1,4 @@ --- title: "Retrieve" -openapi: "GET /api/v1/pki/certificates/{serialNumber}" +openapi: "GET /api/v1/cert-manager/certificates/{id}" --- diff --git a/docs/api-reference/endpoints/certificates/renew.mdx b/docs/api-reference/endpoints/certificates/renew.mdx index b44424369..8f69be6f6 100644 --- a/docs/api-reference/endpoints/certificates/renew.mdx +++ b/docs/api-reference/endpoints/certificates/renew.mdx @@ -1,4 +1,4 @@ --- title: "Renew Certificate" -openapi: "POST /api/v3/pki/certificates/{certificateId}/renew" +openapi: "POST /api/v1/cert-manager/certificates/{id}/renew" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/revoke.mdx b/docs/api-reference/endpoints/certificates/revoke.mdx index e4da73a19..e730412df 100644 --- a/docs/api-reference/endpoints/certificates/revoke.mdx +++ b/docs/api-reference/endpoints/certificates/revoke.mdx @@ -1,4 +1,4 @@ --- title: "Revoke" -openapi: "POST /api/v1/pki/certificates/{serialNumber}/revoke" +openapi: "POST /api/v1/cert-manager/certificates/{id}/revoke" --- diff --git a/docs/api-reference/endpoints/certificates/sign-certificate.mdx b/docs/api-reference/endpoints/certificates/sign-certificate.mdx index 7291025fc..402e8ae08 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/v3/pki/certificates/sign-certificate" +openapi: "POST /api/v1/cert-manager/certificates/sign-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/update-config.mdx b/docs/api-reference/endpoints/certificates/update-config.mdx index 70520bf68..cbfe76b29 100644 --- a/docs/api-reference/endpoints/certificates/update-config.mdx +++ b/docs/api-reference/endpoints/certificates/update-config.mdx @@ -1,4 +1,4 @@ --- title: "Update Certificate Config" -openapi: "PATCH /api/v3/pki/certificates/{certificateId}/config" +openapi: "PATCH /api/v1/cert-manager/certificates/{id}/config" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/integrations/create-auth.mdx b/docs/api-reference/endpoints/integrations/create-auth.mdx deleted file mode 100644 index 5af7a0f9c..000000000 --- a/docs/api-reference/endpoints/integrations/create-auth.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Create Auth" -openapi: "POST /api/v1/integration-auth/access-token" ---- - -## Integration Authentication Parameters - -The integration authentication endpoint is generic and can be used for all native integrations. -For specific integration parameters for a given service, please review the respective documentation below. - - - - - This value must be **aws-secret-manager**. - - - Infisical project id for the integration. - - - The AWS IAM User Access ID. - - - The AWS IAM User Access Secret Key. - - - - Coming Soon - - - Coming Soon - - diff --git a/docs/api-reference/endpoints/integrations/create.mdx b/docs/api-reference/endpoints/integrations/create.mdx deleted file mode 100644 index 0992e91b9..000000000 --- a/docs/api-reference/endpoints/integrations/create.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/integration" ---- - -## Integration Parameters - -The integration creation endpoint is generic and can be used for all native integrations. -For specific integration parameters for a given service, please review the respective documentation below. - - - - - The ID of the integration auth object for authentication with AWS. - Refer [Create Integration Auth](./create-auth) for more info - - - Whether the integration should be active or inactive - - - The secret name used when saving secret in AWS SSM. Used for naming and can be arbitrary. - - - The AWS region of the SSM. Example: `us-east-1` - - - The Infisical environment slug from where secrets will be synced from. Example: `dev` - - - The Infisical folder path from where secrets will be synced from. Example: `/some/path`. The root of the environment is `/`. - - - - Coming Soon - - - Coming Soon - - - diff --git a/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx b/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx deleted file mode 100644 index 5884363fc..000000000 --- a/docs/api-reference/endpoints/integrations/delete-auth-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete Auth By ID" -openapi: "DELETE /api/v1/integration-auth/{integrationAuthId}" ---- diff --git a/docs/api-reference/endpoints/integrations/delete-auth.mdx b/docs/api-reference/endpoints/integrations/delete-auth.mdx deleted file mode 100644 index 93d957903..000000000 --- a/docs/api-reference/endpoints/integrations/delete-auth.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete Auth" -openapi: "DELETE /api/v1/integration-auth" ---- diff --git a/docs/api-reference/endpoints/integrations/delete.mdx b/docs/api-reference/endpoints/integrations/delete.mdx deleted file mode 100644 index 51df56de7..000000000 --- a/docs/api-reference/endpoints/integrations/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/integration/{integrationId}" ---- diff --git a/docs/api-reference/endpoints/integrations/find-auth.mdx b/docs/api-reference/endpoints/integrations/find-auth.mdx deleted file mode 100644 index 439b82935..000000000 --- a/docs/api-reference/endpoints/integrations/find-auth.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get Auth By ID" -openapi: "GET /api/v1/integration-auth/{integrationAuthId}" ---- diff --git a/docs/api-reference/endpoints/integrations/list-auth.mdx b/docs/api-reference/endpoints/integrations/list-auth.mdx deleted file mode 100644 index 3ca961d98..000000000 --- a/docs/api-reference/endpoints/integrations/list-auth.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Auth" -openapi: "GET /api/v1/workspace/{workspaceId}/authorizations" ---- diff --git a/docs/api-reference/endpoints/integrations/list-project-integrations.mdx b/docs/api-reference/endpoints/integrations/list-project-integrations.mdx deleted file mode 100644 index 24ebbf7d8..000000000 --- a/docs/api-reference/endpoints/integrations/list-project-integrations.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Project Integrations" -openapi: "GET /api/v1/workspace/{workspaceId}/integrations" ---- diff --git a/docs/api-reference/endpoints/integrations/update.mdx b/docs/api-reference/endpoints/integrations/update.mdx deleted file mode 100644 index 8567c46ae..000000000 --- a/docs/api-reference/endpoints/integrations/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/integration/{integrationId}" ---- diff --git a/docs/api-reference/endpoints/pki-alerts/create.mdx b/docs/api-reference/endpoints/pki-alerts/create.mdx index d4be026a7..e339dd425 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/v2/pki/alerts" +openapi: "POST /api/v1/cert-manager/alerts" --- diff --git a/docs/api-reference/endpoints/pki-alerts/delete.mdx b/docs/api-reference/endpoints/pki-alerts/delete.mdx index 67429049c..8cf3b8e40 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/v2/pki/alerts/{alertId}" +openapi: "DELETE /api/v1/cert-manager/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki-alerts/read.mdx b/docs/api-reference/endpoints/pki-alerts/read.mdx index 0e0547288..b408e1709 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/v2/pki/alerts/{alertId}" +openapi: "GET /api/v1/cert-manager/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki-alerts/update.mdx b/docs/api-reference/endpoints/pki-alerts/update.mdx index 45f1f1f1f..8e4dbb574 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/v2/pki/alerts/{alertId}" +openapi: "PATCH /api/v1/cert-manager/alerts/{alertId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx index c7b21996e..eaa0b6ca1 100644 --- a/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Add Certificates to Sync" -openapi: "POST /api/v1/pki/syncs/{pkiSyncId}/certificates" +openapi: "POST /api/v1/cert-manager/syncs/{pkiSyncId}/certificates" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx index dcd58cf32..e4e84fd4c 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/create.mdx @@ -1,4 +1,4 @@ --- title: "Create AWS Certificate Manager PKI Sync" -openapi: "POST /api/v1/pki/syncs/aws-certificate-manager" +openapi: "POST /api/v1/cert-manager/syncs/aws-certificate-manager" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx index 73fed2cdb..0b7bcfbb7 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete AWS Certificate Manager PKI Sync" -openapi: "DELETE /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +openapi: "DELETE /api/v1/cert-manager/syncs/aws-certificate-manager/{pkiSyncId}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx index 9191bbde3..7b3a5c14b 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get AWS Certificate Manager PKI Sync by ID" -openapi: "GET /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +openapi: "GET /api/v1/cert-manager/syncs/aws-certificate-manager/{pkiSyncId}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx index 821ddbd61..e91ef9a21 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/list.mdx @@ -1,4 +1,4 @@ --- title: "List AWS Certificate Manager PKI Syncs" -openapi: "GET /api/v1/pki/syncs/aws-certificate-manager" +openapi: "GET /api/v1/cert-manager/syncs/aws-certificate-manager" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx index 5ea989f2a..8d2229b68 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Remove Certificates from AWS Certificate Manager" -openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/remove-certificates" +openapi: "POST /api/v1/cert-manager/syncs/aws-certificate-manager/{pkiSyncId}/remove-certificates" --- diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx index b97b7a9ab..2a3fbae8d 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Sync Certificates to AWS Certificate Manager" -openapi: "POST /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}/sync" +openapi: "POST /api/v1/cert-manager/syncs/aws-certificate-manager/{pkiSyncId}/sync" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx index 9b7382ce8..22fdd5a5e 100644 --- a/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx +++ b/docs/api-reference/endpoints/pki/syncs/aws-certificate-manager/update.mdx @@ -1,4 +1,4 @@ --- title: "Update AWS Certificate Manager PKI Sync" -openapi: "PATCH /api/v1/pki/syncs/aws-certificate-manager/{pkiSyncId}" +openapi: "PATCH /api/v1/cert-manager/syncs/aws-certificate-manager/{pkiSyncId}" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/create.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/create.mdx new file mode 100644 index 000000000..84709ff9d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create AWS Secrets Manager PKI Sync" +openapi: "POST /api/v1/cert-manager/syncs/aws-secrets-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/delete.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/delete.mdx new file mode 100644 index 000000000..22751d5b7 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete AWS Secrets Manager PKI Sync" +openapi: "DELETE /api/v1/cert-manager/syncs/aws-secrets-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/get-by-id.mdx new file mode 100644 index 000000000..b9e06011d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get AWS Secrets Manager PKI Sync by ID" +openapi: "GET /api/v1/cert-manager/syncs/aws-secrets-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/list.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/list.mdx new file mode 100644 index 000000000..5b933d548 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List AWS Secrets Manager PKI Syncs" +openapi: "GET /api/v1/cert-manager/syncs/aws-secrets-manager" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/remove-certificates.mdx new file mode 100644 index 000000000..ed725eadb --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from AWS Secrets Manager" +openapi: "POST /api/v1/cert-manager/syncs/aws-secrets-manager/{pkiSyncId}/remove-certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/sync-certificates.mdx new file mode 100644 index 000000000..0af0093bb --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to AWS Secrets Manager" +openapi: "POST /api/v1/cert-manager/syncs/aws-secrets-manager/{pkiSyncId}/sync" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/update.mdx b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/update.mdx new file mode 100644 index 000000000..807935ee9 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/aws-secrets-manager/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update AWS Secrets Manager PKI Sync" +openapi: "PATCH /api/v1/cert-manager/syncs/aws-secrets-manager/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx index 1a464cd1e..fb0118ec4 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/create.mdx @@ -1,4 +1,4 @@ --- title: "Create Azure Key Vault PKI Sync" -openapi: "POST /api/v1/pki/syncs/azure-key-vault" +openapi: "POST /api/v1/cert-manager/syncs/azure-key-vault" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx index a08b2664d..0f6c686c9 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/delete.mdx @@ -1,4 +1,4 @@ --- title: "Delete Azure Key Vault PKI Sync" -openapi: "DELETE /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +openapi: "DELETE /api/v1/cert-manager/syncs/azure-key-vault/{pkiSyncId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx index 0976a9dd1..7590402d4 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get Azure Key Vault PKI Sync by ID" -openapi: "GET /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +openapi: "GET /api/v1/cert-manager/syncs/azure-key-vault/{pkiSyncId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx index b21f5bc33..38b7f9f25 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/list.mdx @@ -1,4 +1,4 @@ --- title: "List Azure Key Vault PKI Syncs" -openapi: "GET /api/v1/pki/syncs/azure-key-vault" +openapi: "GET /api/v1/cert-manager/syncs/azure-key-vault" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx index 817f545c0..eeb8f8116 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Remove Certificates from Azure Key Vault" -openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/remove-certificates" +openapi: "POST /api/v1/cert-manager/syncs/azure-key-vault/{pkiSyncId}/remove-certificates" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx index ca8faced5..7fd8bebf0 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Sync Certificates to Azure Key Vault" -openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/sync" +openapi: "POST /api/v1/cert-manager/syncs/azure-key-vault/{pkiSyncId}/sync" --- diff --git a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx index bc0e903cf..084d6723e 100644 --- a/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx +++ b/docs/api-reference/endpoints/pki/syncs/azure-key-vault/update.mdx @@ -1,4 +1,4 @@ --- title: "Update Azure Key Vault PKI Sync" -openapi: "PATCH /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}" +openapi: "PATCH /api/v1/cert-manager/syncs/azure-key-vault/{pkiSyncId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/chef/create.mdx b/docs/api-reference/endpoints/pki/syncs/chef/create.mdx new file mode 100644 index 000000000..caec0c714 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Chef PKI Sync" +openapi: "POST /api/v1/cert-manager/syncs/chef" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/delete.mdx b/docs/api-reference/endpoints/pki/syncs/chef/delete.mdx new file mode 100644 index 000000000..78bf9c688 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Chef PKI Sync" +openapi: "DELETE /api/v1/cert-manager/syncs/chef/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/chef/get-by-id.mdx new file mode 100644 index 000000000..d0e02566e --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Chef PKI Sync by ID" +openapi: "GET /api/v1/cert-manager/syncs/chef/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/list.mdx b/docs/api-reference/endpoints/pki/syncs/chef/list.mdx new file mode 100644 index 000000000..84f745b2d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List Chef PKI Syncs" +openapi: "GET /api/v1/cert-manager/syncs/chef" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/chef/remove-certificates.mdx new file mode 100644 index 000000000..c8fe5d50a --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from Chef" +openapi: "POST /api/v1/cert-manager/syncs/chef/{pkiSyncId}/remove-certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/sync-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/chef/sync-certificates.mdx new file mode 100644 index 000000000..458f58cfa --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/sync-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Certificates to Chef" +openapi: "POST /api/v1/cert-manager/syncs/chef/{pkiSyncId}/sync" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/chef/update.mdx b/docs/api-reference/endpoints/pki/syncs/chef/update.mdx new file mode 100644 index 000000000..ad78c8c66 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/chef/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Chef PKI Sync" +openapi: "PATCH /api/v1/cert-manager/syncs/chef/{pkiSyncId}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx index 6ec710ec8..73131deba 100644 --- a/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx +++ b/docs/api-reference/endpoints/pki/syncs/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get PKI Sync by ID" -openapi: "GET /api/v1/pki/syncs/{pkiSyncId}" +openapi: "GET /api/v1/cert-manager/syncs/{pkiSyncId}" --- diff --git a/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx index eaece0a2d..994803c0b 100644 --- a/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx @@ -1,4 +1,4 @@ --- title: "List Sync Certificates" -openapi: "GET /api/v1/pki/syncs/{pkiSyncId}/certificates" +openapi: "GET /api/v1/cert-manager/syncs/{pkiSyncId}/certificates" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/list.mdx b/docs/api-reference/endpoints/pki/syncs/list.mdx index 4b1f1972a..6ac0e4841 100644 --- a/docs/api-reference/endpoints/pki/syncs/list.mdx +++ b/docs/api-reference/endpoints/pki/syncs/list.mdx @@ -1,4 +1,4 @@ --- title: "List PKI Syncs" -openapi: "GET /api/v1/pki/syncs" +openapi: "GET /api/v1/cert-manager/syncs" --- diff --git a/docs/api-reference/endpoints/pki/syncs/options.mdx b/docs/api-reference/endpoints/pki/syncs/options.mdx index a328b0832..b615aa4b6 100644 --- a/docs/api-reference/endpoints/pki/syncs/options.mdx +++ b/docs/api-reference/endpoints/pki/syncs/options.mdx @@ -1,4 +1,4 @@ --- title: "List PKI Sync Options" -openapi: "GET /api/v1/pki/syncs/options" +openapi: "GET /api/v1/cert-manager/syncs/options" --- diff --git a/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx index 99c8bfe28..ed5dbf70a 100644 --- a/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx +++ b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx @@ -1,4 +1,4 @@ --- title: "Remove Certificates from Sync" -openapi: "DELETE /api/v1/pki/syncs/{pkiSyncId}/certificates" +openapi: "DELETE /api/v1/cert-manager/syncs/{pkiSyncId}/certificates" --- \ No newline at end of file diff --git a/docs/api-reference/endpoints/token-auth/get-token.mdx b/docs/api-reference/endpoints/token-auth/get-token.mdx new file mode 100644 index 000000000..69bc14cdc --- /dev/null +++ b/docs/api-reference/endpoints/token-auth/get-token.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Token" +openapi: "GET /api/v1/auth/token-auth/tokens/{tokenId}" +--- diff --git a/docs/api-reference/overview/examples/integration.mdx b/docs/api-reference/overview/examples/integration.mdx deleted file mode 100644 index 71f5b6de4..000000000 --- a/docs/api-reference/overview/examples/integration.mdx +++ /dev/null @@ -1,90 +0,0 @@ ---- -title: "Configure native integrations via API" -description: "How to use Infisical API to sync secrets to external secret managers" ---- - -The Infisical API allows you to create programmatic integrations that connect with third-party secret managers to synchronize secrets from Infisical. - -This guide will primarily demonstrate the process using AWS Secret Store Manager (AWS SSM), but the steps are generally applicable to other secret management integrations. - - - For details on setting up AWS SSM synchronization and understanding its prerequisites, refer to the [AWS SSM integration setup documentation](../../../integrations/cloud/aws-secret-manager). - - - - - Authentication is required for all integrations. Use the [Integration Auth API](../../endpoints/integrations/create-auth) with the following parameters to authenticate. - - - Set this parameter to **aws-secret-manager**. - - - The Infisical project ID for the integration. - - - The AWS IAM User Access ID. - - - The AWS IAM User Access Secret Key. - - - ```bash Request - curl --request POST \ - --url https://app.infisical.com/api/v1/integration-auth/access-token \ - --header 'Authorization: ' \ - --header 'Content-Type: application/json' \ - --data '{ - "workspaceId": "", - "integration": "aws-secret-manager", - "accessId": "", - "accessToken": "" - }' - ``` - - - - Once authentication between AWS SSM and Infisical is established, you can configure the synchronization behavior. - This involves specifying the source (environment and secret path in Infisical) and the destination in SSM to which the secrets will be synchronized. - - Use the [integration API](../../endpoints/integrations/create) with the following parameters to configure the sync source and destination. - - - The ID of the integration authentication object used with AWS, obtained from the previous API response. - - - Indicates whether the integration should be active or inactive. - - - The secret name for saving in AWS SSM, which can be arbitrarily chosen. - - - The AWS region where the SSM is located, e.g., `us-east-1`. - - - The Infisical environment slug from which secrets will be synchronized, e.g., `dev`. - - - The Infisical folder path from which secrets will be synchronized, e.g., `/some/path`. The root path is `/`. - - - ```bash Request - curl --request POST \ - --url https://app.infisical.com/api/v1/integration \ - --header 'Authorization: ' \ - --header 'Content-Type: application/json' \ - --data '{ - "integrationAuthId": "", - "sourceEnvironment": "", - "secretPath": "", - "app": "", - "region": "" - }' - ``` - - - - - -Congratulations! You have successfully set up an integration to synchronize secrets from Infisical with AWS SSM. -For more information, [view the integration API reference](../../endpoints/integrations). - \ No newline at end of file diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 32612938a..e7da7a74f 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -40,12 +40,12 @@ 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. + By default, the gateway automatically connects to the relay with the lowest latency. To target a specific relay, use the `--target-relay-name=` flag. Once started, the gateway component will: -- Automatically connect to a healthy relay with the lowest latency (unless the `--relay` flag is specified) +- Automatically connect to a healthy relay with the lowest latency (unless the `--target-relay-name` 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 @@ -252,14 +252,14 @@ The Gateway supports multiple authentication methods. Below are the available au ### Other Flags - + 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 start --relay=my-relay --name=my-gateway --token= + sudo infisical gateway start --target-relay-name=my-relay --name=my-gateway --token= ``` **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). @@ -336,14 +336,14 @@ sudo infisical gateway systemd install --token= --domain= --name= - + 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= + sudo infisical gateway systemd install --target-relay-name=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). @@ -687,7 +687,7 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot - 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. + If the `--target-relay-name` 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/cli/commands/login.mdx b/docs/cli/commands/login.mdx index c58c13713..a670c03aa 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -10,6 +10,7 @@ infisical login ### Description The CLI uses authentication to verify your identity. You can authenticate using: + - **Browser Login** (default): Opens a browser for authentication - **Direct Login**: Provide email and password via flags or environment variables for non-interactive workflows - **Interactive CLI Login**: Use the `--interactive` flag to enter credentials via CLI prompts @@ -24,9 +25,9 @@ If you have added multiple users, you can switch between the users by using the **JWT Token Output:** - For **user authentication** with the `--plain --silent` flags: outputs only the JWT access token (useful for scripting) - For **machine identity authentication**: an access token is always printed to the console - + Use the `--plain` flag to print only the token in plain text and the `--silent` flag to disable update alerts. - + Both flags are ideal for capturing the token in environment variables or CI/CD pipelines. @@ -500,6 +501,30 @@ The login command supports a number of flags that you can use for different auth The `jwt` flag can be substituted with the `INFISICAL_JWT` environment variable. + + + ```bash + infisical login --domain= + ``` + + #### Description + Specifies the Infisical API URL for non-US Cloud instances. This flag is required when connecting to any instance other than US Cloud (e.g. EU Cloud or self-hosted). + + ```bash + # Example for EU Cloud + infisical login --domain="https://eu.infisical.com" + + # Example for localhost + infisical login --domain="http://localhost:8080" + + # Example for self-hosted + infisical login --domain="https://your-self-hosted-infisical.com" + ``` + + + **Critical:** If you use `--domain` during login, you must also include it on **all subsequent CLI commands** (e.g., `infisical secrets`, `infisical export`, etc.). Alternatively, set the `INFISICAL_API_URL` environment variable to avoid having to use `--domain` on every command. Refer to the [Domain Configuration](/cli/usage#domain-configuration) section for more details. + + @@ -529,8 +554,11 @@ The following examples demonstrate different ways to authenticate as a user with # Basic direct login (defaults to US Cloud) infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" - # EU Cloud (Custom domain) - infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --domain https://eu.infisical.com + # Basic direct login (EU Cloud) + infisical login --domain https://eu.infisical.com --email user@example.com --password "your-password" --organization-id "your-organization-id" + + # Basic direct login (Self-hosted Instance) + infisical login --domain https://your-self-hosted-infisical.com --email user@example.com --password "your-password" --organization-id "your-organization-id" # Output only JWT token for scripting export INFISICAL_TOKEN=$(infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --plain --silent) @@ -550,6 +578,11 @@ The following examples demonstrate different ways to authenticate as a user with # Or with plain output for token capture export INFISICAL_TOKEN=$(infisical login --plain --silent) ``` + + + **For non-US Cloud instances:** If you're using EU Cloud or a self-hosted instance, you must set `INFISICAL_API_URL` before login or use `--domain` on all commands. Refer to the [Domain Configuration](/cli/usage#domain-configuration) section for more details. + + @@ -571,7 +604,7 @@ The following examples demonstrate different ways to authenticate as a user with -If you have SSO enabled, we recommend using the default browser login. + If you have SSO enabled, we recommend using the default browser login. ### Machine Identity Authentication Quick Start @@ -584,6 +617,10 @@ In this example we'll be using the `universal-auth` method to login to obtain an export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # silent and plain is important to ensure only the token itself is printed, so we can easily set it as an environment variable. ``` + + **For non-US Cloud instances:** If you're using EU Cloud or a self-hosted instance, you must set `INFISICAL_API_URL` before login or use `--domain` on all commands. Refer to the [Domain Configuration](/cli/usage#domain-configuration) section for more details. + + Now that we've set the `INFISICAL_TOKEN` environment variable, we can use the CLI to interact with Infisical. The CLI will automatically check for the presence of the `INFISICAL_TOKEN` environment variable and use it for authentication. diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index bedfda22c..04a7cb025 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -127,10 +127,66 @@ The CLI is designed for a variety of secret management applications ranging from Starting with CLI version v0.4.0, you can now choose to log in via Infisical Cloud (US/EU) or your own self-hosted instance by simply running `infisical login` and following the on-screen instructions — no need to manually set the `INFISICAL_API_URL` environment variable. -For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL. +For versions prior to v0.4.0, the CLI defaults to US Cloud. To connect to EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL. + + ## Domain Configuration + +**Important:** If you're not using interactive login, you must configure the domain for **all CLI commands**. + +The CLI defaults to US Cloud (https://app.infisical.com). To connect to **EU Cloud (https://eu.infisical.com)** or a **self-hosted instance**, you must configure the domain in one of the following ways: + +- Use the `INFISICAL_API_URL` environment variable +- Use the `--domain` flag on every command + + + + The easiest way to ensure all CLI commands use the correct domain is to set + the `INFISICAL_API_URL` environment variable. This applies the domain + setting globally to all commands: + + ```bash + # Linux/MacOS + export INFISICAL_API_URL="https://your-domain.infisical.com" + + # Windows PowerShell + setx INFISICAL_API_URL "https://your-domain.infisical.com" + ``` + + Once set, all subsequent CLI commands will automatically use this domain: + + ```bash + # Login with the domain + infisical login --method=universal-auth --client-id= --client-secret= --silent --plain + + # All other commands will also use the same domain automatically + infisical secrets --projectId --env dev + ``` + + + + The `--domain` flag can be used to set the domain for a single command. This + applies the domain setting to the command only: + + ```bash + # Login with domain + infisical login --domain="https://your-domain.infisical.com" --method=universal-auth --client-id= --client-secret= --silent --plain + + # All subsequent commands must also include --domain + infisical secrets --domain="https://your-domain.infisical.com" --projectId= --env=dev + ``` + + + If you use `--domain` during login but forget to include it on subsequent commands, you may encounter authentication errors. + + + + + + + ## Custom Request Headers @@ -186,51 +242,65 @@ For security and privacy concerns, we recommend you to configure your terminal t ## FAQ - - Yes. The CLI is set to connect to Infisical Cloud by default, but if you're running your own instance of Infisical, you can direct the CLI to it using one of the methods provided below. + + Yes. The CLI is set to connect to Infisical US Cloud by default, but if you're using EU Cloud or a self-hosted instance you can configure the domain for **all CLI commands**. - #### Method 1: Use the updated CLI + #### Method 1: Use the updated CLI (v0.4.0+) - Beginning with CLI version V0.4.0, it is now possible to choose between logging in through the Infisical cloud or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. + Beginning with CLI version V0.4.0, you can choose between logging in through Infisical US Cloud, EU Cloud, or your own self-hosted instance. Simply execute the `infisical login` command and follow the on-screen instructions. - #### Method 2: Export environment variable + #### Method 2: Export environment variable You can point the CLI to the self-hosted Infisical instance by exporting the environment variable `INFISICAL_API_URL` in your terminal. ```bash - # set backend host - export INFISICAL_API_URL="https://your-self-hosted-infisical.com/api" + # Set the API URL + export INFISICAL_API_URL="https://your-self-hosted-infisical.com" - # remove backend host + # For EU Cloud + export INFISICAL_API_URL="https://eu.infisical.com" + + # Remove the setting unset INFISICAL_API_URL ``` ```bash - # set backend host - setx INFISICAL_API_URL "https://your-self-hosted-infisical.com/api" + # Set the API URL + setx INFISICAL_API_URL "https://your-self-hosted-infisical.com" - # remove backend host + # For EU Cloud + setx INFISICAL_API_URL "https://eu.infisical.com" + + # Remove the setting setx INFISICAL_API_URL "" - # NOTE: Once set or removed, please restart powershell for the change to take effect + # NOTE: Once set, please restart powershell for the change to take effect ``` -#### Method 3: Set manually on every command + #### Method 3: Set manually on every command -Another option to point the CLI to your self-hosted Infisical instance is to set it via a flag on every command you run. + If you prefer not to use an environment variable, you must include the `--domain` flag on **every CLI command** you run: -```bash -# Example -infisical --domain="https://your-self-hosted-infisical.com/api" -``` + ```bash + # Login with domain + infisical login --domain="https://your-domain.infisical.com" --method=oidc-auth --jwt $JWT + + # All subsequent commands must also include --domain + infisical secrets --domain="https://your-self-hosted-infisical.com" --projectId --env dev + infisical export --domain="https://your-self-hosted-infisical.com" --format=dotenv-export + ``` + + + **Best Practice:** Use `INFISICAL_API_URL` environment variable (Method 2) to avoid having to remember the `--domain` flag on every command. This is especially important in CI/CD pipelines and automation scripts. + diff --git a/docs/docs.json b/docs/docs.json index aea022fd4..5a3d965fd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -118,6 +118,7 @@ "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", "integrations/app-connections/digital-ocean", + "integrations/app-connections/dns-made-easy", "integrations/app-connections/flyio", "integrations/app-connections/gcp", "integrations/app-connections/github", @@ -490,6 +491,10 @@ "pages": [ "integrations/platforms/ansible", "integrations/platforms/apache-airflow", + { + "group": "AWS", + "pages": ["integrations/platforms/aws/lambda"] + }, { "group": "Kubernetes Operator", "pages": [ @@ -568,71 +573,14 @@ } ] }, - { - "group": "Native Integrations", - "pages": [ - { - "group": "AWS", - "pages": [ - "integrations/cloud/aws-parameter-store", - "integrations/cloud/aws-secret-manager", - "integrations/cloud/aws-amplify" - ] - }, - "integrations/cloud/vercel", - "integrations/cloud/azure-key-vault", - "integrations/cloud/azure-app-configuration", - "integrations/cloud/azure-devops", - "integrations/cloud/gcp-secret-manager", - { - "group": "Cloudflare", - "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" - ] - }, - "integrations/cloud/terraform-cloud", - "integrations/cloud/databricks", - { - "group": "View more", - "pages": [ - "integrations/cloud/digital-ocean-app-platform", - "integrations/cloud/heroku", - "integrations/cloud/netlify", - "integrations/cloud/flyio", - "integrations/cloud/railway", - "integrations/cloud/render", - "integrations/cloud/laravel-forge", - "integrations/cloud/supabase", - "integrations/cloud/northflank", - "integrations/cloud/hasura-cloud", - "integrations/cloud/qovery", - "integrations/cloud/hashicorp-vault", - "integrations/cloud/cloud-66", - "integrations/cloud/windmill" - ] - } - ] - }, { "group": "CI/CD Integrations", "pages": [ - "integrations/cicd/jenkins", + "integrations/cicd/aws-amplify", + "integrations/cicd/bitbucket", "integrations/cicd/githubactions", "integrations/cicd/gitlab", - "integrations/cicd/bitbucket", - "integrations/cloud/teamcity", - { - "group": "View more", - "pages": [ - "integrations/cicd/circleci", - "integrations/cicd/travisci", - "integrations/cicd/rundeck", - "integrations/cicd/codefresh", - "integrations/cloud/checkly", - "integrations/cicd/octopus-deploy" - ] - } + "integrations/cicd/jenkins" ] }, { @@ -742,6 +690,7 @@ "pages": [ "documentation/platform/pki/enrollment-methods/overview", "documentation/platform/pki/enrollment-methods/api", + "documentation/platform/pki/enrollment-methods/acme", "documentation/platform/pki/enrollment-methods/est" ] }, @@ -751,7 +700,7 @@ { "group": "Infrastructure Integrations", "pages": [ - "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/k8s-cert-manager", "documentation/platform/pki/integration-guides/gloo-mesh", "documentation/platform/pki/integration-guides/windows-server-acme", "documentation/platform/pki/integration-guides/nginx-certbot", @@ -765,13 +714,17 @@ "pages": [ "documentation/platform/pki/certificate-syncs/overview", "documentation/platform/pki/certificate-syncs/aws-certificate-manager", - "documentation/platform/pki/certificate-syncs/azure-key-vault" + "documentation/platform/pki/certificate-syncs/aws-secrets-manager", + "documentation/platform/pki/certificate-syncs/azure-key-vault", + "documentation/platform/pki/certificate-syncs/chef" ] }, { "group": "External CA Integrations", "pages": [ "documentation/platform/pki/ca/acme-ca", + "documentation/platform/pki/ca/lets-encrypt", + "documentation/platform/pki/ca/digicert", "documentation/platform/pki/ca/azure-adcs" ] } @@ -822,7 +775,23 @@ "group": "Infisical PAM", "pages": [ "documentation/platform/pam/overview", - "documentation/platform/pam/session-recording" + { + "group": "Getting Started", + "pages": [ + "documentation/platform/pam/getting-started/setup", + "documentation/platform/pam/getting-started/resources", + "documentation/platform/pam/getting-started/accounts" + ] + }, + "documentation/platform/pam/architecture" + ] + }, + { + "group": "Product Reference", + "pages": [ + "documentation/platform/pam/product-reference/auditing", + "documentation/platform/pam/product-reference/session-recording", + "documentation/platform/pam/product-reference/credential-rotation" ] } ] @@ -879,23 +848,34 @@ "group": "Overview", "pages": [ "api-reference/overview/introduction", - "api-reference/overview/authentication", - { - "group": "Examples", - "pages": ["api-reference/overview/examples/integration"] - } + "api-reference/overview/authentication" ] }, { - "group": "Endpoints", + "group": "Organization", "pages": [ { - "group": "Admin", - "pages": ["api-reference/endpoints/admin/bootstrap-instance"] + "group": "Organization Users", + "pages": [ + "api-reference/endpoints/organizations/memberships", + "api-reference/endpoints/organizations/update-membership", + "api-reference/endpoints/organizations/delete-membership", + "api-reference/endpoints/organizations/bulk-delete-memberships", + "api-reference/endpoints/organizations/list-identity-memberships" + ] }, { - "group": "Events", - "pages": ["api-reference/endpoints/events/project-events"] + "group": "Organization Groups", + "pages": [ + "api-reference/endpoints/groups/create", + "api-reference/endpoints/groups/update", + "api-reference/endpoints/groups/delete", + "api-reference/endpoints/groups/get", + "api-reference/endpoints/groups/get-by-id", + "api-reference/endpoints/groups/add-group-user", + "api-reference/endpoints/groups/remove-group-user", + "api-reference/endpoints/groups/list-group-users" + ] }, { "group": "Organization Identities", @@ -909,187 +889,34 @@ ] }, { - "group": "Token Auth", + "group": "OIDC SSO", "pages": [ - "api-reference/endpoints/token-auth/attach", - "api-reference/endpoints/token-auth/retrieve", - "api-reference/endpoints/token-auth/update", - "api-reference/endpoints/token-auth/revoke", - "api-reference/endpoints/token-auth/get-tokens", - "api-reference/endpoints/token-auth/create-token", - "api-reference/endpoints/token-auth/update-token", - "api-reference/endpoints/token-auth/revoke-token" + "api-reference/endpoints/organizations/oidc-sso/get-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/update-oidc-config", + "api-reference/endpoints/organizations/oidc-sso/create-oidc-config" ] }, { - "group": "Universal Auth", + "group": "LDAP SSO", "pages": [ - "api-reference/endpoints/universal-auth/login", - "api-reference/endpoints/universal-auth/attach", - "api-reference/endpoints/universal-auth/retrieve", - "api-reference/endpoints/universal-auth/update", - "api-reference/endpoints/universal-auth/revoke", - "api-reference/endpoints/universal-auth/create-client-secret", - "api-reference/endpoints/universal-auth/list-client-secrets", - "api-reference/endpoints/universal-auth/revoke-client-secret", - "api-reference/endpoints/universal-auth/get-client-secret-by-id", - "api-reference/endpoints/universal-auth/renew-access-token", - "api-reference/endpoints/universal-auth/revoke-access-token" + "api-reference/endpoints/organizations/ldap-sso/get-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/update-ldap-config", + "api-reference/endpoints/organizations/ldap-sso/create-ldap-config" ] }, { - "group": "GCP Auth", + "group": "SAML SSO", "pages": [ - "api-reference/endpoints/gcp-auth/login", - "api-reference/endpoints/gcp-auth/attach", - "api-reference/endpoints/gcp-auth/retrieve", - "api-reference/endpoints/gcp-auth/update", - "api-reference/endpoints/gcp-auth/revoke" + "api-reference/endpoints/organizations/saml-sso/get-saml-config", + "api-reference/endpoints/organizations/saml-sso/update-saml-config", + "api-reference/endpoints/organizations/saml-sso/create-saml-config" ] - }, - { - "group": "Alibaba Cloud Auth", - "pages": [ - "api-reference/endpoints/alicloud-auth/login", - "api-reference/endpoints/alicloud-auth/attach", - "api-reference/endpoints/alicloud-auth/retrieve", - "api-reference/endpoints/alicloud-auth/update", - "api-reference/endpoints/alicloud-auth/revoke" - ] - }, - { - "group": "TLS Certificate Auth", - "pages": [ - "api-reference/endpoints/tls-cert-auth/login", - "api-reference/endpoints/tls-cert-auth/attach", - "api-reference/endpoints/tls-cert-auth/retrieve", - "api-reference/endpoints/tls-cert-auth/update", - "api-reference/endpoints/tls-cert-auth/revoke" - ] - }, - { - "group": "AWS Auth", - "pages": [ - "api-reference/endpoints/aws-auth/login", - "api-reference/endpoints/aws-auth/attach", - "api-reference/endpoints/aws-auth/retrieve", - "api-reference/endpoints/aws-auth/update", - "api-reference/endpoints/aws-auth/revoke" - ] - }, - { - "group": "OCI Auth", - "pages": [ - "api-reference/endpoints/oci-auth/login", - "api-reference/endpoints/oci-auth/attach", - "api-reference/endpoints/oci-auth/retrieve", - "api-reference/endpoints/oci-auth/update", - "api-reference/endpoints/oci-auth/revoke" - ] - }, - { - "group": "Azure Auth", - "pages": [ - "api-reference/endpoints/azure-auth/login", - "api-reference/endpoints/azure-auth/attach", - "api-reference/endpoints/azure-auth/retrieve", - "api-reference/endpoints/azure-auth/update", - "api-reference/endpoints/azure-auth/revoke" - ] - }, - { - "group": "Kubernetes Auth", - "pages": [ - "api-reference/endpoints/kubernetes-auth/login", - "api-reference/endpoints/kubernetes-auth/attach", - "api-reference/endpoints/kubernetes-auth/retrieve", - "api-reference/endpoints/kubernetes-auth/update", - "api-reference/endpoints/kubernetes-auth/revoke" - ] - }, - { - "group": "OIDC Auth", - "pages": [ - "api-reference/endpoints/oidc-auth/login", - "api-reference/endpoints/oidc-auth/attach", - "api-reference/endpoints/oidc-auth/retrieve", - "api-reference/endpoints/oidc-auth/update", - "api-reference/endpoints/oidc-auth/revoke" - ] - }, - { - "group": "JWT Auth", - "pages": [ - "api-reference/endpoints/jwt-auth/login", - "api-reference/endpoints/jwt-auth/attach", - "api-reference/endpoints/jwt-auth/retrieve", - "api-reference/endpoints/jwt-auth/update", - "api-reference/endpoints/jwt-auth/revoke" - ] - }, - { - "group": "LDAP Auth", - "pages": [ - "api-reference/endpoints/ldap-auth/login", - "api-reference/endpoints/ldap-auth/attach", - "api-reference/endpoints/ldap-auth/retrieve", - "api-reference/endpoints/ldap-auth/update", - "api-reference/endpoints/ldap-auth/revoke" - ] - }, - { - "group": "Groups", - "pages": [ - "api-reference/endpoints/groups/create", - "api-reference/endpoints/groups/update", - "api-reference/endpoints/groups/delete", - "api-reference/endpoints/groups/get", - "api-reference/endpoints/groups/get-by-id", - "api-reference/endpoints/groups/add-group-user", - "api-reference/endpoints/groups/remove-group-user", - "api-reference/endpoints/groups/list-group-users" - ] - }, - { - "group": "Organizations", - "pages": [ - { - "group": "OIDC SSO", - "pages": [ - "api-reference/endpoints/organizations/oidc-sso/get-oidc-config", - "api-reference/endpoints/organizations/oidc-sso/update-oidc-config", - "api-reference/endpoints/organizations/oidc-sso/create-oidc-config" - ] - }, - { - "group": "LDAP SSO", - "pages": [ - "api-reference/endpoints/organizations/ldap-sso/get-ldap-config", - "api-reference/endpoints/organizations/ldap-sso/update-ldap-config", - "api-reference/endpoints/organizations/ldap-sso/create-ldap-config" - ] - }, - { - "group": "SAML SSO", - "pages": [ - "api-reference/endpoints/organizations/saml-sso/get-saml-config", - "api-reference/endpoints/organizations/saml-sso/update-saml-config", - "api-reference/endpoints/organizations/saml-sso/create-saml-config" - ] - }, - "api-reference/endpoints/organizations/memberships", - "api-reference/endpoints/organizations/update-membership", - "api-reference/endpoints/organizations/delete-membership", - "api-reference/endpoints/organizations/bulk-delete-memberships", - "api-reference/endpoints/organizations/list-identity-memberships", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/organizations/projects" - ] - } - ] - }, + } + ] + }, + { + "group": "Project", + "pages": [ { "group": "Projects", "pages": [ @@ -1107,6 +934,7 @@ "api-reference/endpoints/deprecated/projects/create-project", "api-reference/endpoints/deprecated/projects/delete-project", "api-reference/endpoints/deprecated/projects/get-project", + "api-reference/endpoints/deprecated/organizations/projects", "api-reference/endpoints/deprecated/projects/update-project", "api-reference/endpoints/deprecated/projects/secret-snapshots" ] @@ -1183,6 +1011,16 @@ } ] }, + { + "group": "Project Templates", + "pages": [ + "api-reference/endpoints/project-templates/create", + "api-reference/endpoints/project-templates/update", + "api-reference/endpoints/project-templates/delete", + "api-reference/endpoints/project-templates/get-by-id", + "api-reference/endpoints/project-templates/list" + ] + }, { "group": "Project Roles", "pages": [ @@ -1203,349 +1041,6 @@ } ] }, - { - "group": "Project Templates", - "pages": [ - "api-reference/endpoints/project-templates/create", - "api-reference/endpoints/project-templates/update", - "api-reference/endpoints/project-templates/delete", - "api-reference/endpoints/project-templates/get-by-id", - "api-reference/endpoints/project-templates/list" - ] - }, - { - "group": "Environments", - "pages": [ - "api-reference/endpoints/environments/create", - "api-reference/endpoints/environments/update", - "api-reference/endpoints/environments/delete", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/environments/create", - "api-reference/endpoints/deprecated/environments/update", - "api-reference/endpoints/deprecated/environments/delete" - ] - } - ] - }, - { - "group": "Folders", - "pages": [ - "api-reference/endpoints/folders/list", - "api-reference/endpoints/folders/get-by-id", - "api-reference/endpoints/folders/create", - "api-reference/endpoints/folders/update", - "api-reference/endpoints/folders/delete", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/folders/list", - "api-reference/endpoints/deprecated/folders/get-by-id", - "api-reference/endpoints/deprecated/folders/create", - "api-reference/endpoints/deprecated/folders/update", - "api-reference/endpoints/deprecated/folders/delete" - ] - } - ] - }, - { - "group": "Secret Tags", - "pages": [ - "api-reference/endpoints/secret-tags/list", - "api-reference/endpoints/secret-tags/get-by-id", - "api-reference/endpoints/secret-tags/get-by-slug", - "api-reference/endpoints/secret-tags/create", - "api-reference/endpoints/secret-tags/update", - "api-reference/endpoints/secret-tags/delete", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/secret-tags/list", - "api-reference/endpoints/deprecated/secret-tags/get-by-id", - "api-reference/endpoints/deprecated/secret-tags/get-by-slug", - "api-reference/endpoints/deprecated/secret-tags/create", - "api-reference/endpoints/deprecated/secret-tags/update", - "api-reference/endpoints/deprecated/secret-tags/delete" - ] - } - ] - }, - { - "group": "Secrets", - "pages": [ - "api-reference/endpoints/secrets/list", - "api-reference/endpoints/secrets/create", - "api-reference/endpoints/secrets/read", - "api-reference/endpoints/secrets/update", - "api-reference/endpoints/secrets/delete", - "api-reference/endpoints/secrets/create-many", - "api-reference/endpoints/secrets/update-many", - "api-reference/endpoints/secrets/delete-many", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/secrets/list", - "api-reference/endpoints/deprecated/secrets/create", - "api-reference/endpoints/deprecated/secrets/read", - "api-reference/endpoints/deprecated/secrets/update", - "api-reference/endpoints/deprecated/secrets/delete", - "api-reference/endpoints/deprecated/secrets/create-many", - "api-reference/endpoints/deprecated/secrets/update-many", - "api-reference/endpoints/deprecated/secrets/delete-many", - "api-reference/endpoints/deprecated/secrets/attach-tags", - "api-reference/endpoints/deprecated/secrets/detach-tags" - ] - } - ] - }, - { - "group": "Dynamic Secrets", - "pages": [ - { - "group": "Kubernetes", - "pages": [ - "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" - ] - }, - "api-reference/endpoints/dynamic-secrets/create", - "api-reference/endpoints/dynamic-secrets/update", - "api-reference/endpoints/dynamic-secrets/delete", - "api-reference/endpoints/dynamic-secrets/get", - "api-reference/endpoints/dynamic-secrets/list", - "api-reference/endpoints/dynamic-secrets/list-leases", - "api-reference/endpoints/dynamic-secrets/create-lease", - "api-reference/endpoints/dynamic-secrets/delete-lease", - "api-reference/endpoints/dynamic-secrets/renew-lease", - "api-reference/endpoints/dynamic-secrets/get-lease" - ] - }, - { - "group": "Secret Imports", - "pages": [ - "api-reference/endpoints/secret-imports/list", - "api-reference/endpoints/secret-imports/create", - "api-reference/endpoints/secret-imports/update", - "api-reference/endpoints/secret-imports/delete", - { - "group": "Legacy", - "pages": [ - "api-reference/endpoints/deprecated/secret-imports/list", - "api-reference/endpoints/deprecated/secret-imports/create", - "api-reference/endpoints/deprecated/secret-imports/update", - "api-reference/endpoints/deprecated/secret-imports/delete" - ] - } - ] - }, - { - "group": "Secret Rotations", - "pages": [ - "api-reference/endpoints/secret-rotations/list", - "api-reference/endpoints/secret-rotations/options", - { - "group": "Auth0 Client Secret", - "pages": [ - "api-reference/endpoints/secret-rotations/auth0-client-secret/create", - "api-reference/endpoints/secret-rotations/auth0-client-secret/delete", - "api-reference/endpoints/secret-rotations/auth0-client-secret/get-by-id", - "api-reference/endpoints/secret-rotations/auth0-client-secret/get-by-name", - "api-reference/endpoints/secret-rotations/auth0-client-secret/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/auth0-client-secret/list", - "api-reference/endpoints/secret-rotations/auth0-client-secret/rotate-secrets", - "api-reference/endpoints/secret-rotations/auth0-client-secret/update" - ] - }, - { - "group": "AWS IAM User Secret", - "pages": [ - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/create", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/list", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets", - "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" - ] - }, - { - "group": "Azure Client Secret", - "pages": [ - "api-reference/endpoints/secret-rotations/azure-client-secret/create", - "api-reference/endpoints/secret-rotations/azure-client-secret/delete", - "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id", - "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name", - "api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/azure-client-secret/list", - "api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets", - "api-reference/endpoints/secret-rotations/azure-client-secret/update" - ] - }, - { - "group": "LDAP Password", - "pages": [ - "api-reference/endpoints/secret-rotations/ldap-password/create", - "api-reference/endpoints/secret-rotations/ldap-password/delete", - "api-reference/endpoints/secret-rotations/ldap-password/get-by-id", - "api-reference/endpoints/secret-rotations/ldap-password/get-by-name", - "api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/ldap-password/list", - "api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets", - "api-reference/endpoints/secret-rotations/ldap-password/update" - ] - }, - { - "group": "Microsoft SQL Server Credentials", - "pages": [ - "api-reference/endpoints/secret-rotations/mssql-credentials/create", - "api-reference/endpoints/secret-rotations/mssql-credentials/delete", - "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id", - "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name", - "api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/mssql-credentials/list", - "api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets", - "api-reference/endpoints/secret-rotations/mssql-credentials/update" - ] - }, - { - "group": "MySQL Credentials", - "pages": [ - "api-reference/endpoints/secret-rotations/mysql-credentials/create", - "api-reference/endpoints/secret-rotations/mysql-credentials/delete", - "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id", - "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name", - "api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/mysql-credentials/list", - "api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets", - "api-reference/endpoints/secret-rotations/mysql-credentials/update" - ] - }, - { - "group": "Okta Client Secret", - "pages": [ - "api-reference/endpoints/secret-rotations/okta-client-secret/create", - "api-reference/endpoints/secret-rotations/okta-client-secret/delete", - "api-reference/endpoints/secret-rotations/okta-client-secret/get-by-id", - "api-reference/endpoints/secret-rotations/okta-client-secret/get-by-name", - "api-reference/endpoints/secret-rotations/okta-client-secret/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/okta-client-secret/list", - "api-reference/endpoints/secret-rotations/okta-client-secret/rotate-secrets", - "api-reference/endpoints/secret-rotations/okta-client-secret/update" - ] - }, - { - "group": "OracleDB Credentials", - "pages": [ - "api-reference/endpoints/secret-rotations/oracledb-credentials/create", - "api-reference/endpoints/secret-rotations/oracledb-credentials/delete", - "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id", - "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name", - "api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/oracledb-credentials/list", - "api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets", - "api-reference/endpoints/secret-rotations/oracledb-credentials/update" - ] - }, - { - "group": "PostgreSQL Credentials", - "pages": [ - "api-reference/endpoints/secret-rotations/postgres-credentials/create", - "api-reference/endpoints/secret-rotations/postgres-credentials/delete", - "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id", - "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name", - "api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/postgres-credentials/list", - "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", - "api-reference/endpoints/secret-rotations/postgres-credentials/update" - ] - }, - { - "group": "Redis Credentials", - "pages": [ - "api-reference/endpoints/secret-rotations/redis-credentials/create", - "api-reference/endpoints/secret-rotations/redis-credentials/delete", - "api-reference/endpoints/secret-rotations/redis-credentials/get-by-id", - "api-reference/endpoints/secret-rotations/redis-credentials/get-by-name", - "api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id", - "api-reference/endpoints/secret-rotations/redis-credentials/list", - "api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets", - "api-reference/endpoints/secret-rotations/redis-credentials/update" - ] - } - ] - }, - { - "group": "Secret Scanning", - "pages": [ - { - "group": "Data Sources", - "pages": [ - "api-reference/endpoints/secret-scanning/data-sources/list", - "api-reference/endpoints/secret-scanning/data-sources/options", - { - "group": "Bitbucket", - "pages": [ - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/create", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/update", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan", - "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource" - ] - }, - { - "group": "GitHub", - "pages": [ - "api-reference/endpoints/secret-scanning/data-sources/github/list", - "api-reference/endpoints/secret-scanning/data-sources/github/get-by-id", - "api-reference/endpoints/secret-scanning/data-sources/github/get-by-name", - "api-reference/endpoints/secret-scanning/data-sources/github/list-resources", - "api-reference/endpoints/secret-scanning/data-sources/github/list-scans", - "api-reference/endpoints/secret-scanning/data-sources/github/create", - "api-reference/endpoints/secret-scanning/data-sources/github/update", - "api-reference/endpoints/secret-scanning/data-sources/github/delete", - "api-reference/endpoints/secret-scanning/data-sources/github/scan", - "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" - ] - }, - { - "group": "GitLab", - "pages": [ - "api-reference/endpoints/secret-scanning/data-sources/gitlab/list", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/create", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/update", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/delete", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan", - "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource" - ] - } - ] - }, - { - "group": "Findings", - "pages": [ - "api-reference/endpoints/secret-scanning/findings/list", - "api-reference/endpoints/secret-scanning/findings/update" - ] - }, - { - "group": "Configuration", - "pages": [ - "api-reference/endpoints/secret-scanning/config/get-by-project-id", - "api-reference/endpoints/secret-scanning/config/update" - ] - } - ] - }, { "group": "Identity Specific Privilege", "pages": [ @@ -1573,6 +1068,15 @@ } ] }, + { + "group": "Service Tokens", + "pages": ["api-reference/endpoints/service-tokens/get"] + } + ] + }, + { + "group": "Shared", + "pages": [ { "group": "App Connections", "pages": [ @@ -2083,6 +1587,415 @@ } ] }, + { + "group": "Audit Logs", + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + }, + { + "group": "Events", + "pages": ["api-reference/endpoints/events/project-events"] + } + ] + }, + { + "group": "Identity Auth", + "pages": [ + { + "group": "Token Auth", + "pages": [ + "api-reference/endpoints/token-auth/attach", + "api-reference/endpoints/token-auth/retrieve", + "api-reference/endpoints/token-auth/update", + "api-reference/endpoints/token-auth/revoke", + "api-reference/endpoints/token-auth/get-tokens", + "api-reference/endpoints/token-auth/create-token", + "api-reference/endpoints/token-auth/update-token", + "api-reference/endpoints/token-auth/revoke-token" + ] + }, + { + "group": "Universal Auth", + "pages": [ + "api-reference/endpoints/universal-auth/login", + "api-reference/endpoints/universal-auth/attach", + "api-reference/endpoints/universal-auth/retrieve", + "api-reference/endpoints/universal-auth/update", + "api-reference/endpoints/universal-auth/revoke", + "api-reference/endpoints/universal-auth/create-client-secret", + "api-reference/endpoints/universal-auth/list-client-secrets", + "api-reference/endpoints/universal-auth/revoke-client-secret", + "api-reference/endpoints/universal-auth/get-client-secret-by-id", + "api-reference/endpoints/universal-auth/renew-access-token", + "api-reference/endpoints/universal-auth/revoke-access-token" + ] + }, + { + "group": "GCP Auth", + "pages": [ + "api-reference/endpoints/gcp-auth/login", + "api-reference/endpoints/gcp-auth/attach", + "api-reference/endpoints/gcp-auth/retrieve", + "api-reference/endpoints/gcp-auth/update", + "api-reference/endpoints/gcp-auth/revoke" + ] + }, + { + "group": "Alibaba Cloud Auth", + "pages": [ + "api-reference/endpoints/alicloud-auth/login", + "api-reference/endpoints/alicloud-auth/attach", + "api-reference/endpoints/alicloud-auth/retrieve", + "api-reference/endpoints/alicloud-auth/update", + "api-reference/endpoints/alicloud-auth/revoke" + ] + }, + { + "group": "TLS Certificate Auth", + "pages": [ + "api-reference/endpoints/tls-cert-auth/login", + "api-reference/endpoints/tls-cert-auth/attach", + "api-reference/endpoints/tls-cert-auth/retrieve", + "api-reference/endpoints/tls-cert-auth/update", + "api-reference/endpoints/tls-cert-auth/revoke" + ] + }, + { + "group": "AWS Auth", + "pages": [ + "api-reference/endpoints/aws-auth/login", + "api-reference/endpoints/aws-auth/attach", + "api-reference/endpoints/aws-auth/retrieve", + "api-reference/endpoints/aws-auth/update", + "api-reference/endpoints/aws-auth/revoke" + ] + }, + { + "group": "OCI Auth", + "pages": [ + "api-reference/endpoints/oci-auth/login", + "api-reference/endpoints/oci-auth/attach", + "api-reference/endpoints/oci-auth/retrieve", + "api-reference/endpoints/oci-auth/update", + "api-reference/endpoints/oci-auth/revoke" + ] + }, + { + "group": "Azure Auth", + "pages": [ + "api-reference/endpoints/azure-auth/login", + "api-reference/endpoints/azure-auth/attach", + "api-reference/endpoints/azure-auth/retrieve", + "api-reference/endpoints/azure-auth/update", + "api-reference/endpoints/azure-auth/revoke" + ] + }, + { + "group": "Kubernetes Auth", + "pages": [ + "api-reference/endpoints/kubernetes-auth/login", + "api-reference/endpoints/kubernetes-auth/attach", + "api-reference/endpoints/kubernetes-auth/retrieve", + "api-reference/endpoints/kubernetes-auth/update", + "api-reference/endpoints/kubernetes-auth/revoke" + ] + }, + { + "group": "OIDC Auth", + "pages": [ + "api-reference/endpoints/oidc-auth/login", + "api-reference/endpoints/oidc-auth/attach", + "api-reference/endpoints/oidc-auth/retrieve", + "api-reference/endpoints/oidc-auth/update", + "api-reference/endpoints/oidc-auth/revoke" + ] + }, + { + "group": "JWT Auth", + "pages": [ + "api-reference/endpoints/jwt-auth/login", + "api-reference/endpoints/jwt-auth/attach", + "api-reference/endpoints/jwt-auth/retrieve", + "api-reference/endpoints/jwt-auth/update", + "api-reference/endpoints/jwt-auth/revoke" + ] + }, + { + "group": "LDAP Auth", + "pages": [ + "api-reference/endpoints/ldap-auth/login", + "api-reference/endpoints/ldap-auth/attach", + "api-reference/endpoints/ldap-auth/retrieve", + "api-reference/endpoints/ldap-auth/update", + "api-reference/endpoints/ldap-auth/revoke" + ] + } + ] + }, + { + "group": "Secrets Management", + "pages": [ + { + "group": "Environments", + "pages": [ + "api-reference/endpoints/environments/create", + "api-reference/endpoints/environments/update", + "api-reference/endpoints/environments/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/environments/create", + "api-reference/endpoints/deprecated/environments/update", + "api-reference/endpoints/deprecated/environments/delete" + ] + } + ] + }, + { + "group": "Folders", + "pages": [ + "api-reference/endpoints/folders/list", + "api-reference/endpoints/folders/get-by-id", + "api-reference/endpoints/folders/create", + "api-reference/endpoints/folders/update", + "api-reference/endpoints/folders/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/folders/list", + "api-reference/endpoints/deprecated/folders/get-by-id", + "api-reference/endpoints/deprecated/folders/create", + "api-reference/endpoints/deprecated/folders/update", + "api-reference/endpoints/deprecated/folders/delete" + ] + } + ] + }, + { + "group": "Secrets", + "pages": [ + "api-reference/endpoints/secrets/list", + "api-reference/endpoints/secrets/create", + "api-reference/endpoints/secrets/read", + "api-reference/endpoints/secrets/update", + "api-reference/endpoints/secrets/delete", + "api-reference/endpoints/secrets/create-many", + "api-reference/endpoints/secrets/update-many", + "api-reference/endpoints/secrets/delete-many", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secrets/list", + "api-reference/endpoints/deprecated/secrets/create", + "api-reference/endpoints/deprecated/secrets/read", + "api-reference/endpoints/deprecated/secrets/update", + "api-reference/endpoints/deprecated/secrets/delete", + "api-reference/endpoints/deprecated/secrets/create-many", + "api-reference/endpoints/deprecated/secrets/update-many", + "api-reference/endpoints/deprecated/secrets/delete-many", + "api-reference/endpoints/deprecated/secrets/attach-tags", + "api-reference/endpoints/deprecated/secrets/detach-tags" + ] + } + ] + }, + { + "group": "Secret Tags", + "pages": [ + "api-reference/endpoints/secret-tags/list", + "api-reference/endpoints/secret-tags/get-by-id", + "api-reference/endpoints/secret-tags/get-by-slug", + "api-reference/endpoints/secret-tags/create", + "api-reference/endpoints/secret-tags/update", + "api-reference/endpoints/secret-tags/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secret-tags/list", + "api-reference/endpoints/deprecated/secret-tags/get-by-id", + "api-reference/endpoints/deprecated/secret-tags/get-by-slug", + "api-reference/endpoints/deprecated/secret-tags/create", + "api-reference/endpoints/deprecated/secret-tags/update", + "api-reference/endpoints/deprecated/secret-tags/delete" + ] + } + ] + }, + { + "group": "Secret Imports", + "pages": [ + "api-reference/endpoints/secret-imports/list", + "api-reference/endpoints/secret-imports/create", + "api-reference/endpoints/secret-imports/update", + "api-reference/endpoints/secret-imports/delete", + { + "group": "Legacy", + "pages": [ + "api-reference/endpoints/deprecated/secret-imports/list", + "api-reference/endpoints/deprecated/secret-imports/create", + "api-reference/endpoints/deprecated/secret-imports/update", + "api-reference/endpoints/deprecated/secret-imports/delete" + ] + } + ] + }, + { + "group": "Dynamic Secrets", + "pages": [ + { + "group": "Kubernetes", + "pages": [ + "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" + ] + }, + "api-reference/endpoints/dynamic-secrets/create", + "api-reference/endpoints/dynamic-secrets/update", + "api-reference/endpoints/dynamic-secrets/delete", + "api-reference/endpoints/dynamic-secrets/get", + "api-reference/endpoints/dynamic-secrets/list", + "api-reference/endpoints/dynamic-secrets/list-leases", + "api-reference/endpoints/dynamic-secrets/create-lease", + "api-reference/endpoints/dynamic-secrets/delete-lease", + "api-reference/endpoints/dynamic-secrets/renew-lease", + "api-reference/endpoints/dynamic-secrets/get-lease" + ] + }, + { + "group": "Secret Rotations", + "pages": [ + "api-reference/endpoints/secret-rotations/list", + "api-reference/endpoints/secret-rotations/options", + { + "group": "Auth0 Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/auth0-client-secret/create", + "api-reference/endpoints/secret-rotations/auth0-client-secret/delete", + "api-reference/endpoints/secret-rotations/auth0-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/auth0-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/auth0-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/auth0-client-secret/list", + "api-reference/endpoints/secret-rotations/auth0-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/auth0-client-secret/update" + ] + }, + { + "group": "AWS IAM User Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/create", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/delete", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-by-name", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/list", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/aws-iam-user-secret/update" + ] + }, + { + "group": "Azure Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/azure-client-secret/create", + "api-reference/endpoints/secret-rotations/azure-client-secret/delete", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/azure-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/azure-client-secret/list", + "api-reference/endpoints/secret-rotations/azure-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/azure-client-secret/update" + ] + }, + { + "group": "LDAP Password", + "pages": [ + "api-reference/endpoints/secret-rotations/ldap-password/create", + "api-reference/endpoints/secret-rotations/ldap-password/delete", + "api-reference/endpoints/secret-rotations/ldap-password/get-by-id", + "api-reference/endpoints/secret-rotations/ldap-password/get-by-name", + "api-reference/endpoints/secret-rotations/ldap-password/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/ldap-password/list", + "api-reference/endpoints/secret-rotations/ldap-password/rotate-secrets", + "api-reference/endpoints/secret-rotations/ldap-password/update" + ] + }, + { + "group": "Microsoft SQL Server Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mssql-credentials/create", + "api-reference/endpoints/secret-rotations/mssql-credentials/delete", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/list", + "api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mssql-credentials/update" + ] + }, + { + "group": "MySQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mysql-credentials/create", + "api-reference/endpoints/secret-rotations/mysql-credentials/delete", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mysql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mysql-credentials/list", + "api-reference/endpoints/secret-rotations/mysql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mysql-credentials/update" + ] + }, + { + "group": "Okta Client Secret", + "pages": [ + "api-reference/endpoints/secret-rotations/okta-client-secret/create", + "api-reference/endpoints/secret-rotations/okta-client-secret/delete", + "api-reference/endpoints/secret-rotations/okta-client-secret/get-by-id", + "api-reference/endpoints/secret-rotations/okta-client-secret/get-by-name", + "api-reference/endpoints/secret-rotations/okta-client-secret/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/okta-client-secret/list", + "api-reference/endpoints/secret-rotations/okta-client-secret/rotate-secrets", + "api-reference/endpoints/secret-rotations/okta-client-secret/update" + ] + }, + { + "group": "OracleDB Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/oracledb-credentials/create", + "api-reference/endpoints/secret-rotations/oracledb-credentials/delete", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/oracledb-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/oracledb-credentials/list", + "api-reference/endpoints/secret-rotations/oracledb-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/oracledb-credentials/update" + ] + }, + { + "group": "PostgreSQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/postgres-credentials/create", + "api-reference/endpoints/secret-rotations/postgres-credentials/delete", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/list", + "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/postgres-credentials/update" + ] + }, + { + "group": "Redis Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/redis-credentials/create", + "api-reference/endpoints/secret-rotations/redis-credentials/delete", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/list", + "api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/redis-credentials/update" + ] + } + ] + }, { "group": "Secret Syncs", "pages": [ @@ -2537,28 +2450,6 @@ ] } ] - }, - { - "group": "Integrations", - "pages": [ - "api-reference/endpoints/integrations/create-auth", - "api-reference/endpoints/integrations/list-auth", - "api-reference/endpoints/integrations/find-auth", - "api-reference/endpoints/integrations/delete-auth", - "api-reference/endpoints/integrations/delete-auth-by-id", - "api-reference/endpoints/integrations/create", - "api-reference/endpoints/integrations/update", - "api-reference/endpoints/integrations/delete", - "api-reference/endpoints/integrations/list-project-integrations" - ] - }, - { - "group": "Service Tokens", - "pages": ["api-reference/endpoints/service-tokens/get"] - }, - { - "group": "Audit Logs", - "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] } ] }, @@ -2585,21 +2476,16 @@ "api-reference/endpoints/certificate-authorities/internal/create", "api-reference/endpoints/certificate-authorities/internal/read", "api-reference/endpoints/certificate-authorities/internal/update", - "api-reference/endpoints/certificate-authorities/internal/delete" + "api-reference/endpoints/certificate-authorities/internal/delete", + "api-reference/endpoints/certificate-authorities/internal/renew", + "api-reference/endpoints/certificate-authorities/internal/list-ca-certs", + "api-reference/endpoints/certificate-authorities/internal/csr", + "api-reference/endpoints/certificate-authorities/internal/cert", + "api-reference/endpoints/certificate-authorities/internal/sign-intermediate", + "api-reference/endpoints/certificate-authorities/internal/import-cert", + "api-reference/endpoints/certificate-authorities/internal/crl" ] - }, - "api-reference/endpoints/certificate-authorities/list", - "api-reference/endpoints/certificate-authorities/create", - "api-reference/endpoints/certificate-authorities/read", - "api-reference/endpoints/certificate-authorities/update", - "api-reference/endpoints/certificate-authorities/delete", - "api-reference/endpoints/certificate-authorities/renew", - "api-reference/endpoints/certificate-authorities/list-ca-certs", - "api-reference/endpoints/certificate-authorities/csr", - "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/crl" + } ] }, { @@ -2607,8 +2493,6 @@ "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", @@ -2621,23 +2505,11 @@ { "group": "Certificate Templates", "pages": [ - "api-reference/endpoints/certificate-templates-v2/list", - "api-reference/endpoints/certificate-templates-v2/create", - "api-reference/endpoints/certificate-templates-v2/update", - "api-reference/endpoints/certificate-templates-v2/get-by-id", - "api-reference/endpoints/certificate-templates-v2/delete" - ] - }, - { - "group": "Certificate Collections", - "pages": [ - "api-reference/endpoints/pki-collections/create", - "api-reference/endpoints/pki-collections/read", - "api-reference/endpoints/pki-collections/update", - "api-reference/endpoints/pki-collections/delete", - "api-reference/endpoints/pki-collections/add-item", - "api-reference/endpoints/pki-collections/list-items", - "api-reference/endpoints/pki-collections/delete-item" + "api-reference/endpoints/certificate-templates/list", + "api-reference/endpoints/certificate-templates/create", + "api-reference/endpoints/certificate-templates/update", + "api-reference/endpoints/certificate-templates/get-by-id", + "api-reference/endpoints/certificate-templates/delete" ] }, { @@ -2653,6 +2525,15 @@ "api-reference/endpoints/certificate-profiles/get-latest-active-bundle" ] }, + { + "group": "Certificate Alerts", + "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 Syncs", "pages": [ @@ -2674,6 +2555,18 @@ "api-reference/endpoints/pki/syncs/aws-certificate-manager/remove-certificates" ] }, + { + "group": "AWS Secrets Manager", + "pages": [ + "api-reference/endpoints/pki/syncs/aws-secrets-manager/list", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/get-by-id", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/create", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/update", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/delete", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/sync-certificates", + "api-reference/endpoints/pki/syncs/aws-secrets-manager/remove-certificates" + ] + }, { "group": "Azure Key Vault", "pages": [ @@ -2685,11 +2578,94 @@ "api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates", "api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates" ] + }, + { + "group": "Chef", + "pages": [ + "api-reference/endpoints/pki/syncs/chef/list", + "api-reference/endpoints/pki/syncs/chef/get-by-id", + "api-reference/endpoints/pki/syncs/chef/create", + "api-reference/endpoints/pki/syncs/chef/update", + "api-reference/endpoints/pki/syncs/chef/delete", + "api-reference/endpoints/pki/syncs/chef/sync-certificates", + "api-reference/endpoints/pki/syncs/chef/remove-certificates" + ] } ] } ] }, + { + "group": "Secret Scanning", + "pages": [ + { + "group": "Data Sources", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/list", + "api-reference/endpoints/secret-scanning/data-sources/options", + { + "group": "Bitbucket", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/create", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/update", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/delete", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan", + "api-reference/endpoints/secret-scanning/data-sources/bitbucket/scan-resource" + ] + }, + { + "group": "GitHub", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/github/list", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/github/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/github/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/github/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/github/create", + "api-reference/endpoints/secret-scanning/data-sources/github/update", + "api-reference/endpoints/secret-scanning/data-sources/github/delete", + "api-reference/endpoints/secret-scanning/data-sources/github/scan", + "api-reference/endpoints/secret-scanning/data-sources/github/scan-resource" + ] + }, + { + "group": "GitLab", + "pages": [ + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-id", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/get-by-name", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-resources", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/list-scans", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/create", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/update", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/delete", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan", + "api-reference/endpoints/secret-scanning/data-sources/gitlab/scan-resource" + ] + } + ] + }, + { + "group": "Findings", + "pages": [ + "api-reference/endpoints/secret-scanning/findings/list", + "api-reference/endpoints/secret-scanning/findings/update" + ] + }, + { + "group": "Configuration", + "pages": [ + "api-reference/endpoints/secret-scanning/config/get-by-project-id", + "api-reference/endpoints/secret-scanning/config/update" + ] + } + ] + }, { "group": "Infisical SSH", "pages": [ @@ -2783,6 +2759,15 @@ ] } ] + }, + { + "group": "Other", + "pages": [ + { + "group": "Admin", + "pages": ["api-reference/endpoints/admin/bootstrap-instance"] + } + ] } ] }, diff --git a/docs/documentation/getting-started/concepts/client-integrations.mdx b/docs/documentation/getting-started/concepts/client-integrations.mdx index bcd935830..aa7b37d4a 100644 --- a/docs/documentation/getting-started/concepts/client-integrations.mdx +++ b/docs/documentation/getting-started/concepts/client-integrations.mdx @@ -24,7 +24,7 @@ Infisical offers a non-exhaustive set of clients and interfaces to support a wid - [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical): Allows Infisical to act as a backend provider for syncing secrets into Kubernetes `Secret` objects using the widely adopted External Secrets Operator. -- [Kubernetes PKI Issuer](/documentation/platform/pki/pki-issuer): A controller that issues X.509 certificates from Infisical PKI using the cert-manager Issuer and Certificate CRDs. +- [Kubernetes cert-manager](/documentation/platform/pki/k8s-cert-manager): A controller that issues X.509 certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles) using the cert-manager Issuer and Certificate CRDs. - [Secret Syncs](/integrations/secret-syncs/overview): Native integrations to forward secrets to services like GitHub, GitLab, AWS Secrets Manager, Vercel, and more. diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index e10d594da..77342e36c 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -35,7 +35,7 @@ Infisical consists of several tightly integrated products, each designed to solv - [Secrets Management](/documentation/platform/secrets-mgmt/overview): Securely store, access, and distribute secrets across environments with fine-grained controls, automatic rotation, and audit logging. - [Secrets Scanning](/documentation/platform/secret-scanning/overview): Detect hardcoded secrets in code, CI pipelines, and infrastructure—integrated with GitHub, GitLab, Bitbucket, and more. -- [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. +- [Certificate Management](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. - [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. diff --git a/docs/documentation/guides/nextjs-vercel.mdx b/docs/documentation/guides/nextjs-vercel.mdx index ecefb0f2e..d0cdd639e 100644 --- a/docs/documentation/guides/nextjs-vercel.mdx +++ b/docs/documentation/guides/nextjs-vercel.mdx @@ -183,32 +183,7 @@ At this stage, you know how to use the Infisical CLI to inject secrets into your ## Infisical-Vercel integration for production environment variables -We'll now use the Infisical-Vercel integration send secrets from Infisical to Vercel as production environment variables. - -### Infisical-Vercel integration - -To begin we have to import the Next.js app into Vercel as a project. [Follow these instructions](https://vercel.com/docs/frameworks/nextjs) to deploy the Next.js app to Vercel. - -Next, navigate to your project's integrations tab in Infisical and press on the Vercel tile to grant Infisical access to your Vercel account. - -![integrations](../../images/integrations.png) - -![integrations vercel authorization](../../images/integrations/vercel/integrations-vercel-auth.png) - - - 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](/internals/security). - - -Now select **Production** for (the source) **Environment** and sync it to the **Production Environment** of the (target) application in Vercel. -Lastly, press create integration to start syncing secrets to Vercel. - -![integrations vercel](../../images/integrations/vercel/integrations-vercel-create.png) -![integrations vercel](../../images/integrations/vercel/integrations-vercel.png) - -You should now see your secret from Infisical appear as production environment variables in your Vercel project. +Use our [Vercel Secret Syncs](../../integrations/secret-syncs/vercel) guide to sync secrets from Infisical to Vercel as production environment variables. At this stage, you know how to use the Infisical-Vercel integration to sync production secrets from Infisical to Vercel. @@ -245,4 +220,4 @@ At this stage, you know how to use the Infisical-Vercel integration to sync prod See also: - [Documentation for the Infisical CLI](/cli/overview) -- [Documentation for the Vercel integration](/integrations/cloud/vercel) +- [Documentation for the Vercel Secret Sync](../../integrations/secret-syncs/vercel) diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx index c7b8763c2..0d8136775 100644 --- a/docs/documentation/platform/gateways/gateway-deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -127,7 +127,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order ``` - By default, the gateway connects to the most optimal relay. Use the `--relay` flag to manually specify a different relay server. + By default, the gateway connects to the most optimal relay. Use the `--target-relay-name` flag to manually specify a different relay server. @@ -177,7 +177,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order ``` - By default, the gateway connects to the most optimal relay. Use the `--relay` flag to manually specify a different relay server. + By default, the gateway connects to the most optimal relay. Use the `--target-relay-name` flag to manually specify a different relay server. diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index d7b7663a9..964a3ad7c 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -16,15 +16,37 @@ Key Features: - Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. - Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. +## Scopes + +Identities can be created either at the organization-level or the project-level. Outside of identity management and scope of operation, organization and project identities are functionally identical. + +- Project identities are managed at the project-level and can only operate within their respective project. +Project-level identities are useful for organizations that delegate responsibility to autonomous teams via projects. + +- Organization identities are managed at the organization-level and can be assigned to one or more projects, as well as +perform organization-level operations. Organization-level identities are useful for organizations that have cross-project operations. + ## Workflow -A typical workflow for using identities consists of four steps: + + + A typical workflow for using project identities consists of three steps: -1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Organization Access Control > Machine Identities. - This step also involves configuring an authentication method for it. -2. Adding the identity to the project(s) you want it to have access to. -3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. -4. Authenticating subsequent requests with the Infisical API using the short-lived access token. + 1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Project > Access Control > Machine Identities. + This step also involves configuring an authentication method for it. + 2. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. + 3. Authenticating subsequent requests with the Infisical API using the short-lived access token. + + + A typical workflow for using organization identities consists of four steps: + + 1. Creating the identity with a name and [role](/documentation/platform/access-controls/role-based-access-controls) in Organization > Access Control > Machine Identities. + This step also involves configuring an authentication method for it. + 2. Adding the identity to the project(s) you want it to have access to. + 3. Authenticating the identity with the Infisical API based on the configured authentication method on it and receiving a short-lived access token back. + 4. Authenticating subsequent requests with the Infisical API using the short-lived access token. + + ## Authentication Methods diff --git a/docs/documentation/platform/integrations.mdx b/docs/documentation/platform/integrations.mdx deleted file mode 100644 index 2414c5c14..000000000 --- a/docs/documentation/platform/integrations.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Integrations" -description: "How to sync your secrets among various 3rd-party services with Infisical." ---- - -Integrations allow environment variables to be synced across your entire infrastructure from local development to CI/CD and production. - - - View all available integrations and their guides - - -![integrations](../../images/integrations.png) diff --git a/docs/documentation/platform/pam/architecture.mdx b/docs/documentation/platform/pam/architecture.mdx new file mode 100644 index 000000000..5f5a12433 --- /dev/null +++ b/docs/documentation/platform/pam/architecture.mdx @@ -0,0 +1,77 @@ +--- +title: "Architecture" +sidebarTitle: "Architecture" +description: "Learn about the architecture, components, and security model of Infisical PAM." +--- + +Infisical PAM utilizes a secure, proxy-based architecture designed to provide access to private resources without exposing them directly to the internet. This system relies on a combination of the Infisical CLI, a Relay server, and a self-hosted Gateway. For more information on Gateways, refer to the [Gateway Overview](/documentation/platform/gateways/overview). + +## Core Components + +The architecture consists of three main components working in unison: + + + + The client-side interface used to initiate access requests. It creates a local listener that forwards traffic securely to the Gateway. + + + A lightweight service deployed within your private network (e.g., VPC, on-prem). It acts as a proxy, intercepting traffic to enforce policies and record sessions before forwarding requests to the target resource. + + + The actual infrastructure being accessed, such as a PostgreSQL database, a Linux server, or a web application. + + + +## Access Flow + +```mermaid +graph LR + subgraph Client ["User Environment"] + CLI["Infisical CLI"] + end + + Relay["Relay Server"] + + subgraph Network ["Private Network (VPC)"] + Gateway["Infisical Gateway"] + DB[("Target Resource (Database/Server)")] + end + + CLI <-->|Encrypted Tunnel| Relay + Relay <-->|Reverse Tunnel| Gateway + Gateway <-->|Native Protocol| DB +``` + +When a user accesses a resource (e.g., via `infisical access`), the following workflow occurs: + +1. **Connection Initiation**: The Infisical CLI initiates a connection to the Relay server. +2. **Tunnel Establishment**: The Relay facilitates an end-to-end encrypted tunnel between the CLI and the Gateway. +3. **Proxy & Credential Injection**: The Gateway authenticates the request and connects to the target resource on the user's behalf. It automatically injects the necessary credentials (e.g., database passwords, SSH keys), ensuring the user never directly handles sensitive secrets. +4. **Traffic Forwarding**: Traffic flows securely from the user's machine, through the Relay, to the Gateway, and finally to the resource. + +## Session Recording & Auditing + +![Session Logging](/images/pam/architecture/session-logging.png) + +A key feature of the Gateway is its ability to act as a "middleman" for all session traffic. + +- **Interception**: Because the Gateway sits between the secure tunnel and the target resource, it intercepts all data flowing through the connection. +- **Logging**: This traffic is logged as part of [Session Recording](/documentation/platform/pam/product-reference/session-recording). The Gateway temporarily stores encrypted session logs locally. +- **Upload**: Once the session concludes, the logs are securely uploaded to the Infisical platform for storage and review. + +## Security Architecture + +The PAM security model allows you to maintain a zero-trust environment while enabling convenient access. + +### End-to-End Encryption +The connection between the Infisical CLI (client) and the Gateway is end-to-end encrypted. The Relay server acts solely as a router for encrypted packets and **cannot decrypt or inspect** the traffic passing through it. + +### Network Security +The Gateway uses **SSH reverse tunnels** to connect to the Relay. This design offers significant security benefits: +- **No Inbound Ports**: You do not need to open any inbound firewall ports (like 22 or 5432) to the internet. +- **Outbound-Only**: The Gateway only requires outbound connectivity to the Relay server and Infisical API. + +For a deep dive into the underlying cryptography, certificate management, and isolation guarantees, refer to the [Gateway Security Architecture](/documentation/platform/gateways/security). + +### Deployment +For instructions on setting up the necessary infrastructure, see the [Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment). diff --git a/docs/documentation/platform/pam/getting-started/accounts.mdx b/docs/documentation/platform/pam/getting-started/accounts.mdx new file mode 100644 index 000000000..4ed0616f8 --- /dev/null +++ b/docs/documentation/platform/pam/getting-started/accounts.mdx @@ -0,0 +1,47 @@ +--- +title: "PAM Account" +sidebarTitle: "Accounts" +description: "Learn how to create and manage accounts in PAM to control access to resources like databases and servers." +--- + +An **Account** contains the credentials (such as a username and password) used to connect to a [Resource](/documentation/platform/pam/getting-started/resources). + +## Relationship to Resources + +Accounts belong to Resources. A single Resource can have multiple Accounts associated with it, each with different permission levels. + +For example, your database would normally have multiple accounts. You might have a superuser account for admins, a standard read/write account for applications, and a read-only account for reporting. + +In PAM, these are represented as: +- **Resource**: `Production Database` (PostgreSQL) + - **Account 1**: `postgres` (Superuser) + - **Account 2**: `app_user` (Read/Write) + - **Account 3**: `analytics` (Read-only) + +When a user requests access in PAM, they request access to a specific **Account** on a **Resource**. + +## Creating an Account + + + **Prerequisite**: You must have at least one [Resource](/documentation/platform/pam/getting-started/resources) created before adding accounts. + + +To add an account, navigate to the **Accounts** tab in your PAM project and click **Add Account**. + +![Add Account Button](/images/pam/getting-started/accounts/add-account-button.png) + +Next, select the **Resource** that this account belongs to. + +![Select Resource](/images/pam/getting-started/accounts/select-resource.png) + +After selecting a resource, provide the credentials (username, password, etc.) for this account. The required fields vary depending on the resource type. For example, for a Linux server, you would enter the username and the corresponding password or SSH key. + +![Create Account](/images/pam/getting-started/accounts/create-account.png) + +Clicking **Create Account** will trigger a validation check. Infisical will attempt to connect to the resource using the provided credentials to verify they are valid. + +## Automated Credential Rotation + +Infisical supports automated credential rotation for some accounts on select resources, allowing you to automatically change passwords at set intervals to enhance security. + +To learn more about how to configure this, please refer to the [Credential Rotation guide](/documentation/platform/pam/product-reference/credential-rotation). diff --git a/docs/documentation/platform/pam/getting-started/resources.mdx b/docs/documentation/platform/pam/getting-started/resources.mdx new file mode 100644 index 000000000..4eaeebb74 --- /dev/null +++ b/docs/documentation/platform/pam/getting-started/resources.mdx @@ -0,0 +1,45 @@ +--- +title: "PAM Resource" +sidebarTitle: "Resources" +description: "Learn how to add and configure resources like databases and servers, and set up automated credential rotation." +--- + +A resource represents a target system, such as a database, server, or application, that you want to manage access to. Some examples of resources are: +- PostgreSQL Database +- MCP Server +- Linux Server +- Web Application + +## Prerequisites + +Before you can create a resource, you must have an **Infisical Gateway** deployed that is able to reach the target resource over the network. + +The Gateway acts as a secure bridge, allowing Infisical to reach your private infrastructure without exposing it to the public internet. When creating a resource, you will be asked to specify which Gateway should be used to connect to it. + +[Read the Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment) + +## Creating a Resource + +To add a resource, navigate to the **Resources** tab in your PAM project and click **Add Resource**. + +![Add Resource Button](/images/pam/getting-started/resources/add-resource-button.png) + +Next, select the type of resource you want to add. + +![Select Resource Type](/images/pam/getting-started/resources/select-resource-type.png) + +After selecting a resource type, provide the necessary connection details. The required fields vary depending on the resource type. + +**Important**: You must select the **Gateway** that has network access to this resource. + +In this PostgreSQL example, you provide details such as host, port, gateway, and database name. + +![Create Resource](/images/pam/getting-started/resources/create-resource.png) + +Clicking **Create Resource** will trigger a connection test from the selected Gateway to your target resource. If the connection fails, an error message will be displayed to help you troubleshoot (usually indicating a network firewall issue between the Gateway and the Resource). + +## Automated Credential Rotation + +Some resources, such as PostgreSQL, support automated credential rotation to enhance your security posture. This feature requires configuring a privileged "Rotation Account" on the resource. + +To learn more about how to configure this, please refer to the [Credential Rotation guide](/documentation/platform/pam/product-reference/credential-rotation). diff --git a/docs/documentation/platform/pam/getting-started/setup.mdx b/docs/documentation/platform/pam/getting-started/setup.mdx new file mode 100644 index 000000000..8da923712 --- /dev/null +++ b/docs/documentation/platform/pam/getting-started/setup.mdx @@ -0,0 +1,35 @@ +--- +title: "Setup" +sidebarTitle: "Setup" +description: "This guide provides a step-by-step walkthrough for configuring Infisical's Privileged Access Management (PAM). Learn how to deploy a gateway, define resources, and grant your team secure, audited access to critical infrastructure." +--- + +Infisical's Privileged Access Management (PAM) solution enables you to provide developers with secure, just-in-time access to your critical infrastructure, such as databases, servers, and web applications. Instead of sharing static credentials, your team can request temporary access through Infisical, which is then brokered through a secure gateway with full auditing and session recording. + +Getting started involves a few key components: +- **Gateways:** A lightweight service you deploy in your own infrastructure to act as a secure entry point to your private resources. +- **Resources:** The specific systems you want to manage access to (e.g., a PostgreSQL database or an SSH server). +- **Accounts:** The privileged credentials (e.g., a database user or an SSH user) that Infisical uses to connect to a resource on behalf of a user. + +The following steps will guide you through the entire setup process, from deploying your first gateway to establishing a secure connection. + + + + Before you can manage any resources, you must deploy an **Infisical Gateway** within your infrastructure. This component is responsible for brokering connections to your private resources. + + [Read the Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment) + + + Once the Gateway is active, define a **Resource** in Infisical (e.g., "Production Database"). You will link this resource to your deployed Gateway so Infisical knows how to reach it. + + [Learn about Resources](/documentation/platform/pam/getting-started/resources) + + + Add **Accounts** to your Resource (e.g., `postgres` or `read_only_user`). These represent the actual PAM users or privileged identities that are utilized when a user connects. + + [Learn about Accounts](/documentation/platform/pam/getting-started/accounts) + + + Users can now use the Infisical CLI to securely connect to the resource using the defined accounts, with full auditing and session recording enabled. + + diff --git a/docs/documentation/platform/pam/overview.mdx b/docs/documentation/platform/pam/overview.mdx index a6e0094f5..2b311c48c 100644 --- a/docs/documentation/platform/pam/overview.mdx +++ b/docs/documentation/platform/pam/overview.mdx @@ -1,45 +1,67 @@ --- -title: "Infisical PAM" +title: "Overview" sidebarTitle: "Overview" -description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals." +description: "Manage and secure access to critical infrastructure like databases and servers with policy-based controls and approvals." --- Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to. -### How it Works +## The PAM Workflow -Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable. +At its core, Infisical PAM is designed to decouple **user identity** from **infrastructure credentials**. Instead of sharing static passwords or SSH keys, users authenticate with their SSO identity, and Infisical handles the rest. -#### 1. Create a Resource +Here is how a typical access lifecycle looks: -The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port. +1. **Discovery**: A user logs into Infisical and sees a catalog of resources (databases, servers) and accounts they are allowed to access. +2. **Connection**: The user selects a resource and an account (e.g., "Production DB" as `read_only`). They initiate the connection via the Infisical CLI. +3. **Credential Injection**: Infisical validates the request. If allowed, it establishes a secure tunnel and automatically injects the credentials for the target account. **The user never sees the underlying password or key.** +4. **Monitoring**: The session is established. All traffic is intercepted, logged, and recorded for audit purposes. -![Create Resource](/images/pam/overview/create-resource.png) +## Core Concepts -#### 2. Add Accounts to the Resource +To successfully implement Infisical PAM, it is essential to understand the relationship between the following components: -Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place. + + + A lightweight service deployed in your network that acts as a secure bridge to your private infrastructure. + + + The specific target you are protecting (e.g., a PostgreSQL database or an Ubuntu server). + + + The specific identity on the Resource that the user is trying to access. One Resource can have multiple Accounts. + + -![Create Account](/images/pam/overview/create-account.png) +### Relationship Model -### Infisical PAM Features +The hierarchy is structured as follows: -#### Session Logging and Auditing +```mermaid +graph TD + GW[Gateway] --> |Provides Access| DB[Resource: Production DB] + GW[Gateway] --> |Provides Access| SRV[Resource: Linux Server] + + DB --> A1[Account: admin] + DB --> A2[Account: readonly] + + SRV --> A3[Account: ubuntu] +``` -- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session. -- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project. +1. **Gateway**: Deployed once per network/VPC. It provides connectivity to all resources in that environment. +2. **Resource**: Configured within Infisical. It points to a specific IP/Host accessible by the Gateway. +3. **Account**: Defined under a Resource. Users request access to a specific *Account* on a *Resource*. -![Session Page](/images/pam/overview/session-page.png) +## Network Architecture -#### Automated Credential Rotation +Infisical PAM uses a secure proxy-based architecture to connect users to resources without direct network exposure. -Infisical PAM can automatically rotate account credentials to enhance your security posture. +When a user accesses a resource, their connection is routed securely through a Relay to your self-hosted Gateway, which then connects to the target resource. This ensures zero-trust access without exposing your infrastructure to the public internet. -Here’s how it works: -1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource. -![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png) +For a deep dive into the technical architecture and security model, see [Architecture](/documentation/platform/pam/architecture). -2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days). -![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png) +## Core Capabilities -Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness. +- **[Auditing](/documentation/platform/pam/product-reference/auditing)**: Track and review a comprehensive log of all user actions and system events. +- **[Session Recording](/documentation/platform/pam/product-reference/session-recording)**: Record and playback user sessions for security reviews, compliance, and troubleshooting. +- **[Automated Credential Rotation](/documentation/platform/pam/product-reference/credential-rotation)**: Automatically rotate credentials for supported resources to minimize the risk of compromised credentials. diff --git a/docs/documentation/platform/pam/product-reference/auditing.mdx b/docs/documentation/platform/pam/product-reference/auditing.mdx new file mode 100644 index 000000000..e716b7f76 --- /dev/null +++ b/docs/documentation/platform/pam/product-reference/auditing.mdx @@ -0,0 +1,23 @@ +--- +title: "Auditing" +sidebarTitle: "Auditing" +description: "Learn how Infisical audits all actions across your PAM project." +--- + +## What's Audited + +Infisical logs a wide range of actions to provide a complete audit trail for your PAM project. These actions include: + +- Session Start and End +- Fetching session credentials +- Creating, updating, or deleting resources, accounts, folders, and sessions + + + Please note: Audit logs track metadata about sessions (e.g., start/end times), but not the specific commands executed *within* them. For detailed in-session activity, check out [Session Recording](/documentation/platform/pam/product-reference/session-recording). + + +## Viewing Audit Logs + +You can view, search, and filter all events from the **Audit Logs** page within your PAM project. + +![Audit Logs](/images/pam/product-reference/auditing/audit-logs.png) diff --git a/docs/documentation/platform/pam/product-reference/credential-rotation.mdx b/docs/documentation/platform/pam/product-reference/credential-rotation.mdx new file mode 100644 index 000000000..c3204ff56 --- /dev/null +++ b/docs/documentation/platform/pam/product-reference/credential-rotation.mdx @@ -0,0 +1,47 @@ +--- +title: "Credential Rotation" +sidebarTitle: "Credential Rotation" +description: "Learn how to automate credential rotation for your PAM resources." +--- + +Automated Credential Rotation enhances your security posture by automatically changing the passwords of your accounts at set intervals. This minimizes the risk of compromised credentials by ensuring that even if a password is leaked, it remains valid only for a short period. + +## How it Works + +When rotation is enabled, Infisical's Gateway connects to the target resource using a privileged "Rotation Account". It then executes the necessary commands to change the password for the target user account to a new, cryptographically secure random value. + +## Configuration + +Setting up automated rotation requires a two-step configuration: first at the Resource level, and then at the individual Account level. + + + + A **Rotation Account** is a master or privileged account that has the necessary permissions to change the passwords of other users on the target system. + + When creating or editing a [Resource](/documentation/platform/pam/getting-started/resources), you must provide the credentials for this privileged account. + + *Example: For a PostgreSQL database, this would typically be the `postgres` superuser or another role with `ALTER ROLE` privileges.* + + ![Credential Rotation Account](/images/pam/getting-started/resources/credential-rotation-account.png) + + + + Once the resource has a rotation account configured, you can enable rotation for individual [Accounts](/documentation/platform/pam/getting-started/accounts) that belong to that resource. + + In the account settings: + 1. Toggle **Enable Rotation**. + 2. Set the **Rotation Interval** (e.g., every 7 days, 30 days). + + ![Rotate Credentials Account](/images/pam/getting-started/resources/rotate-credentials-account.png) + + + +## Supported Resources + +Automated rotation is currently supported for the following resource types: + +- **PostgreSQL**: Requires a user with `ALTER ROLE` permissions. + + + We are constantly adding support for more resource types. + diff --git a/docs/documentation/platform/pam/product-reference/session-recording.mdx b/docs/documentation/platform/pam/product-reference/session-recording.mdx new file mode 100644 index 000000000..954f2f992 --- /dev/null +++ b/docs/documentation/platform/pam/product-reference/session-recording.mdx @@ -0,0 +1,60 @@ +--- +title: "Session Recording" +sidebarTitle: "Session Recording" +description: "Learn how Infisical records and stores session activity for auditing and monitoring." +--- + +Infisical PAM provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure. + +## How It Works + +When a user initiates a session by accessing an account, a recording of the session begins. The Gateway securely caches all recording data in temporary encrypted files on its local system. + +Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs on the Infisical platform. + +## What's Captured + +The content captured during a session depends on the type of resource being accessed. + + + + Infisical captures all queries executed and their corresponding responses, including timestamps for each action. + + + Infisical captures all commands executed and their corresponding responses, including timestamps for each action. + + + +## Viewing Recordings + +To review session recordings: + +1. Navigate to the **Sessions** page in your PAM project. +2. Click on a session from the list to view its details. + +![PAM Sessions](/images/pam/product-reference/session-recording/sessions-page.png) + +The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more. + +![PAM Individual Session](/images/pam/product-reference/session-recording/individual-session-page.png) + +### Searching Logs + +You can use the search bar to quickly find relevant information: + +**Sessions page:** Search across all session logs to locate specific queries or outputs. +![PAM Sessions Search](/images/pam/product-reference/session-recording/sessions-page-search.png) + +**Individual session page:** Search within that specific session's logs to pinpoint activity. +![PAM Individual Session Search](/images/pam/product-reference/session-recording/individual-session-page-search.png) + +## FAQ + + + + Yes. All session recordings are encrypted at rest by default, ensuring your data is always secure. + + + Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release. + + diff --git a/docs/documentation/platform/pam/session-recording.mdx b/docs/documentation/platform/pam/session-recording.mdx deleted file mode 100644 index e9061430c..000000000 --- a/docs/documentation/platform/pam/session-recording.mdx +++ /dev/null @@ -1,60 +0,0 @@ ---- -title: "Session Recording" -sidebarTitle: "Session Recording" -description: "Learn how Infisical records and stores session activity for auditing and monitoring." ---- - -Infisical's Privileged Access Management (PAM) provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure. - -## How It Works - -When a user initiates a session through the Infisical Gateway, a recording of the session begins. The gateway securely caches all recording data in temporary encrypted files on its local system. - -Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs in the Infisical UI. - -## What's Captured - -The content captured during a session depends on the type of resource being accessed. - -### Database Sessions - -For database connections, Infisical captures all queries executed and their corresponding responses. - - -Support for additional resource types like SSH, RDP, Kubernetes, and MCP is coming soon. - - -## Viewing Recordings - -To review session recordings: - -1. Navigate to the **PAM Sessions** page in your project. -2. Click on a session from the list to view its details. - -![PAM Sessions](/images/pam/session-recording/sessions-page.png) - -The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more. - -![PAM Individual Session](/images/pam/session-recording/individual-session-page.png) - -### Searching Logs - -You can use the search bar to quickly find relevant information: - -- **On the main Sessions page:** Search across all session logs to locate specific queries or outputs. -- **On an individual session page:** Search within that specific session's logs to pinpoint activity. - -![PAM Sessions Search](/images/pam/session-recording/sessions-page-search.png) - -![PAM Individual Session Search](/images/pam/session-recording/individual-session-page-search.png) - -## FAQ - - - - Yes. All session recordings are encrypted at rest by default, ensuring your audit data is always secure. - - - Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release. - - diff --git a/docs/documentation/platform/pki/ca/acme-ca.mdx b/docs/documentation/platform/pki/ca/acme-ca.mdx index 76f5cdc8f..7f2290ff6 100644 --- a/docs/documentation/platform/pki/ca/acme-ca.mdx +++ b/docs/documentation/platform/pki/ca/acme-ca.mdx @@ -1,66 +1,63 @@ --- 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" +description: "Learn how to connect Infisical to an ACME-compatible CA to issue certificates." --- ## Concept -The Infisical ACME integration allows you to connect with ACME (Automatic Certificate Management Environment) Certificate Authorities to automatically issue and manage publicly trusted TLS certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established public CA infrastructure like Let's Encrypt while centralizing your certificate management within Infisical. +Infisical can connect to any upstream ACME-compatible CA (e.g. Lets's Encrypt, DigiCert, etc.) supporting the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) to issue certificates back to your end-entities. This integration uses the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) method as part of the ACME domain validation challenge workflow for a requested certificate. -ACME is a protocol that automates the process of certificate issuance and renewal through domain validation challenges. The integration is perfect for obtaining trusted X.509 certificates for public-facing services and is capable of automatically renewing certificates as needed. +The upstream ACME-compatible CA integration lets you connect Infisical to providers by specifying +their **ACME Directory URL** such as: + +- [Let's Encrypt](/documentation/platform/pki/ca/lets-encrypt): `https://acme-v02.api.letsencrypt.org/directory`. +- [DigiCert](/documentation/platform/pki/ca/digicert): `https://acme.digicert.com/v2/acme/directory`. +- Google GTS: `https://dv.acme-v02.api.pki.goog/directory`. +- Buypass: `https://api.buypass.com/acme/directory`. +- ZeroSSL: `https://acme.zerossl.com/v2/DV90`. +- SSL.com: `https://acme.ssl.com/sslcom-dv-rsa`. + +When Infisical requests a certificate from an ACME-compatible CA, it creates a TXT record at `_acme-challenge.{your-domain}` in your configured DNS provider (e.g. Route53, Cloudflare, DNS Made Easy, etc.); this TXT record contains the challenge token issued by the ACME-compatible CA to validate domain control for the requested certificate. +The ACME provider checks for the existence of this TXT record to verify domain control before issuing the certificate back to Infisical. + +After validation completes successfully, Infisical automatically removes the TXT record from your DNS provider.
```mermaid graph TD - A[ACME CA Provider
e.g., Let's Encrypt] <-->|ACME v2 Protocol| B[Infisical] - B -->|Creates TXT Records
via Route53/Cloudflare| C[DNS Validation] - B -->|Manages Certificates| D[Subscribers] + A[ACME-compatible CA] <-->|ACME v2 Protocol| B[Infisical] + B -->|Creates TXT Records
via DNS Provider| C[DNS Validation] + B -->|Manages Certificates| D[End-Entities] ```
-As part of the workflow, you configure DNS provider credentials, register an ACME CA provider with Infisical, and create subscribers to represent the certificates you wish to issue. Each issued certificate is automatically managed through its lifecycle, including renewal before expiration. - -We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) and [DNS-01 challenges](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) for a fuller understanding of the underlying technology. +We recommend reading about [ACME protocol](https://tools.ietf.org/html/rfc8555) and [DNS-01 challenges](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) for a fuller understanding of the underlying workflow. ## Workflow -A typical workflow for using Infisical with ACME Certificate Authorities consists of the following steps: +A typical workflow for using Infisical with an external ACME-compatible CA consists of the following steps: -1. Setting up AWS Route53 or Cloudflare credentials with appropriate DNS permissions. -2. Creating an AWS/Cloudflare connection in Infisical to store the credentials. -3. Registering an ACME Certificate Authority (like Let's Encrypt) with Infisical. -4. Creating subscribers that use the ACME CA as their issuing authority. -5. Managing certificate lifecycle events such as issuance, renewal, and revocation through Infisical. +1. Setting up your DNS provider (e.g. Route53, Cloudflare, etc.) with appropriate DNS permissions. +2. Creating an [App Connection](/integrations/app-connections/overview) in Infisical to store credentials for Infisical to connect to your DNS provider and create/remove DNS records as part of the DNS-01 challenge. +3. Registering an [External CA](/documentation/platform/pki/ca/external-ca) in Infisical with the ACME type and inputting required configuration including the **ACME Directory URL** of the upstream ACME-compatible CA and the **App Connection** for your DNS provider. -## Understanding ACME DNS-01 Challenge +Once this is complete, you can create a [certificate profile](/documentation/platform/pki/certificates/profiles) linked to the External CA proceed to request a certificate against it. -The DNS-01 challenge is the method used by ACME CA providers to verify that you control a domain before issuing a certificate. Here's how Infisical handles this process: +## Guide to Connecting Infisical to an ACME-compatible CA -1. **Challenge Request**: When you request a certificate, the ACME provider (like Let's Encrypt) issues a challenge token. - -2. **DNS Record Creation**: Infisical creates a TXT record at `_acme-challenge.` with a value derived from the challenge token. - -3. **DNS Propagation**: The TXT record must propagate through the DNS system (usually takes a few minutes, depending on TTL settings). - -4. **Validation**: The ACME provider checks for the existence of this TXT record to verify domain control. - -5. **Cleanup**: After validation completes successfully, Infisical automatically removes the TXT record from your DNS. - -This automated process eliminates the need for manual intervention in domain validation, streamlining certificate issuance. - -## Guide - -In the following steps, we explore how to set up ACME Certificate Authority integration with Infisical using Let's Encrypt as an example. +In the following steps, we explore how to connect Infisical to an ACME-compatible CA. - - Before proceeding with the ACME CA registration, you need to set up an App Connection with the appropriate permissions for DNS validation: + + Before registering an ACME-compatible CA with Infisical, you need to set up an [App Connection](/integrations/app-connections/overview) with the appropriate permissions for Infisical to perform the DNS-01 challenge with your DNS provider. + + If you don’t see a specific DNS provider listed below or need a dedicated one, please reach out to sales@infisical.com and we’ll help get that enabled for you. - 1. Navigate to your Organization Settings > App Connections and create a new AWS connection. + 1. Navigate to your Certificate Management Project > App Connections and create a new AWS connection. 2. Ensure your AWS connection has the following minimum permissions for Route53 DNS validation: @@ -112,7 +109,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte For detailed instructions on setting up an AWS connection, see the [AWS Connection](/integrations/app-connections/aws) documentation. - 1. Navigate to your Organization Settings > App Connections and create a new Cloudflare connection. + 1. Navigate to your Certificate Management Project > App Connections and create a new Cloudflare connection. 2. Ensure your Cloudflare token has the following minimum permissions for DNS validation: @@ -123,58 +120,45 @@ In the following steps, we explore how to set up ACME Certificate Authority inte For detailed instructions on setting up a Cloudflare connection, see the [Cloudflare Connection](/integrations/app-connections/cloudflare) documentation. + + Navigate to your Certificate Management Project > App Connections and create a new DNS Made Easy connection. + + For detailed instructions on setting up a DNS Made Easy connection, see the [DNS Made Easy Connection](/integrations/app-connections/dns-made-easy) documentation. + - + - - - To register an ACME CA, head to your Project > Internal PKI > Certificate Authorities and press the **+** button in the External Certificate Authorities section. + To register an ACME-compatible CA, head to your Certificate Management Project > Certificate Authorities > External Certificate Authorities and press **Create CA**. - ![pki register external ca](/images/platform/pki/ca/external-ca/create-external-ca-button.png) + ![pki register external ca](/images/platform/pki/ca/external-ca/create-external-ca-button.png) - Fill out the details for the ACME CA registration: + Here, set the **CA Type** to **ACME** and fill out details for it. - ![pki register external ca details](/images/platform/pki/ca/external-ca/create-external-ca-form.png) + ![pki register external ca details](/images/platform/pki/ca/external-ca/create-external-ca-form.png) - Here's guidance on each field: + Here's some guidance for each field: - - **Type**: Select "ACME" as the External CA type. - - **Name**: Enter a name for the ACME CA (e.g., "lets-encrypt-production"). - - **DNS App Connection**: Select from available DNS app connections or configure a new one. This connection provides Infisical with the credentials needed to create and remove DNS records for ACME validation. - - **Zone ID**: Enter the Zone ID for the domain(s) you'll be requesting certificates for. - - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. - - **EAB Key Identifier (KID)**: (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them. - - **EAB HMAC Key**: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration. + - Name: A slug-friendly name for the ACME-compatible CA such as `lets-encrypt-production`. + - DNS App Connection: The App Connection from Step 1 used for Infisical to connect to your DNS provider and create/remove DNS records as part of the DNS-01 challenge in ACME. + - Zone / Zone ID: Enter the Zone / Zone ID for the domain(s) you'll be requesting certificates for. + - Directory URL: Enter the **ACME Directory URL** for your desired upstream ACME-compatible CA such as `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt. + - Account Email: The email address to associate with your ACME account. This email will receive important notifications about your certificates. + - EAB Key Identifier (KID): (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them. + - EAB HMAC Key: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration. - Finally, press **Create** to register the ACME CA with Infisical. - - - Once registered, your ACME CA will appear in the External Certificate Authorities section. + Finally, press **Create** to register the ACME-compatible CA with Infisical. - ![pki external ca list](/images/platform/pki/ca/external-ca/external-ca-list.png) - - From here, you can: - - - View the status of the ACME CA registration - - Edit the configuration settings - - Disable or re-enable the ACME CA - - Delete the ACME CA registration if no longer needed - - You can now use this ACME CA to issue certificates for your subscribers. - - + Great! You’ve successfully registered an external ACME-compatible CA with Infisical. Now check out the [Certificates](/documentation/platform/pki/certificates/overview) section to learn more about how to issue X.509 certificates using the ACME-compatible CA. - To register an ACME CA with Infisical using the API, make a request to the Create External CA endpoint: + To register an ACME CA with Infisical using the API, make a request to the [Create External CA](https://infisical.com/docs/api-reference/endpoints/certificate-authorities/acme/create) endpoint: ### Sample request ```bash Request - curl 'https://app.infisical.com/api/v1/pki/ca/acme' \ + curl 'https://app.infisical.com/api/v1/cert-manager/ca/acme' \ -H 'Authorization: Bearer ' \ -H 'Content-Type: application/json' \ --data-raw '{ @@ -227,78 +211,9 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - - Next, create a subscriber that uses your ACME CA for certificate issuance. Navigate to your Project > Subscribers and create a new subscriber. - - Configure the subscriber with: - - **Issuing CA**: Select your registered ACME CA - - **Common Name**: The domain for which you want to issue certificates (e.g., `example.com`) - - **Alternative Names**: Additional domains to include in the certificate - - Check out the [Subscribers](/documentation/platform/pki/subscribers) page for detailed instructions on creating and managing subscribers. - - - Once your subscriber is configured, you can issue certificates either through the Infisical UI or programmatically via the API. - - When you request a certificate: - 1. Infisical generates a key pair for the certificate - 2. Sends a Certificate Signing Request (CSR) to the ACME CA - 3. Receives a DNS-01 challenge from the ACME provider - 4. Creates a TXT record in Route53/Cloudflare to satisfy the challenge - 5. Notifies the ACME provider that the challenge is ready for validation - 6. Once validated, the ACME provider issues the certificate - 7. Infisical stores and manages the certificate for your subscriber - - The certificate will be automatically renewed before expiration according to your subscriber configuration. - - - 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/certificate-profiles/get-latest-active-bundle) - -## Example: Let's Encrypt Integration - -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. - - -## Example: DigiCert Integration - -DigiCert is a leading commercial Certificate Authority providing a wide range of trusted SSL/TLS certificates. Infisical can integrate with [DigiCert's ACME](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/third-party-acme-integration/request-and-manage-certificates-with-acme.html) service to automate the provisioning and management of these certificates. - -- **Directory URL**: `https://acme.digicert.com/v2/acme/directory` -- **External Account Binding (EAB)**: Required. You will need a Key Identifier (KID) and HMAC Key from your DigiCert account to register the ACME CA in Infisical. -- **Certificate Validity**: Typically 90 days, with automatic renewal through Infisical. -- **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. - - ## FAQ @@ -325,17 +240,8 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Reduce the impact of compromised certificates - 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: - - - Different providers for different domains or use cases - - Staging and production environments for the same provider - - Backup providers for redundancy - - Each subscriber can be configured to use a specific ACME CA based on your requirements. - + Yes. You can register multiple ACME CAs in the same project.
diff --git a/docs/documentation/platform/pki/ca/digicert.mdx b/docs/documentation/platform/pki/ca/digicert.mdx new file mode 100644 index 000000000..b83c86320 --- /dev/null +++ b/docs/documentation/platform/pki/ca/digicert.mdx @@ -0,0 +1,16 @@ +--- +title: "DigiCert" +description: "Learn how to connect Infisical to DigiCert to issue certificates." +--- + +## Concept + +Infisical can connect to [DigiCert](https://www.digicert.com/) using the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) to issue certificates back to your end-entities. + +## Guide to Connecting Infisical to DigiCert CA + +To connect Infisical to DigiCert, follow the steps in the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) guide but use the DigiCert **ACME Directory URL**: `https://acme.digicert.com/v2/acme/directory`. + +DigiCert requires **External Account Binding (EAB)** for all ACME registrations. You will need to obtain both a Key Identifier (KID) and an HMAC Key from your DigiCert account before registering the ACME CA in Infisical. + +DigiCert typically issues certificates with a 90-day validity period. diff --git a/docs/documentation/platform/pki/ca/external-ca.mdx b/docs/documentation/platform/pki/ca/external-ca.mdx index 1dc89ec96..7e57d50a1 100644 --- a/docs/documentation/platform/pki/ca/external-ca.mdx +++ b/docs/documentation/platform/pki/ca/external-ca.mdx @@ -6,7 +6,7 @@ description: "Learn how to connect External Certificate Authorities with Infisic ## 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. +Infisical lets you integrate with External Certificate Authorities (CAs), allowing you to use existing PKI infrastructure or connect to public CAs to issue certificates for your end-entities.
@@ -23,7 +23,7 @@ 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. +Note that Infisical can act as an _ACME client_, allowing you to integrate upstream with any [ACME-compatible CA](/documentation/platform/pki/ca/acme-ca) to automate certificate issuance and renewal. ## Workflow diff --git a/docs/documentation/platform/pki/ca/lets-encrypt.mdx b/docs/documentation/platform/pki/ca/lets-encrypt.mdx new file mode 100644 index 000000000..75c7988de --- /dev/null +++ b/docs/documentation/platform/pki/ca/lets-encrypt.mdx @@ -0,0 +1,16 @@ +--- +title: "Let's Encrypt" +description: "Learn how to connect Infisical to Let's Encrypt to issue certificates." +--- + +## Concept + +Infisical can connect to [Let's Encrypt](https://letsencrypt.org/) using the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) to issue certificates back to your end-entities. + +## Guide to Connecting Infisical to Let's Encrypt CA + +To connect Infisical to Let's Encrypt, follow the steps in the [ACME-compatible CA integration](/documentation/platform/pki/ca/acme-ca) guide but use the Let's Encrypt **ACME Directory URL**: `https://acme-v02.api.letsencrypt.org/directory`. + +Note that Let’s Encrypt issues 90-day certificates and enforces a limit of 50 certificates per registered domain per week. + +We strongly recommend testing your setup against the Let's Encrypt staging environment first at the **ACME Directory URL** `https://acme-staging-v02.api.letsencrypt.org/directory` prior to switching to the production environment. This allows you to verify your DNS configuration and certificate issuance process without consuming production rate limits. diff --git a/docs/documentation/platform/pki/ca/private-ca.mdx b/docs/documentation/platform/pki/ca/private-ca.mdx index 74913d4cc..67b38b455 100644 --- a/docs/documentation/platform/pki/ca/private-ca.mdx +++ b/docs/documentation/platform/pki/ca/private-ca.mdx @@ -122,7 +122,7 @@ consisting of an (optional) root CA and an intermediate CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/ca/internal' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -155,7 +155,7 @@ consisting of an (optional) root CA and an intermediate CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/ca/internal' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -183,7 +183,7 @@ consisting of an (optional) root CA and an intermediate CA. ### Sample request ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//csr' \ + curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal//csr' \ --header 'Authorization: Bearer ' \ --data-raw '' ``` @@ -204,7 +204,7 @@ consisting of an (optional) root CA and an intermediate CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//sign-intermediate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/ca/internal//sign-intermediate' \ --header 'Content-Type: application/json' \ --data-raw '{ "csr": "", @@ -234,7 +234,7 @@ consisting of an (optional) root CA and an intermediate CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//import-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/ca/internal//import-certificate' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -292,7 +292,7 @@ the certificate back to the intermediate CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//renew' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/ca/internal//renew' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ 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 e33f46f3e..22285be8e 100644 --- a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx @@ -39,6 +39,7 @@ These permissions allow Infisical to list, import, tag, and manage certificates - **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. + - **Include Root CA**: If enabled, the Root CA certificate will be included in the certificate chain when syncing to AWS Certificate Manager. If disabled, only intermediate certificates will be included. - **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. @@ -69,7 +70,7 @@ These permissions allow Infisical to list, import, tag, and manage certificates ```bash Request curl --request POST \ - --url https://app.infisical.com/api/v1/pki/syncs/aws-certificate-manager \ + --url https://app.infisical.com/api/v1/cert-manager/syncs/aws-certificate-manager \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ @@ -86,6 +87,7 @@ These permissions allow Infisical to list, import, tag, and manage certificates "syncOptions": { "canRemoveCertificates": true, "preserveArnOnRenewal": true, + "includeRootCa": false, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -110,6 +112,7 @@ These permissions allow Infisical to list, import, tag, and manage certificates "syncOptions": { "canRemoveCertificates": true, "preserveArnOnRenewal": true, + "includeRootCa": false, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", diff --git a/docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx b/docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx new file mode 100644 index 000000000..86461bb93 --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx @@ -0,0 +1,251 @@ +--- +title: "AWS Secrets Manager" +description: "Learn how to configure an AWS Secrets Manager Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + +- Create an [AWS Connection](/integrations/app-connections/aws) +- Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. + + + The AWS Secrets Manager Certificate Sync requires the following permissions to be set on the AWS IAM user + for Infisical to sync certificates to AWS Secrets Manager: `secretsmanager:CreateSecret`, `secretsmanager:UpdateSecret`, + `secretsmanager:GetSecretValue`, `secretsmanager:DeleteSecret`, `secretsmanager:ListSecrets`. + +Any role with these permissions would work such as a custom policy with **SecretsManager** permissions. + + + + + Certificates synced to AWS Secrets Manager will be stored as JSON secrets, + preserving both the certificate and private key components as separate fields within the secret value. + + + + + 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 Secrets Manager** option. + ![Select AWS Secrets Manager](/images/platform/pki/certificate-syncs/aws-secrets-manager/select-aws-secrets-manager-option.png) + + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-destination.png) + + - **AWS Connection**: The AWS Connection to authenticate with. + - **Region**: The AWS region where secrets will be stored. + + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-options.png) + + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Preserve Secret on Renewal**: Only applies to certificate renewals. When a certificate is renewed in Infisical, this option controls how the renewed certificate is handled. If enabled, the renewed certificate will update the existing secret, preserving the same secret name. If disabled, the renewed certificate will be created as a new secret with a new name. + - **Include Root CA**: If enabled, the Root CA certificate will be included in the certificate chain when syncing to AWS Secrets Manager. If disabled, only intermediate certificates will be included. + - **Certificate Name Schema** (Optional): Customize how secret names are generated in AWS Secrets Manager. Use `{{certificateId}}` as a placeholder for the certificate ID. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. + + 5. Configure the **Field Mappings** to customize how certificate data is stored in AWS Secrets Manager secrets, then click **Next**. + ![Configure Field Mappings](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-field-mappings.png) + + - **Certificate Field**: The field name where the certificate will be stored in the secret value (default: `certificate`) + - **Private Key Field**: The field name where the private key will be stored in the secret value (default: `private_key`) + - **Certificate Chain Field**: The field name where the full certificate chain excluding the root CA certificate will be stored (default: `certificate_chain`) + - **CA Certificate Field**: The field name where the root CA certificate will be stored (default: `ca_certificate`) + + + **AWS Secrets Manager Secret Structure**: Certificates are stored in AWS Secrets Manager as JSON secrets with the following structure (field names can be customized via field mappings): + ```json + { + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "certificate_chain": "-----BEGIN CERTIFICATE-----\n...", + "ca_certificate": "-----BEGIN CERTIFICATE-----\n..." + } + ``` + + **Example with Custom Field Mappings**: + ```json + { + "ssl_cert": "-----BEGIN CERTIFICATE-----\n...", + "ssl_key": "-----BEGIN PRIVATE KEY-----\n...", + "ssl_chain": "-----BEGIN CERTIFICATE-----\n...", + "ssl_ca": "-----BEGIN CERTIFICATE-----\n..." + } + ``` + + + 6. Configure the **Details** of your AWS Secrets Manager Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Select which certificates should be synced to AWS Secrets Manager. + ![Select Certificates](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-certificates.png) + + 8. Review your AWS Secrets Manager Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-review.png) + + 9. If enabled, your AWS Secrets Manager Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-synced.png) + + + To create an **AWS Secrets Manager Certificate Sync**, make an API request to the [Create AWS Secrets Manager Certificate Sync](/api-reference/endpoints/pki/syncs/aws-secrets-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/cert-manager/syncs/aws-secrets-manager \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-secrets-manager-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "aws-secrets-manager", + "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "myapp-{{certificateId}}", + "fieldMappings": { + "certificate": "ssl_cert", + "privateKey": "ssl_key", + "certificateChain": "ssl_chain", + "caCertificate": "ssl_ca" + } + }, + "destinationConfig": { + "region": "us-east-1", + "keyId": "alias/my-kms-key" + } + }' + ``` + + ### Example with Default Field Mappings + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/cert-manager/syncs/aws-secrets-manager \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-aws-secrets-manager-cert-sync-default", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "AWS Secrets Manager sync with default field mappings", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "aws-secrets-manager", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "infisical-{{certificateId}}", + "fieldMappings": { + "certificate": "certificate", + "privateKey": "private_key", + "certificateChain": "certificate_chain", + "caCertificate": "ca_certificate" + } + }, + "destinationConfig": { + "region": "us-west-2" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-aws-secrets-manager-cert-sync", + "description": "an example certificate sync", + "destination": "aws-secrets-manager", + "isAutoSyncEnabled": true, + "destinationConfig": { + "region": "us-east-1", + "keyId": "alias/my-kms-key" + }, + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "myapp-{{certificateId}}", + "fieldMappings": { + "certificate": "ssl_cert", + "privateKey": "ssl_key", + "certificateChain": "ssl_chain", + "caCertificate": "ssl_ca" + } + }, + "projectId": "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" + } + } + ``` + + + + +## Certificate Management + +Your AWS Secrets Manager Certificate Sync will: + +- **Automatic Deployment**: Deploy certificates in Infisical to AWS Secrets Manager as JSON secrets with customizable field names +- **Certificate Updates**: Update certificates in AWS Secrets Manager when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from AWS Secrets Manager (if enabled) +- **Format Preservation**: Maintain certificate format during sync operations +- **Field Customization**: Map certificate data to custom field names that match your application requirements +- **CA Certificate Support**: Include CA certificates in secrets for complete certificate chain management +- **KMS Encryption**: Optionally use custom KMS keys for secret encryption +- **Regional Deployment**: Deploy secrets to specific AWS regions + + + AWS Secrets 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 to AWS Secrets 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 +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/aws-secrets-manager/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + + AWS Secrets Manager does not support importing certificates back into Infisical + due to the nature of AWS Secrets Manager where certificates are stored as JSON secrets + rather than managed certificate objects. + + +## Secret Naming Constraints + +AWS Secrets Manager has specific naming requirements for secrets: + +- **Allowed Characters**: Letters, numbers, hyphens (-), and underscores (_) only +- **Length**: 1-512 characters 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 cfdbfe136..4c6c81bc0 100644 --- a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx @@ -40,6 +40,7 @@ Any role with these permissions would work such as the **Key Vault Certificates - **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. + - **Include Root CA**: If enabled, the Root CA certificate will be included in the certificate chain when syncing to Azure Key Vault. If disabled, only intermediate certificates will be included. - **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. @@ -76,7 +77,7 @@ Any role with these permissions would work such as the **Key Vault Certificates ```bash Request curl --request POST \ - --url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \ + --url https://app.infisical.com/api/v1/cert-manager/syncs/azure-key-vault \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ @@ -93,6 +94,7 @@ Any role with these permissions would work such as the **Key Vault Certificates "syncOptions": { "canRemoveCertificates": true, "enableVersioningOnRenewal": true, + "includeRootCa": false, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -117,6 +119,7 @@ Any role with these permissions would work such as the **Key Vault Certificates "syncOptions": { "canRemoveCertificates": true, "enableVersioningOnRenewal": true, + "includeRootCa": false, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", diff --git a/docs/documentation/platform/pki/certificate-syncs/chef.mdx b/docs/documentation/platform/pki/certificate-syncs/chef.mdx new file mode 100644 index 000000000..ec3eedafd --- /dev/null +++ b/docs/documentation/platform/pki/certificate-syncs/chef.mdx @@ -0,0 +1,241 @@ +--- +title: "Chef" +description: "Learn how to configure a Chef Certificate Sync for Infisical PKI." +--- + +**Prerequisites:** + +- Create a [Chef Connection](/integrations/app-connections/chef) +- Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. + + + The Chef Certificate Sync requires the following permissions to be set on the Chef user + for Infisical to sync certificates to Chef: `data bag read`, `data bag create`, `data bag update`, `data bag delete`. + +Any role with these permissions would work such as a custom role with **Data Bag** permissions. + + + + + Certificates synced to Chef will be stored as data bag items within the specified data bag, + preserving both the certificate and private key components as separate fields. + + + + + 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 **Chef** option. + ![Select Chef](/images/platform/pki/certificate-syncs/chef/select-chef-option.png) + + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/chef/chef-destination.png) + + - **Chef Connection**: The Chef Connection to authenticate with. + - **Data Bag Name**: The name of the Chef data bag where certificates will be stored. + + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/chef/chef-options.png) + + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Preserve Data Bag Item on Renewal**: Only applies to certificate renewals. When a certificate is renewed in Infisical, this option controls how the renewed certificate is handled. If enabled, the renewed certificate will update the existing data bag item, preserving the same item name. If disabled, the renewed certificate will be created as a new data bag item with a new name. + - **Include Root CA**: If enabled, the Root CA certificate will be included in the certificate chain when syncing to Chef data bags. If disabled, only intermediate certificates will be included. + - **Certificate Name Schema** (Optional): Customize how certificate item names are generated in Chef data bags. Use `{{certificateId}}` as a placeholder for the certificate ID. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. + + 5. Configure the **Field Mappings** to customize how certificate data is stored in Chef data bag items, then click **Next**. + ![Configure Field Mappings](/images/platform/pki/certificate-syncs/chef/chef-field-mappings.png) + + - **Certificate Field**: The field name where the certificate will be stored in the data bag item (default: `certificate`) + - **Private Key Field**: The field name where the private key will be stored in the data bag item (default: `private_key`) + - **Certificate Chain Field**: The field name where the full certificate chain excluding the root CA certificate will be stored (default: `certificate_chain`) + - **CA Certificate Field**: The field name where the root CA certificate will be stored (default: `ca_certificate`) + + + **Chef Data Bag Item Structure**: Certificates are stored in Chef data bags as items with the following structure (field names can be customized via field mappings): + ```json + { + "id": "certificate-item-name", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "certificate_chain": "-----BEGIN CERTIFICATE-----\n...", + "ca_certificate": "-----BEGIN CERTIFICATE-----\n..." + } + ``` + + **Example with Custom Field Mappings**: + ```json + { + "id": "certificate-item-name", + "ssl_cert": "-----BEGIN CERTIFICATE-----\n...", + "ssl_key": "-----BEGIN PRIVATE KEY-----\n...", + "ssl_chain": "-----BEGIN CERTIFICATE-----\n...", + "ssl_ca": "-----BEGIN CERTIFICATE-----\n..." + } + ``` + + + 6. Configure the **Details** of your Chef Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/chef/chef-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Select which certificates should be synced to Chef. + ![Select Certificates](/images/platform/pki/certificate-syncs/chef/chef-certificates.png) + + 8. Review your Chef Certificate Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/platform/pki/certificate-syncs/chef/chef-review.png) + + 9. If enabled, your Chef Certificate Sync will begin syncing your certificates to the destination endpoint. + ![Sync Certificates](/images/platform/pki/certificate-syncs/chef/chef-synced.png) + + + To create a **Chef Certificate Sync**, make an API request to the [Create Chef Certificate Sync](/api-reference/endpoints/pki/syncs/chef/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/cert-manager/syncs/chef \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-chef-cert-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example certificate sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "chef", + "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "myapp-{{certificateId}}", + "fieldMappings": { + "certificate": "ssl_cert", + "privateKey": "ssl_key", + "certificateChain": "ssl_chain", + "caCertificate": "ssl_ca" + } + }, + "destinationConfig": { + "dataBagName": "ssl_certificates" + } + }' + ``` + + ### Example with Default Field Mappings + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/cert-manager/syncs/chef \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-chef-cert-sync-default", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "Chef sync with default field mappings", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "destination": "chef", + "isAutoSyncEnabled": true, + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "{{commonName}}-{{certificateId}}", + "fieldMappings": { + "certificate": "certificate", + "privateKey": "private_key", + "certificateChain": "certificate_chain", + "caCertificate": "ca_certificate" + } + }, + "destinationConfig": { + "dataBagName": "certificates" + } + }' + ``` + + ### Sample response + + ```json Response + { + "pkiSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-chef-cert-sync", + "description": "an example certificate sync", + "destination": "chef", + "isAutoSyncEnabled": true, + "destinationConfig": { + "dataBagName": "ssl_certificates" + }, + "syncOptions": { + "canRemoveCertificates": true, + "preserveSecretOnRenewal": true, + "canImportCertificates": false, + "includeRootCa": false, + "certificateNameSchema": "myapp-{{certificateId}}", + "fieldMappings": { + "certificate": "ssl_cert", + "privateKey": "ssl_key", + "certificateChain": "ssl_chain", + "caCertificate": "ssl_ca" + } + }, + "projectId": "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" + } + } + ``` + + + + +## Certificate Management + +Your Chef Certificate Sync will: + +- **Automatic Deployment**: Deploy certificates in Infisical to Chef data bags with customizable field names +- **Certificate Updates**: Update certificates in Chef data bags when renewals occur +- **Expiration Handling**: Optionally remove expired certificates from Chef data bags (if enabled) +- **Format Preservation**: Maintain certificate format during sync operations +- **Field Customization**: Map certificate data to custom field names that match your Chef cookbook requirements +- **CA Certificate Support**: Include CA certificates in data bag items for complete certificate chain management + + + Chef 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 to Chef using the sync certificates functionality. This is useful for: + +- Initial setup when you have existing certificates to deploy +- One-time sync of specific certificates +- Testing certificate sync configurations +- Force sync after making changes + +To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/chef/sync-certificates) API endpoint or the manual sync option in the Infisical UI. + + + Chef does not support importing certificates back into Infisical + due to the nature of Chef data bags where certificates are stored as data + rather than managed certificate objects. + \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificate-syncs/overview.mdx b/docs/documentation/platform/pki/certificate-syncs/overview.mdx index d7931d893..b72be71f9 100644 --- a/docs/documentation/platform/pki/certificate-syncs/overview.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/overview.mdx @@ -83,6 +83,7 @@ should be synced. Follow these steps to start syncing: - 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. + - Whether to include the Root CA certificate in the certificate chain. - Certificate naming schema to control how certificate names are generated in the destination. diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index de8de4541..da73de37d 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -221,7 +221,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ --header 'Content-Type: application/json' \ --data-raw '{ "profileId": "", @@ -260,7 +260,7 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates/sign-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ --header 'Content-Type: application/json' \ --data-raw '{ "certificateTemplateId": "", @@ -337,7 +337,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates//revoke' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -362,7 +362,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem ### Sample request ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crls' \ + curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal//crls' \ --header 'Authorization: Bearer ' ``` diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index 05703beee..eab06fe43 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -19,13 +19,12 @@ where you can manage various aspects of its lifecycle including deployment to cl ## 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. +To [issue a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#enrollment-request-%2F-issuance), 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. +- Self-Signed Certificates: To issue a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate), you must configure the certificate profile to use the `Self-Signed` issuer type. You can then use the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) to request a self-signed certificate against it. +- CA-Issued Certificates: To issue a certificate from a certificate authority, you must configure the certificate profile to use the `Certificate Authority` issuer type and select the [issuing CA](/documentation/platform/pki/ca/overview) to use. You can then use one of the [enrollment methods](/documentation/platform/pki/enrollment-methods/overview) to request a certificate against 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. +Refer to the documentation for each [enrollment method](/documentation/platform/pki/enrollment-methods/overview) to learn more about how to issue certificates using it. ## Guide to Renewing Certificates @@ -49,24 +48,33 @@ Note that server-driven certificate renewal is only available for certificates i 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. + 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. +- Using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme), you may connect an ACME client like [certbot](https://certbot.eff.org/) to fetch back and renew certificates for [Apache](/documentation/platform/pki/integration-guides/apache-certbot), [Nginx](/documentation/platform/pki/integration-guides/nginx-certbot), 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](/documentation/platform/pki/enrollment-methods/acme), 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](/documentation/platform/pki/enrollment-methods/api), 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 +## Guide to Downloading Certificates -In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure. +In the following steps, we explore different options for exporting already-issued certificates from Infisical in different formats for use in your applications and infrastructure. -### Accessing the Export Certificate Modal +### Download Latest Profile Certificate -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. +You can download the latest certificate issued against a [certificate profile](/documentation/platform/pki/certificates/profiles) using the [latest certificate bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) endpoint. + +### Download Specific Certificate + +To export a specific 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) @@ -108,6 +116,7 @@ To export any certificate, first navigate to your project's certificate inventor ``` + @@ -158,6 +167,7 @@ To export any certificate, first navigate to your project's certificate inventor + @@ -212,7 +222,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates//revoke' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -237,7 +247,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem ### Sample request ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crls' \ + curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal//crls' \ --header 'Authorization: Bearer ' ``` diff --git a/docs/documentation/platform/pki/certificates/profiles.mdx b/docs/documentation/platform/pki/certificates/profiles.mdx index ccbef89cd..1121437cf 100644 --- a/docs/documentation/platform/pki/certificates/profiles.mdx +++ b/docs/documentation/platform/pki/certificates/profiles.mdx @@ -21,7 +21,8 @@ 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. +- Issuer Type: The type of issuer that should be used to issue certificates for the profile; this can be either `Certificate Authority` or `Self-Signed`. If `Self-Signed` is selected, then the profile will only support the API enrollment method and be used to issue self-signed certificates over REST API. +- Issuing CA: The [issuing CA](/documentation/platform/pki/ca/overview) that should be used to issue certificates for the profile when the **Issuer Type** is set to `Certificate Authority`. - 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. diff --git a/docs/documentation/platform/pki/certificates/templates.mdx b/docs/documentation/platform/pki/certificates/templates.mdx index 38b5570dd..b8d976961 100644 --- a/docs/documentation/platform/pki/certificates/templates.mdx +++ b/docs/documentation/platform/pki/certificates/templates.mdx @@ -7,7 +7,7 @@ sidebarTitle: "Templates" 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. +Each certificate requested against a [certificate profile](/documentation/platform/pki/certificates/profiles) 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 diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx index 559b0cab8..3c12a5040 100644 --- a/docs/documentation/platform/pki/enrollment-methods/acme.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -3,6 +3,62 @@ title: "Certificate Enrollment via ACME" sidebarTitle: "ACME" --- - - ACME-based certificate enrollment is currently under development and will be included in a future release. - +## Concept + +The ACME enrollment method allows Infisical to act as an ACME server. It lets you request and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment). +This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management. + +Infisical's ACME enrollment method is based on [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555/). + +## Prerequisites + +Install an [ACME client](https://letsencrypt.org/docs/client-options/) onto your server. This client will handle [ACME challenges](https://letsencrypt.org/docs/challenge-types/) and request/renew certificates from Infisical. + +## Guide to Certificate Enrollment via ACME + +In the following steps, we explore how to issue a X.509 certificate using the ACME enrollment method. + + + + Create a [certificate + profile](/documentation/platform/pki/certificates/profiles) with **ACME** + selected as the enrollment method. + + ![pki acme config](/images/platform/pki/enrollment-methods/acme/acme-config.png) + + + + Once you've created the certificate profile, you can obtain its ACME configuration details by clicking the **Reveal ACME EAB** option on the profile. + + ![pki acme eab config](/images/platform/pki/enrollment-methods/acme/acme-eab.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that the ACME client will use to communicate with Infisical's ACME server. + - 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. + + + + Provide the **ACME Directory URL**, **EAB KID**, and **EAB Secret** from Step 2 to your ACME client to authenticate with Infisical and request a certificate. + + For example, if using [Certbot](https://certbot.eff.org/) as an ACME client, you can configure and start requesting certificates with the following command: + + ```bash + sudo certbot certonly \ + --standalone \ + --server "https://your-infisical-instance.com/api/v1/cert-manager/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-kid" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + For client-specific setup and usage instructions, refer to the documentation for your ACME client. + + + diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx index 4adcdc01b..bfbac7f2e 100644 --- a/docs/documentation/platform/pki/enrollment-methods/api.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -5,7 +5,7 @@ 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. +The API enrollment method allows you to issue certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) over Web UI or by making an API request to Infisical. ## Guide to Certificate Enrollment via API @@ -15,7 +15,7 @@ In the following steps, we explore how to issue a X.509 certificate using the AP - + Create a [certificate profile](/documentation/platform/pki/certificates/profiles) with **API** selected as the enrollment method. @@ -54,14 +54,14 @@ Here, select the certificate profile from step 1 that will be used to issue the - + 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' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificate-profiles' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -105,7 +105,7 @@ Here, select the certificate profile from step 1 that will be used to issue the ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ @@ -151,7 +151,7 @@ Here, select the certificate profile from step 1 that will be used to issue the ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/sign-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ diff --git a/docs/documentation/platform/pki/enrollment-methods/est.mdx b/docs/documentation/platform/pki/enrollment-methods/est.mdx index a4e463a2f..35e770595 100644 --- a/docs/documentation/platform/pki/enrollment-methods/est.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/est.mdx @@ -5,7 +5,7 @@ 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). +The EST enrollment method allows you to issue and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) 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: @@ -32,7 +32,7 @@ and structured under `https://app.infisical.com:8443/.well-known/est/{profile_id 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. diff --git a/docs/documentation/platform/pki/enrollment-methods/overview.mdx b/docs/documentation/platform/pki/enrollment-methods/overview.mdx index f1af9375d..df203c35c 100644 --- a/docs/documentation/platform/pki/enrollment-methods/overview.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/overview.mdx @@ -5,7 +5,10 @@ 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. +Refer to the documentation for each enrollment method below 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. +- [ACME](/documentation/platform/pki/enrollment-methods/acme): Enroll certificates using the ACME protocol. +- [EST](/documentation/platform/pki/enrollment-methods/est): Enroll certificates using the EST protocol. + +Note that beyond using an enrollment method, you can also deliver a certificate to a target destination using supported [certificate syncs](https://infisical.com/docs/documentation/platform/pki/certificate-syncs/overview). diff --git a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx index 78f0301e1..23aed363a 100644 --- a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Apache Server" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Apache Server with Certbot" +description: "Learn how to issue 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/). +This guide demonstrates how to use Infisical to issue 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. @@ -29,7 +29,7 @@ Before you begin, make sure you have: 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`. + - 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/cert-manager/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. @@ -56,7 +56,7 @@ Before you begin, make sure you have: ```bash sudo certbot certonly \ --apache \ - --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --server "https://your-infisical-instance.com/api/v1/cert-manager/certificate-profiles/{profile-id}/acme/directory" \ --eab-kid "your-eab-key-identifier" \ --eab-hmac-key "your-eab-secret" \ -d example.infisical.com \ @@ -182,4 +182,5 @@ Before you begin, make sure you have: - \ 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 d1f1273fd..d83e062fa 100644 --- a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -1,13 +1,13 @@ --- title: "Gloo Mesh" -description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" +description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical" --- -This guide will provide a high level overview on how you can use Infisical PKI and cert-manager to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). +This guide will provide a high level overview on how you can use Infisical and [cert-manager](https://cert-manager.io/) to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). ## 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. +In this setup, we will use Infisical 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. @@ -21,19 +21,19 @@ With this approach, you get the following benefits: ## 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 [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles) to authenticate using EAB 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. -Follow the [Infisical PKI Issuer guide](/documentation/platform/pki/pki-issuer) for detailed instructions on how to set up the Infisical PKI Issuer and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. +Follow the [Kubernetes cert-manager guide](/documentation/platform/pki/k8s-cert-manager) for detailed instructions on how to set up the Infisical and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. For Gloo Mesh-specific configuration, ensure that: - The Certificate resource targets the `istio-system` namespace with `secretName: cacerts` -- Certificate templates in Infisical PKI are configured for intermediate CA usage with appropriate key usage and constraints -- Multiple workload clusters use the same Infisical PKI root to enable cross-cluster mTLS communication +- Certificate profiles in Infisical are configured for intermediate CA usage with appropriate key usage and constraints +- Multiple workload clusters use the same Infisical root to enable cross-cluster mTLS communication ## 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. +When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical 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 index c0e1c896b..e8d8fb8b9 100644 --- a/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx @@ -1,9 +1,9 @@ --- title: "JBoss/WildFly" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on JBoss/WildFly with Certbot" +description: "Learn how to issue 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. +This guide demonstrates how to use Infisical to issue 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. @@ -30,7 +30,7 @@ Before you begin, make sure you have: 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`. + - 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/cert-manager/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. @@ -67,7 +67,7 @@ Before you begin, make sure you have: ```bash sudo certbot certonly \ --standalone \ - --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --server "https://your-infisical-instance.com/api/v1/cert-manager/certificate-profiles/{profile-id}/acme/directory" \ --eab-kid "your-eab-key-identifier" \ --eab-hmac-key "your-eab-secret" \ -d example.infisical.com \ @@ -223,4 +223,5 @@ Before you begin, make sure you have: 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 index f28e5ee09..ca3c35034 100644 --- a/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Nginx" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Nginx with Certbot" +description: "Learn how to issue 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. +This guide demonstrates how to use Infisical to issue 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). @@ -29,7 +29,7 @@ Before you begin, make sure you have: 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`. + - 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/cert-manager/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. @@ -56,7 +56,7 @@ Before you begin, make sure you have: ```bash sudo certbot certonly \ --nginx \ - --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --server "https://your-infisical-instance.com/api/v1/cert-manager/certificate-profiles/{profile-id}/acme/directory" \ --eab-kid "your-eab-key-identifier" \ --eab-hmac-key "your-eab-secret" \ -d example.infisical.com \ diff --git a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx index ffb07bf1b..42e2b11ea 100644 --- a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Tomcat" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Tomcat with Certbot" +description: "Learn how to issue 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. +This guide demonstrates how to use Infisical to issue 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. @@ -29,7 +29,7 @@ Before you begin, make sure you have: 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`. + - 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/cert-manager/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. @@ -64,7 +64,7 @@ Before you begin, make sure you have: ```bash sudo certbot certonly \ --standalone \ - --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --server "https://your-infisical-instance.com/api/v1/cert-manager/certificate-profiles/{profile-id}/acme/directory" \ --eab-kid "your-eab-key-identifier" \ --eab-hmac-key "your-eab-secret" \ -d example.infisical.com \ @@ -248,4 +248,5 @@ Before you begin, make sure you have: 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 index 2aab0870d..ae835d8a3 100644 --- a/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx +++ b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx @@ -1,9 +1,9 @@ --- title: "Windows Server" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Windows Server with win-acme" +description: "Learn how to issue 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. +This guide demonstrates how to use Infisical to issue 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. @@ -28,7 +28,7 @@ Before you begin, make sure you have: 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`. + - 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/cert-manager/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. @@ -67,7 +67,7 @@ Before you begin, make sure you have: 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 + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/cert-manager/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: @@ -87,7 +87,7 @@ Before you begin, make sure you have: 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 + - `https://your-infisical-instance.com/api/v1/cert-manager/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 @@ -101,21 +101,21 @@ Before you begin, make sure you have: 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 + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/cert-manager/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 + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/cert-manager/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 + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/cert-manager/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 ``` @@ -129,7 +129,7 @@ Before you begin, make sure you have: 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 + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/cert-manager/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** @@ -191,4 +191,5 @@ Before you begin, make sure you have: + diff --git a/docs/documentation/platform/pki/k8s-cert-manager.mdx b/docs/documentation/platform/pki/k8s-cert-manager.mdx new file mode 100644 index 000000000..b0f696ba9 --- /dev/null +++ b/docs/documentation/platform/pki/k8s-cert-manager.mdx @@ -0,0 +1,267 @@ +--- +title: "Kubernetes cert-manager" +description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical" +--- + +## Concept + +This guide demonstrates how to use Infisical to issue TLS certificates back to your Kubernetes environment using [cert-manager](https://cert-manager.io/). + +It uses the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/) to request and renew certificates automatically from Infisical +using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). The issuer is perfect at obtaining X.509 certificates for Ingresses and other Kubernetes resources and can automatically renew them before expiration. + +The typical workflow involves installing `cert-manager` and configuring resources that represent the connection details to Infisical as well as the certificates you want to issue. +Each issued certificate and its corresponding private key are stored in a Kubernetes `Secret`. + +We recommend reading the official [cert-manager documentation](https://cert-manager.io/docs/) for a complete overview. +For the ACME-specific configuration, refer to the [ACME section](https://cert-manager.io/docs/configuration/acme/). + +## Workflow + +A typical workflow for using cert-manager with Infisical via ACME consists of the following steps: + +1. Create a [certificate profile](/documentation/platform/pki/certificates/profiles) in Infisical with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on it. +2. Install `cert-manager` in your Kubernetes cluster. +3. Create a Kubernetes `Secret` containing the EAB (External Account Binding) credentials for the ACME certificate profile. +4. Create an `Issuer` or `ClusterIssuer` resource that connects to the desired Infisical [certificate profile](/documentation/platform/pki/certificates/profiles). +5. Create a `Certificate` resource defining the certificate you wish to issue and the target `Secret` where the certificate and private key will be stored. +6. Use the resulting Kubernetes `Secret` in your Ingresses or other resources. + +## Guide + +The following steps show how to install cert-manager (using `kubectl`) and obtain certificates from Infisical. + + + + + Follow the instructions [here](/documentation/platform/pki/enrollment-methods/acme) to create a certificate profile that uses ACME enrollment. + + After completion, you will have the following values: + - **ACME Directory URL** + - **EAB Key ID (KID)** + - **EAB Secret** + + These will be needed in later steps. + + + Currently, the Infisical ACME enrollment method only supports authentication via dedicated EAB credentials generated per certificate profile. + + Support for [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) is planned for the near future. + + + + + + Install cert-manager in your Kubernetes cluster by following the official guide [here](https://cert-manager.io/docs/installation/) or by applying the manifest directly: + + ```bash + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml + ``` + + + + Create a Kubernetes `Secret` that contains the **EAB Secret (HMAC key)** obtained in step 1. + The cert-manager uses this secret to authenticate with the Infisical ACME server. + + + + ```bash + kubectl create secret generic infisical-acme-eab-secret \ + --namespace \ + --from-literal=eabSecret= + ``` + + + ```yaml acme-eab-secret.yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-acme-eab-secret + namespace: + data: + eabSecret: + ``` + + ```bash + kubectl apply -f acme-eab-secret.yaml + ``` + + + + + + Next, create a cert-manager `Issuer` (or `ClusterIssuer`) by replacing the placeholders ``, ``, and `` in the configuration below and applying it. + This resource configures cert-manager to use your Infisical PKI collection's ACME server for certificate issuance. + + ```yaml issuer-infisical.yaml + apiVersion: cert-manager.io/v1 + kind: Issuer + metadata: + name: issuer-infisical + namespace: + spec: + acme: + # ACME server URL from your Infisical certificate profile (Step 1) + server: + # Email address for ACME account + # (any valid email works; currently ignored by Infisical) + email: + externalAccountBinding: + # EAB Key ID from Step 1 + keyID: + # Reference to the Kubernetes Secret containing the EAB + # HMAC key (created in Step 3) + keySecretRef: + name: infisical-acme-eab-secret + key: eabSecret + privateKeySecretRef: + name: issuer-infisical-account-key + solvers: + - http01: + ingress: + # Replace with your actual ingress class if different + className: nginx + ``` + + ``` + kubectl apply -f issuer-infisical.yaml + ``` + + You can check that the issuer was created successfully by running the following command: + + ```bash + kubectl get issuers.cert-manager.io -n -o wide + ``` + + ```bash + NAME AGE + issuer-infisical 21h + ``` + + + - Currently, the Infisical ACME server only supports the HTTP-01 challenge and requires successful challenge completion before issuing certificates. Support for optional challenges and DNS-01 is planned for a future release. + - An `Issuer` is namespace-scoped. Certificates can only be issued using an `Issuer` that exists in the same namespace as the `Certificate` resource. + - If you need to issue certificates across multiple namespaces with a single resource, create a `ClusterIssuer` instead. The configuration is identical except `kind: ClusterIssuer` and no `metadata.namespace`. + - More details: https://cert-manager.io/docs/configuration/acme/ + + + + + + Finally, request a certificate from Infisical ACME server by creating a cert-manager `Certificate` resource. + This configuration file specifies the details of the (end-entity/leaf) certificate to be issued. + + ```yaml certificate-issuer.yaml + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: certificate-by-issuer + namespace: + spec: + dnsNames: + - certificate-by-issuer.example.com + # name of the resulting Kubernetes Secret + secretName: certificate-by-issuer + # total validity period of the certificate + duration: 48h + # cert-manager will attempt renewal 12 hours before expiry + renewBefore: 12h + privateKey: + algorithm: ECDSA + # uses NIST P-256 curve + size: 256 + issuerRef: + name: issuer-infisical + ``` + + The above sample configuration file specifies a certificate to be issued with the dns name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. + The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. + + Note that the full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + You can check that the certificate was created successfully by running the following command: + + ```bash + kubectl get certificates -n -o wide + ``` + + ```bash + NAME READY SECRET ISSUER STATUS AGE + certificate-by-issuer True certificate-by-issuer issuer-infisical Certificate is up to date and has not expired 20h + ``` + + + + Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: + + ```bash + kubectl get secret certificate-by-issuer -n + ``` + + ```bash + NAME TYPE DATA AGE + certificate-by-issuer kubernetes.io/tls 2 26h + ``` + + We can `describe` the secret to get more information about it: + + ```bash + kubectl describe secret certificate-by-issuer -n default + ``` + + ```bash + Name: certificate-by-issuer + Namespace: default + Labels: controller.cert-manager.io/fao=true + Annotations: cert-manager.io/alt-names: + cert-manager.io/certificate-name: certificate-by-issuer + cert-manager.io/common-name: + cert-manager.io/alt-names: certificate-by-issuer.example.com + cert-manager.io/ip-sans: + cert-manager.io/issuer-group: cert-manager.io + cert-manager.io/issuer-kind: Issuer + cert-manager.io/issuer-name: issuer-infisical + cert-manager.io/uri-sans: + + Type: kubernetes.io/tls + + Data + ==== + ca.crt: 1306 bytes + tls.crt: 2380 bytes + tls.key: 227 bytes + ``` + + Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. + + We can decode the certificate and print it out using `openssl`: + + ```bash + kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout + ``` + + In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. + + + + + +## FAQ + + + + The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + + Currently, not all fields are supported by the Infisical PKI ACME server. + + + + + Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as + specified in the corresponding `Certificate` resource. + + You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index 9aba032b1..9d208039b 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -12,7 +12,7 @@ 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 Enrollment](/documentation/platform/pki/enrollment-methods/overview): Support enrollment methods including [API](/documentation/platform/pki/enrollment-methods/api), [ACME](/documentation/platform/pki/enrollment-methods/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). diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx deleted file mode 100644 index a1d07c98b..000000000 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ /dev/null @@ -1,305 +0,0 @@ ---- -title: "Kubernetes Issuer" -description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" ---- - -## Concept - -The Infisical PKI Issuer is an installable Kubernetes [cert-manager](https://cert-manager.io/) controller that uses Infisical PKI to sign certificate requests. The issuer is perfect for getting X.509 certificates for ingresses and other Kubernetes resources and capable of automatically renewing certificates as needed. - -As part of the workflow, you install `cert-manager`, the Infisical PKI Issuer, and configure resources to represent the connection details to your Infisical PKI and the certificates you wish to issue. Each issued certificate and corresponding private key is made available in a Kubernetes secret. - -We recommend reading the [cert-manager documentation](https://cert-manager.io/docs/) for a fuller understanding of all the moving parts. - -## Workflow - -A typical workflow for using the Infisical PKI Issuer to issue certificates for your Kubernetes resources consists of the following steps: - -1. Creating a machine identity in Infisical. -2. Creating a Kubernetes secret to store the credentials of the machine identity. -3. Installing `cert-manager` into your Kubernetes cluster. -4. Installing the Infisical PKI Issuer controller into your Kubernetes cluster. -5. Creating an `Issuer` or `ClusterIssuer` resource in your Kubernetes cluster to represent the Infisical PKI issuer you wish to use. -6. Create the approver policy to accept certificate request. -7. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. -8. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. - -## Guide - -In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. - - - - - Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - - By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the Infisical PKI Issuer to authenticate with Infisical; this will be useful in steps 4 and 5. - - - Currently, the Infisical PKI Issuer only supports authenticating with Infisical via the [Universal Auth](/documentation/platform/identities/universal-auth) authentication method. - - We're planning to add support for [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) in the near future. - - - - Install `cert-manager` into your Kubernetes cluster by following the instructions [here](https://cert-manager.io/docs/installation/) or by running the following command: - - ```bash - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml - ``` - - - Install the Infisical PKI Issuer controller into your Kubernetes cluster using one of the following methods: - - - - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - helm install infisical-pki-issuer infisical-helm-charts/infisical-pki-issuer - ``` - - - ```bash - kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml - ``` - - - - - Start by creating a Kubernetes `Secret` containing the **Client Secret** from step 1. As mentioned previously, this will be used by the Infisical PKI issuer to authenticate with Infisical. - - - - ```bash - kubectl create secret generic issuer-infisical-client-secret \ - --namespace \ - --from-literal=clientSecret= - ``` - - - ```yaml secret-issuer.yaml - apiVersion: v1 - kind: Secret - metadata: - name: issuer-infisical-client-secret - namespace: - data: - clientSecret: - ``` - - ```bash - kubectl apply -f secret-issuer.yaml - ``` - - - - - Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, `projectId` or `certificateTemplateName`, and applying the following configuration file for the `Issuer` resource. - This configuration file specifies the connection details to your Infisical PKI CA to be used for issuing certificates. - - ```yaml infisical-issuer.yaml - apiVersion: infisical-issuer.infisical.com/v1alpha1 - kind: Issuer - metadata: - name: issuer-infisical - namespace: - spec: - url: "https://app.infisical.com" # the URL of your Infisical instance - projectId: # the ID of the project you want to use to issue certificates - certificateTemplateName: # the name of the certificate template you want to use to issue certificates against - authentication: - universalAuth: - clientId: # the Client ID from step 1 - secretRef: # reference to the Secret created in step 4 - name: "issuer-infisical-client-secret" - key: "clientSecret" - ``` - - ``` - kubectl apply -f infisical-issuer.yaml - ``` - - You can check that the issuer was created successfully by running the following command: - - ```bash - kubectl get issuers.infisical-issuer.infisical.com -n -o wide - ``` - - ```bash - NAME AGE - issuer-infisical 21h - ``` - - - An `Issuer` is a namespaced resource, and it is not possible to issue certificates from an `Issuer` in a different namespace. - This means you will need to create an `Issuer` in each namespace you wish to obtain `Certificates` in. - - If you want to create a single `Issuer` that can be consumed in multiple namespaces, you should consider creating a `ClusterIssuer` resource. This is almost identical to the `Issuer` resource, however is non-namespaced so it can be used to issue `Certificates` across all namespaces. - - You can read more about the `Issuer` and `ClusterIssuer` resources [here](https://cert-manager.io/docs/configuration/). - - - - If you create a `CertificateRequest` now, you'll notice it's neither approved nor denied. This is expected because by default cert-manager approver controller requires an approver-policy. - - To enable approval, create the following YAML file and apply it: - - ```yaml infisical-approver-policy.yaml - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: infisical-issuer-approver - rules: - # Permission to approve or deny CertificateRequests for signers in cert-manager.io API group - - apiGroups: ['cert-manager.io'] - resources: ['signers'] - verbs: ['approve'] - resourceNames: - # Grant approval permissions for namespaced issuers - - "issuers.infisical-issuer.infisical.com/default.issuer-infisical" - # Grant approval permissions for cluster-scoped issuers - - "clusterissuers.infisical-issuer.infisical.com/clusterissuer-infisical" - --- - # Bind the cert-manager service account to the new role - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: infisical-issuer-approver-binding - subjects: - - kind: ServiceAccount - name: cert-manager - namespace: cert-manager - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: infisical-issuer-approver - ``` - - ``` - kubectl apply -f infisical-approver-policy.yaml - ``` - - This configuration creates a `ClusterRole` named `infisical-issuer-approver` that grants approval permissions for specific Infisical issuer types. It then binds this role to the cert-manager service account, allowing it to approve certificate requests from your Infisical issuers. - - For information, check out [cert manager approval policy doc](https://cert-manager.io/docs/policy/approval/approver-policy/). - - - - Finally, create a `Certificate` by applying the following configuration file. - This configuration file specifies the details of the (end-entity/leaf) certificate to be issued. - - ```yaml certificate-issuer.yaml - apiVersion: cert-manager.io/v1 - kind: Certificate - metadata: - name: certificate-by-issuer - namespace: - spec: - commonName: certificate-by-issuer.example.com # the common name for the certificate - secretName: certificate-by-issuer # the name of the Kubernetes Secret to create and store the certificate and private key in - issuerRef: - name: issuer-infisical - group: infisical-issuer.infisical.com - kind: Issuer - privateKey: # the algorithm and key size to use - algorithm: ECDSA - size: 256 - duration: 48h # the ttl for the certificate - renewBefore: 12h # the time before the certificate expiry that the certificate should be automatically renewed - ``` - - The above sample configuration file specifies a certificate to be issued with the common name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. - The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. - - Note that the full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - You can check that the certificate was created successfully by running the following command: - - ```bash - kubectl get certificates -n -o wide - ``` - - ```bash - NAME READY SECRET ISSUER STATUS AGE - certificate-by-issuer True certificate-by-issuer issuer-infisical Certificate is up to date and has not expired 20h - ``` - - - Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: - - ```bash - kubectl get secret certificate-by-issuer -n - ``` - - ```bash - NAME TYPE DATA AGE - certificate-by-issuer kubernetes.io/tls 2 26h - ``` - - We can `describe` the secret to get more information about it: - - ```bash - kubectl describe secret certificate-by-issuer -n default - ``` - - ```bash - Name: certificate-by-issuer - Namespace: default - Labels: controller.cert-manager.io/fao=true - Annotations: cert-manager.io/alt-names: - cert-manager.io/certificate-name: certificate-by-issuer - cert-manager.io/common-name: certificate-by-issuer.example.com - cert-manager.io/ip-sans: - cert-manager.io/issuer-group: infisical-issuer.infisical.com - cert-manager.io/issuer-kind: Issuer - cert-manager.io/issuer-name: issuer-infisical - cert-manager.io/uri-sans: - - Type: kubernetes.io/tls - - Data - ==== - ca.crt: 1306 bytes - tls.crt: 2380 bytes - tls.key: 227 bytes - ``` - - Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. - - We can decode the certificate and print it out using `openssl`: - - ```bash - kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout - ``` - - In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. - - - - -## FAQ - - - - The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - - Currently, not all fields are supported by the Infisical PKI Issuer. - - - - - Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as - specified in the corresponding `Certificate` resource. - - You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - - - If you see log messages similar to: - ``` - "CertificateRequest has not been approved yet. Ignoring.","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","CertificateRequest":{"name":"skynet-infisical-rta-rsa2048-1","namespace":"infisical-system"},"namespace":"infisical-system","name":"skynet-infisical-rta-rsa2048-1","reconcileID":"bfb7cad9-d867-45b5-b3a3-0139e731b7a6"} - ``` - This indicates that the `CertificateRequest` has been created, but `cert-manager` has not yet approved it. This typically occurs because a necessary approver policy is missing. Refer to the documentation above to create an approver policy. - - diff --git a/docs/images/app-connections/dns-made-easy/copy-api-credentials.png b/docs/images/app-connections/dns-made-easy/copy-api-credentials.png new file mode 100644 index 000000000..557085294 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/copy-api-credentials.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png new file mode 100644 index 000000000..41d6fd90d Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png new file mode 100644 index 000000000..0e6599231 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png new file mode 100644 index 000000000..28f67fcf2 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png differ diff --git a/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png b/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png new file mode 100644 index 000000000..de56f75fd Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png differ diff --git a/docs/images/app-connections/dns-made-easy/nav-to-account-info.png b/docs/images/app-connections/dns-made-easy/nav-to-account-info.png new file mode 100644 index 000000000..56094c55e Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/nav-to-account-info.png differ diff --git a/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png b/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png index 9cb703e12..3a33e4351 100644 Binary files a/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png and b/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png differ diff --git a/docs/images/pam/architecture/session-logging.png b/docs/images/pam/architecture/session-logging.png new file mode 100644 index 000000000..cc64aad4a Binary files /dev/null and b/docs/images/pam/architecture/session-logging.png differ diff --git a/docs/images/pam/getting-started/accounts/add-account-button.png b/docs/images/pam/getting-started/accounts/add-account-button.png new file mode 100644 index 000000000..7ff6c459e Binary files /dev/null and b/docs/images/pam/getting-started/accounts/add-account-button.png differ diff --git a/docs/images/pam/getting-started/accounts/create-account.png b/docs/images/pam/getting-started/accounts/create-account.png new file mode 100644 index 000000000..af79dc365 Binary files /dev/null and b/docs/images/pam/getting-started/accounts/create-account.png differ diff --git a/docs/images/pam/getting-started/accounts/select-resource.png b/docs/images/pam/getting-started/accounts/select-resource.png new file mode 100644 index 000000000..cba352726 Binary files /dev/null and b/docs/images/pam/getting-started/accounts/select-resource.png differ diff --git a/docs/images/pam/getting-started/resources/add-resource-button.png b/docs/images/pam/getting-started/resources/add-resource-button.png new file mode 100644 index 000000000..0769b572c Binary files /dev/null and b/docs/images/pam/getting-started/resources/add-resource-button.png differ diff --git a/docs/images/pam/getting-started/resources/create-resource.png b/docs/images/pam/getting-started/resources/create-resource.png new file mode 100644 index 000000000..8477d6297 Binary files /dev/null and b/docs/images/pam/getting-started/resources/create-resource.png differ diff --git a/docs/images/pam/getting-started/resources/credential-rotation-account.png b/docs/images/pam/getting-started/resources/credential-rotation-account.png new file mode 100644 index 000000000..f44e5e670 Binary files /dev/null and b/docs/images/pam/getting-started/resources/credential-rotation-account.png differ diff --git a/docs/images/pam/getting-started/resources/rotate-credentials-account.png b/docs/images/pam/getting-started/resources/rotate-credentials-account.png new file mode 100644 index 000000000..8c9113b9b Binary files /dev/null and b/docs/images/pam/getting-started/resources/rotate-credentials-account.png differ diff --git a/docs/images/pam/getting-started/resources/select-resource-type.png b/docs/images/pam/getting-started/resources/select-resource-type.png new file mode 100644 index 000000000..e14b2ab3a Binary files /dev/null and b/docs/images/pam/getting-started/resources/select-resource-type.png differ diff --git a/docs/images/pam/overview/create-account.png b/docs/images/pam/overview/create-account.png deleted file mode 100644 index 34f1c7434..000000000 Binary files a/docs/images/pam/overview/create-account.png and /dev/null differ diff --git a/docs/images/pam/overview/create-resource.png b/docs/images/pam/overview/create-resource.png deleted file mode 100644 index ac34b9dca..000000000 Binary files a/docs/images/pam/overview/create-resource.png and /dev/null differ diff --git a/docs/images/pam/overview/credential-rotation-account.png b/docs/images/pam/overview/credential-rotation-account.png deleted file mode 100644 index 5e379eccc..000000000 Binary files a/docs/images/pam/overview/credential-rotation-account.png and /dev/null differ diff --git a/docs/images/pam/overview/rotate-credentials-account.png b/docs/images/pam/overview/rotate-credentials-account.png deleted file mode 100644 index 3c908cd49..000000000 Binary files a/docs/images/pam/overview/rotate-credentials-account.png and /dev/null differ diff --git a/docs/images/pam/overview/session-page.png b/docs/images/pam/overview/session-page.png deleted file mode 100644 index 5c2fa41cf..000000000 Binary files a/docs/images/pam/overview/session-page.png and /dev/null differ diff --git a/docs/images/pam/product-reference/auditing/audit-logs.png b/docs/images/pam/product-reference/auditing/audit-logs.png new file mode 100644 index 000000000..f8e9d8b3b Binary files /dev/null and b/docs/images/pam/product-reference/auditing/audit-logs.png differ diff --git a/docs/images/pam/product-reference/session-recording/individual-session-page-search.png b/docs/images/pam/product-reference/session-recording/individual-session-page-search.png new file mode 100644 index 000000000..d4f31218d Binary files /dev/null and b/docs/images/pam/product-reference/session-recording/individual-session-page-search.png differ diff --git a/docs/images/pam/product-reference/session-recording/individual-session-page.png b/docs/images/pam/product-reference/session-recording/individual-session-page.png new file mode 100644 index 000000000..2efd60312 Binary files /dev/null and b/docs/images/pam/product-reference/session-recording/individual-session-page.png differ diff --git a/docs/images/pam/product-reference/session-recording/sessions-page-search.png b/docs/images/pam/product-reference/session-recording/sessions-page-search.png new file mode 100644 index 000000000..eaebbd70a Binary files /dev/null and b/docs/images/pam/product-reference/session-recording/sessions-page-search.png differ diff --git a/docs/images/pam/product-reference/session-recording/sessions-page.png b/docs/images/pam/product-reference/session-recording/sessions-page.png new file mode 100644 index 000000000..4be14838a Binary files /dev/null and b/docs/images/pam/product-reference/session-recording/sessions-page.png differ diff --git a/docs/images/pam/session-recording/individual-session-page-search.png b/docs/images/pam/session-recording/individual-session-page-search.png deleted file mode 100644 index ce369f515..000000000 Binary files a/docs/images/pam/session-recording/individual-session-page-search.png and /dev/null differ diff --git a/docs/images/pam/session-recording/individual-session-page.png b/docs/images/pam/session-recording/individual-session-page.png deleted file mode 100644 index 2926caf67..000000000 Binary files a/docs/images/pam/session-recording/individual-session-page.png and /dev/null differ diff --git a/docs/images/pam/session-recording/sessions-page-search.png b/docs/images/pam/session-recording/sessions-page-search.png deleted file mode 100644 index a90cda587..000000000 Binary files a/docs/images/pam/session-recording/sessions-page-search.png and /dev/null differ diff --git a/docs/images/pam/session-recording/sessions-page.png b/docs/images/pam/session-recording/sessions-page.png deleted file mode 100644 index 8faab291d..000000000 Binary files a/docs/images/pam/session-recording/sessions-page.png and /dev/null differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png index bda7822a0..67df534cb 100644 Binary files a/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png and b/docs/images/platform/pki/ca/external-ca/create-external-ca-button.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png index bc32c23f6..dc993d9f0 100644 Binary files a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png and b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png differ diff --git a/docs/images/platform/pki/ca/external-ca/external-ca-list.png b/docs/images/platform/pki/ca/external-ca/external-ca-list.png deleted file mode 100644 index 4ef05c059..000000000 Binary files a/docs/images/platform/pki/ca/external-ca/external-ca-list.png and /dev/null 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 index 03254ee9f..f1f5f3266 100644 Binary files a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png 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-secrets-manager/aws-secrets-manager-certificates.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-certificates.png new file mode 100644 index 000000000..58eaae85f Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-destination.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-destination.png new file mode 100644 index 000000000..559d7ab5c Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-details.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-details.png new file mode 100644 index 000000000..d3617bcd1 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-field-mappings.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-field-mappings.png new file mode 100644 index 000000000..04b158346 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-field-mappings.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-options.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-options.png new file mode 100644 index 000000000..b957bad8f Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-review.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-review.png new file mode 100644 index 000000000..23733e1bf Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-synced.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-synced.png new file mode 100644 index 000000000..b230bc554 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/aws-secrets-manager-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/select-aws-secrets-manager-option.png b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/select-aws-secrets-manager-option.png new file mode 100644 index 000000000..fbc4115b7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-secrets-manager/select-aws-secrets-manager-option.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 index 69cd37d21..bea535d88 100644 Binary files a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-certificates.png b/docs/images/platform/pki/certificate-syncs/chef/chef-certificates.png new file mode 100644 index 000000000..c4d7022ea Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-destination.png b/docs/images/platform/pki/certificate-syncs/chef/chef-destination.png new file mode 100644 index 000000000..b8cc5c365 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-details.png b/docs/images/platform/pki/certificate-syncs/chef/chef-details.png new file mode 100644 index 000000000..aa568b2ad Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-field-mappings.png b/docs/images/platform/pki/certificate-syncs/chef/chef-field-mappings.png new file mode 100644 index 000000000..de580f849 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-field-mappings.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-options.png b/docs/images/platform/pki/certificate-syncs/chef/chef-options.png new file mode 100644 index 000000000..7955d0ae6 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-review.png b/docs/images/platform/pki/certificate-syncs/chef/chef-review.png new file mode 100644 index 000000000..43f294585 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/chef-synced.png b/docs/images/platform/pki/certificate-syncs/chef/chef-synced.png new file mode 100644 index 000000000..f4ed46add Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/chef-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/chef/select-chef-option.png b/docs/images/platform/pki/certificate-syncs/chef/select-chef-option.png new file mode 100644 index 000000000..d7b8d9c9b Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/chef/select-chef-option.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile-modal.png b/docs/images/platform/pki/certificate/cert-profile-modal.png index 29280d01c..961ad466a 100644 Binary files a/docs/images/platform/pki/certificate/cert-profile-modal.png and b/docs/images/platform/pki/certificate/cert-profile-modal.png differ diff --git a/docs/images/platform/pki/enrollment-methods/acme/acme-config.png b/docs/images/platform/pki/enrollment-methods/acme/acme-config.png new file mode 100644 index 000000000..11ea8b075 Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/acme/acme-config.png differ diff --git a/docs/images/platform/pki/enrollment-methods/acme/acme-eab.png b/docs/images/platform/pki/enrollment-methods/acme/acme-eab.png new file mode 100644 index 000000000..d2bffd001 Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/acme/acme-eab.png differ diff --git a/docs/integrations/app-connections/dns-made-easy.mdx b/docs/integrations/app-connections/dns-made-easy.mdx new file mode 100644 index 000000000..f2fe297bf --- /dev/null +++ b/docs/integrations/app-connections/dns-made-easy.mdx @@ -0,0 +1,59 @@ +--- +title: "DNS Made Easy" +description: "Learn how to configure a DNS Made Easy Connection for Infisical." +--- + +Infisical supports connecting to DNS Made Easy using API key and secret key for secure access to your DNS Made Easy service. + +## Configure API key and secret Key for Infisical + + + + Navigate to your DNS Made Easy dashboard and go to **Account Information** under the **Config** top menu. + + ![Navigate to Account Information](/images/app-connections/dns-made-easy/nav-to-account-info.png) + + If your **API Key** and **Secret Key** are already available, proceed to step 2. + + Otherwise, check the **Generate New API Credentials** then click the **Save** button to generate the new API credentials. + + ![Generate API Credentials](/images/app-connections/dns-made-easy/generate-new-api-credentials.png) + + + + After creation, copy your API key and secret key. + + ![Generated API Token](/images/app-connections/dns-made-easy/copy-api-credentials.png) + + + Keep your API key and secret key secure and do not share it. + Anyone with access to this token can manage your DNS Made Easy resources. + + + + + +## Setup DNS Made Easy Connection in Infisical + + + + Navigate to the **App Connections** page in the desired project. ![App + Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **DNS Made Easy Connection** option from the connection options + modal. ![Select DNS Made Easy + Connection](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png) + + + Enter your DNS Made Easy API key and secret key in the provided fields and + click **Connect to DNS Made Easy** to establish the connection. ![Connect to + DNS Made + Easy](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png) + + + Your **DNS Made Easy Connection** is now available for use in your Infisical + projects. ![DNS Made Easy Connection + Created](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png) + + diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index c9af952a7..588a6f990 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -12,6 +12,8 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access Using the GitLab Connection with OAuth on a self-hosted instance of Infisical requires configuring an OAuth application in GitLab and registering your instance with it. + If you're self-hosting GitLab with custom certificates, you will have to configure your Infisical instance to trust these certificates. To learn how, please follow [this guide](../../self-hosting/guides/custom-certificates). + **Prerequisites:** - A GitLab account with existing projects - Self-hosted Infisical instance diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 8b1032e7d..1201ca3ca 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -75,10 +75,6 @@ to limit the access of this entity to the minimal permission set required to per 4. Utilize the Connection: Use your App Connection for various features across Infisical such as our Secrets Sync by selecting it via the dropdown menu in the UI or by passing the associated `connectionId` when generating resources via the API. - - Infisical is continuously expanding its third-party application support. If your desired application isn't listed, - you can still use previous methods of connecting to it such as our Native Integrations. - ## Platform Managed Credentials diff --git a/docs/integrations/cloud/aws-amplify.mdx b/docs/integrations/cicd/aws-amplify.mdx similarity index 95% rename from docs/integrations/cloud/aws-amplify.mdx rename to docs/integrations/cicd/aws-amplify.mdx index 6d3123b10..28de7640c 100644 --- a/docs/integrations/cloud/aws-amplify.mdx +++ b/docs/integrations/cicd/aws-amplify.mdx @@ -19,7 +19,7 @@ This approach enables you to fetch secrets from Infisical during Amplify build t - Create a machine identtiy and connect it to your Infisical project. You can read more about how to use machine identities [here](/documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. + Create a machine identity and connect it to your Infisical project. You can read more about how to use machine identities [here](/documentation/platform/identities/machine-identities). The machine identity will allow you to authenticate and fetch secrets from Infisical. @@ -108,7 +108,7 @@ This approach enables you to fetch secrets from Infisical during Amplify build t - Follow the [Infisical AWS SSM Parameter Store Integration Guide](./aws-parameter-store) to set up the integration. Pause once you reach the step where it asks you to select the path you would like to sync. + Follow the [Infisical AWS SSM Parameter Store Secret Syncs Guide](../secret-syncs/aws-parameter-store) to set up the integration. Pause once you reach the step where it asks you to select the path you would like to sync. ![amplify app id](../../images/integrations/aws/integrations-amplify-app-id.png) diff --git a/docs/integrations/cicd/bitbucket.mdx b/docs/integrations/cicd/bitbucket.mdx index 3c1330308..44893a5b6 100644 --- a/docs/integrations/cicd/bitbucket.mdx +++ b/docs/integrations/cicd/bitbucket.mdx @@ -12,29 +12,7 @@ Prerequisites: - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](/images/integrations.png) - - Press on the Bitbucket tile and grant Infisical access to your Bitbucket account. - - ![integrations bitbucket authorization](/images/integrations/bitbucket/integrations-bitbucket.png) - - - Select which workspace, repository, and optionally, deployment environment, you'd like to sync your secrets - to. - ![integrations configure - bitbucket](/images/integrations/bitbucket/integrations-bitbucket-configuration.png) - - Once created, your integration will begin syncing secrets to the configured repository or deployment - environment. - - ![integrations bitbucket](/images/integrations/bitbucket/integrations-bitbucket.png) - - - + Use our [Bitbucket Secret Syncs](../secret-syncs/bitbucket) diff --git a/docs/integrations/cicd/circleci.mdx b/docs/integrations/cicd/circleci.mdx deleted file mode 100644 index 5bf04822d..000000000 --- a/docs/integrations/cicd/circleci.mdx +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: "CircleCI" -description: "How to sync secrets from Infisical to CircleCI" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain an API token in User Settings > Personal API Tokens - - ![integrations circleci token](/images/integrations/circleci/integrations-circleci-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](/images/integrations.png) - - Press on the CircleCI tile and input your CircleCI API token to grant Infisical access to your CircleCI account. - - ![integrations circleci authorization](/images/integrations/circleci/integrations-circleci-auth.png) - - - - Select which Infisical environment secrets you want to sync to which CircleCI project or context. - - - ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-project.png) - - - ![integrations circle ci project](/images/integrations/circleci/integrations-circleci-create-context.png) - - - - Finally, press create integration to start syncing secrets to CircleCI. - ![integrations circleci](/images/integrations/circleci/integrations-circleci.png) - - - diff --git a/docs/integrations/cicd/codefresh.mdx b/docs/integrations/cicd/codefresh.mdx deleted file mode 100644 index cf69ae04d..000000000 --- a/docs/integrations/cicd/codefresh.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Codefresh" -description: "How to sync secrets from Infisical to Codefresh" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain an API key in User Settings > API Keys - - ![integrations codefresh dashboard](../../images/integrations/codefresh/integrations-codefresh-dashboard.png) - ![integrations codefresh token](../../images/integrations/codefresh/integrations-codefresh-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Codefresh tile and input your Codefresh API key to grant Infisical access to your Codefresh account. - - ![integrations codefresh authorization](../../images/integrations/codefresh/integrations-codefresh-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Codefresh service and press create integration to start syncing secrets to Codefresh. - - ![create integration codefresh](../../images/integrations/codefresh/integrations-codefresh-create.png) - ![integrations codefresh](../../images/integrations/codefresh/integrations-codefresh.png) - - \ No newline at end of file diff --git a/docs/integrations/cicd/githubactions.mdx b/docs/integrations/cicd/githubactions.mdx index 82076874c..fa4387413 100644 --- a/docs/integrations/cicd/githubactions.mdx +++ b/docs/integrations/cicd/githubactions.mdx @@ -4,204 +4,6 @@ description: "How to sync secrets from Infisical to GitHub Actions" --- - Alternatively, you can use Infisical's official GitHub Action - [here](https://github.com/Infisical/secrets-action). - - -Infisical lets you sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. - -## Connecting with GitHub App (Recommended) - - - - - - Navigate to your project's integrations tab in Infisical and press on the GitHub tile. - - ![integrations](../../images/integrations/github/app/integration-overview.png) - - Select GitHub App as the authentication method and click **Connect to GitHub**. - - ![integrations github app auth selection](../../images/integrations/github/app/github-app-method-selection.png) - - You will then be redirected to the GitHub app installation page. - - ![integrations github app installation](../../images/integrations/github/app/github-app-installation.png) - - Install and authorize the GitHub application. This will redirect you back to the Infisical integration page. - - - - Select which Infisical environment secrets you want to sync to which GitHub organization, repository, or repository environment. - - - - ![integrations github](../../images/integrations/github/integrations-github-scope-repo.png) - - - ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) - - When using the organization scope, your secrets will be saved in the top-level of your GitHub Organization. - - You can choose the visibility, which defines which repositories can access the secrets. The options are: - - **All public repositories**: All public repositories in the organization can access the secrets. - - **All private repositories**: All private repositories in the organization can access the secrets. - - **Selected repositories**: Only the selected repositories can access the secrets. This gives a more fine-grained control over which repositories can access the secrets. You can select _both_ private and public repositories with this option. - - - ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) - - - - Finally, press create integration to start syncing secrets to GitHub. - - ![integrations github](../../images/integrations/github/integrations-github.png) - - - - - - Using the GitHub integration with app authentication on a self-hosted instance of Infisical requires configuring an application on GitHub - and registering your instance with it. - - - Navigate to the GitHub app settings [here](https://github.com/settings/apps). Click **New GitHub App**. - - ![integrations github app create](../../images/integrations/github/app/self-hosted-github-app-create.png) - - Give the application a name, a homepage URL (your self-hosted domain i.e. `https://your-domain.com`), and a callback URL (i.e. `https://your-domain.com/integrations/github/oauth2/callback`). - - ![integrations github app basic details](../../images/integrations/github/app/self-hosted-github-app-basic-details.png) - - Enable request user authorization during app installation. - ![integrations github app enable auth](../../images/integrations/github/app/self-hosted-github-app-enable-oauth.png) - - Disable webhook by unchecking the Active checkbox. - ![integrations github app webhook](../../images/integrations/github/app/self-hosted-github-app-webhook.png) - - Set the repository permissions as follows: Metadata: Read-only, Secrets: Read and write, Environments: Read and write, Actions: Read. - ![integrations github app repository](../../images/integrations/github/app/self-hosted-github-app-repository.png) - - Similarly, set the organization permissions as follows: Secrets: Read and write. - ![integrations github app organization](../../images/integrations/github/app/self-hosted-github-app-organization.png) - - Create the Github application. - ![integrations github app create confirm](../../images/integrations/github/app/self-hosted-github-app-create-confirm.png) - - - If you have a GitHub organization, you can create an application under it - in your organization Settings > Developer settings > GitHub Apps > New GitHub App. - - - - Generate a new **Client Secret** for your GitHub application. - ![integrations github app create secret](../../images/integrations/github/app/self-hosted-github-app-secret.png) - - Generate a new **Private Key** for your Github application. - ![integrations github app create private key](../../images/integrations/github/app/self-hosted-github-app-private-key.png) - - Obtain the necessary Github application credentials. This would be the application slug, client ID, app ID, client secret, and private key. - ![integrations github app credentials](../../images/integrations/github/app/self-hosted-github-app-credentials.png) - - Back in your Infisical instance, add the five new environment variables for the credentials of your GitHub application: - - - `CLIENT_ID_GITHUB_APP`: The **Client ID** of your GitHub application. - - `CLIENT_SECRET_GITHUB_APP`: The **Client Secret** of your GitHub application. - - `CLIENT_SLUG_GITHUB_APP`: The **Slug** of your GitHub application. This is the one found in the URL. - - `CLIENT_APP_ID_GITHUB_APP`: The **App ID** of your GitHub application. - - `CLIENT_PRIVATE_KEY_GITHUB_APP`: The **Private Key** of your GitHub application. - - Once added, restart your Infisical instance and use the GitHub integration via app authentication. - - - - - - -## Connecting with GitHub OAuth - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Ensure that you have admin privileges to the repository you want to sync secrets to. - - - - - - Navigate to your project's integrations tab in Infisical and press on the GitHub tile. - ![integrations](../../images/integrations/github/integration-overview.png) - - Select OAuth as the authentication method and click **Connect to GitHub**. - ![integrations github oauth auth selection](../../images/integrations/github/github-oauth-method-selection.png) - - Grant Infisical access to your GitHub account (organization and repo privileges). - ![integrations github authorization](../../images/integrations/github/integrations-github-auth.png) - - - - Select which Infisical environment secrets you want to sync to which GitHub organization, repository, or repository environment. - - - - ![integrations github](../../images/integrations/github/integrations-github-scope-repo.png) - - - ![integrations github](../../images/integrations/github/integrations-github-scope-org.png) - - When using the organization scope, your secrets will be saved in the top-level of your GitHub Organization. - - You can choose the visibility, which defines which repositories can access the secrets. The options are: - - **All public repositories**: All public repositories in the organization can access the secrets. - - **All private repositories**: All private repositories in the organization can access the secrets. - - **Selected repositories**: Only the selected repositories can access the secrets. This gives a more fine-grained control over which repositories can access the secrets. You can select _both_ private and public repositories with this option. - - - ![integrations github](../../images/integrations/github/integrations-github-scope-env.png) - - - - Finally, press create integration to start syncing secrets to GitHub. - - ![integrations github](../../images/integrations/github/integrations-github.png) - - - - - - Using the GitHub integration on a self-hosted instance of Infisical requires configuring an OAuth application in GitHub - and registering your instance with it. - - - Navigate to your user Settings > Developer settings > OAuth Apps to create a new GitHub OAuth application. - - ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) - ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app.png) - - Create the OAuth application. As part of the form, set the **Homepage URL** to your self-hosted domain `https://your-domain.com` - and the **Authorization callback URL** to `https://your-domain.com/integrations/github/oauth2/callback`. - - ![integrations github config](../../images/integrations/github/integrations-github-config-new-app-form.png) - - - If you have a GitHub organization, you can create an OAuth application under it - in your organization Settings > Developer settings > OAuth Apps > New Org OAuth App. - - - - Obtain the **Client ID** and generate a new **Client Secret** for your GitHub OAuth application. - - ![integrations github config](../../images/integrations/github/integrations-github-config-credentials.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your GitHub OAuth application: - - - `CLIENT_ID_GITHUB`: The **Client ID** of your GitHub OAuth application. - - `CLIENT_SECRET_GITHUB`: The **Client Secret** of your GitHub OAuth application. - - Once added, restart your Infisical instance and use the GitHub integration. - - - - - + Use our [GitHub Secret Syncs](../secret-syncs/github) to sync secrets to GitHub at the organization-level, repository-level, and repository environment-level. + Alternatively, you can use Infisical's official GitHub Action [here](https://github.com/Infisical/secrets-action). + \ No newline at end of file diff --git a/docs/integrations/cicd/gitlab.mdx b/docs/integrations/cicd/gitlab.mdx index 2da61ef77..7cbf9512e 100644 --- a/docs/integrations/cicd/gitlab.mdx +++ b/docs/integrations/cicd/gitlab.mdx @@ -3,41 +3,13 @@ title: "GitLab" description: "How to sync secrets from Infisical to GitLab" --- - - + Prerequisites: - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) + - Set up and add envars to [Infisical Cloud](https://app.infisical.com). - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the GitLab tile and grant Infisical access to your GitLab account. - - ![integrations gitlab authorization](../../images/integrations/gitlab/integrations-gitlab-auth.png) - - - - Select which Infisical environment secrets you want to sync to which GitLab repository and press create integration to start syncing secrets to GitLab. - - ![integrations gitlab](../../images/integrations/gitlab/integrations-gitlab-create.png) - - Note that the GitLab integration supports a few options in the **Options** tab: - - - Secret Prefix: If inputted, the prefix is appended to the front of every secret name prior to being synced. - - Secret Suffix: If inputted, the suffix to appended to the back of every name of every secret prior to being synced. - - Setting a secret prefix or suffix ensures that existing secrets in GitLab are not overwritten during the sync. As part of this process, Infisical abstains from mutating any secrets in GitLab without the specified prefix or suffix. - - ![integrations gitlab options](../../images/integrations/gitlab/integrations-gitlab-create-options.png) - - ![integrations gitlab](../../images/integrations/gitlab/integrations-gitlab.png) - - + Use our [GitLab Secret Syncs](../secret-syncs/gitlab) @@ -70,42 +42,4 @@ description: "How to sync secrets from Infisical to GitLab" - - - - - Using the GitLab integration on a self-hosted instance of Infisical requires configuring an application in GitLab - and registering your instance with it. - If you're self-hosting Gitlab with custom certificates, you will have to configure your Infisical instance to trust these certificates. To learn how, please follow [this guide](../../self-hosting/guides/custom-certificates). - - - Navigate to your user Settings > Applications to create a new GitLab application. - - ![integrations gitlab config](../../images/integrations/gitlab/integrations-gitlab-config-edit-profile.png) - ![integrations gitlab config](../../images/integrations/gitlab/integrations-gitlab-config-new-app.png) - - Create the application. As part of the form, set the **Redirect URI** to `https://your-domain.com/integrations/gitlab/oauth2/callback`. - - ![integrations gitlab config](../../images/integrations/gitlab/integrations-gitlab-config-new-app-form.png) - - - If you have a GitLab group, you can create an OAuth application under it - in your group Settings > Applications. - - - - Obtain the **Application ID** and **Secret** for your GitLab application. - - ![integrations gitlab config](../../images/integrations/gitlab/integrations-gitlab-config-credentials.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your GitLab application: - - - `CLIENT_ID_GITLAB`: The **Client ID** of your GitLab application. - - `CLIENT_SECRET_GITLAB`: The **Secret** of your GitLab application. - - Once added, restart your Infisical instance and use the GitLab integration. - - - - - + \ No newline at end of file diff --git a/docs/integrations/cicd/octopus-deploy.mdx b/docs/integrations/cicd/octopus-deploy.mdx deleted file mode 100644 index 90f06e09a..000000000 --- a/docs/integrations/cicd/octopus-deploy.mdx +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: "Octopus Deploy" -description: "Learn how to sync secrets from Infisical to Octopus Deploy" ---- - -Prerequisites: - -- Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to **Configuration** > **Users** and click on the **Create Service Account** button. - - ![integrations octopus deploy - users](/images/integrations/octopus-deploy/integrations-octopus-deploy-user-settings.png) - - Fill out the required fields and click on the **Save** button. - ![integrations octopus deploy service - account](/images/integrations/octopus-deploy/integrations-octopus-deploy-create-service-account.png) - - - On the **Service Account** user page, expand the **API Keys** section and click on the **New API Key** button. - - ![integrations octopus deploy - new api key](/images/integrations/octopus-deploy/integrations-octopus-deploy-create-api-key.png) - - Fill out the required fields and click on the **Generate New** button. - - ![integrations octopus deploy - generate api key](/images/integrations/octopus-deploy/integrations-octopus-deploy-generate-api-key.png) - - If you configure your access token to expire, - you will need to generate a new API key for Infisical prior to this date to keep your integration running. - - Copy the generated **API Key** and click on the **Close** button. - - ![integrations octopus deploy - copy api key](/images/integrations/octopus-deploy/integrations-octopus-deploy-copy-api-key.png) - - - You can skip creating a new team if you already have an Octopus Deploy team configured with - the **Project Contributor** role to assign your Service Account to. - - Navigate to **Configuration** > **Teams** and click on the **Add Team** button. - - ![integrations octopus deploy - teams](/images/integrations/octopus-deploy/integrations-octopus-deploy-team-settings.png) - - Create a new team for **Service Accounts** and click on the **Save** button. - ![integrations octopus deploy add - team](/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png) - - On the **Members** tab, click on the **Add Member** button, add your **Infisical Service Account** and click on the **Add** button. - ![integrations octopus deploy add service account to team](/images/integrations/octopus-deploy/integrations-octopus-deploy-add-to-team.png) - - On the **User Roles** tab, click on the **Include User Role** button, and add the **Project Contributor** role. Optionally, - click on the **Define Scope** button to further refine what projects your Service Account has access to. Click on the **Apply** button once complete. - ![integrations octopus deploy add user roles to team](/images/integrations/octopus-deploy/integrations-octopus-deploy-add-role.png) - - Save your team changes by clicking on the **Save** button. - ![integrations octopus deploy save team changes](/images/integrations/octopus-deploy/integrations-octopus-deploy-save-team.png) - - - In Infisical, navigate to your **Project** > **Integrations** page and select the **Octopus Deploy** integration. - ![integration octopus deploy](/images/integrations/octopus-deploy/integrations-octopus-deploy-integrations.png) - - Enter your **Instance URL** and **API Key** from **Octopus Deploy** to authorize Infisical. - ![integration octopus deploy](/images/integrations/octopus-deploy/integrations-octopus-deploy-authorize.png) - - Select a **Space** and **Project** from **Octopus Deploy** to sync secrets to; configuring additional **Scope Values** as needed. Click on the **Create Integration** button once configured. - ![integration octopus deploy](/images/integrations/octopus-deploy/integrations-octopus-deploy-create.png) - - Your Infisical secrets will begin to sync to **Octopus Deploy**. - ![integration octopus deploy](/images/integrations/octopus-deploy/integrations-octopus-deploy-sync.png) - - \ No newline at end of file diff --git a/docs/integrations/cicd/rundeck.mdx b/docs/integrations/cicd/rundeck.mdx deleted file mode 100644 index bda7d8162..000000000 --- a/docs/integrations/cicd/rundeck.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Rundeck" -description: "How to sync secrets from Infisical to Rundeck" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a User API Token in the Profile settings of Rundeck - - ![integrations rundeck token](../../images/integrations/rundeck/integrations-rundeck-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Rundeck tile and input your Rundeck instance Base URL and User API token to grant Infisical access to manage Rundeck keys - - ![integrations rundeck authorization](../../images/integrations/rundeck/integrations-rundeck-auth.png) - - - - Select which Infisical environment secrets you want to sync to a Rundeck Key Storage Path and press create integration to start syncing secrets to Rundeck. - - ![create integration rundeck](../../images/integrations/rundeck/integrations-rundeck-create.png) - ![integrations rundeck](../../images/integrations/rundeck/integrations-rundeck.png) - - - diff --git a/docs/integrations/cloud/teamcity.mdx b/docs/integrations/cicd/teamcity.mdx similarity index 100% rename from docs/integrations/cloud/teamcity.mdx rename to docs/integrations/cicd/teamcity.mdx diff --git a/docs/integrations/cicd/travisci.mdx b/docs/integrations/cicd/travisci.mdx deleted file mode 100644 index 873c371b6..000000000 --- a/docs/integrations/cicd/travisci.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: "Travis CI" -description: "How to sync secrets from Infisical to Travis CI" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain your API token in User Settings > API authentication > Token - - ![integrations travis ci token](../../images/integrations/travis-ci/integrations-travisci-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Travis CI tile and input your Travis CI API token to grant Infisical access to your Travis CI account. - - ![integrations travis ci authorization](../../images/integrations/travis-ci/integrations-travisci-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Travis CI repository and press create integration to start syncing secrets to Travis CI. - - ![create integration travis ci](../../images/integrations/travis-ci/integrations-travisci-create.png) - ![integrations travis ci](../../images/integrations/travis-ci/integrations-travisci.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx deleted file mode 100644 index d2bb36a0b..000000000 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "AWS Parameter Store" -description: "Learn how to sync secrets from Infisical to AWS Parameter Store." ---- - - - The AWS Parameter Store Native Integration will be deprecated in 2026. Please migrate to our new [AWS Parameter Store Sync](../secret-syncs/aws-parameter-store). - \ No newline at end of file diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx deleted file mode 100644 index a56461998..000000000 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "AWS Secrets Manager" -description: "Learn how to sync secrets from Infisical to AWS Secrets Manager." ---- - - - The AWS Secrets Manager Native Integration will be deprecated in 2026. Please migrate to our new [AWS Secrets Manager Sync](../secret-syncs/aws-secrets-manager). - \ No newline at end of file diff --git a/docs/integrations/cloud/azure-app-configuration.mdx b/docs/integrations/cloud/azure-app-configuration.mdx deleted file mode 100644 index 4e7dfd94f..000000000 --- a/docs/integrations/cloud/azure-app-configuration.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Azure App Configuration" -description: "How to sync secrets from Infisical to Azure App Configuration" ---- - - - The Azure App Configuration Native Integration will be deprecated in 2026. Please migrate to our new [Azure App Configuration Sync](../secret-syncs/azure-app-configuration). - \ No newline at end of file diff --git a/docs/integrations/cloud/azure-devops.mdx b/docs/integrations/cloud/azure-devops.mdx deleted file mode 100644 index 4eaaf0cc1..000000000 --- a/docs/integrations/cloud/azure-devops.mdx +++ /dev/null @@ -1,55 +0,0 @@ ---- -title: "Azure DevOps" -description: "How to sync secrets from Infisical to Azure DevOps" ---- - -### Usage -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com). -- Create a new [Azure DevOps](https://dev.azure.com) project if you don't have one already. - - -#### Create a new Azure DevOps personal access token (PAT) -You'll need to create a new personal access token (PAT) in order to authenticate Infisical with Azure DevOps. - - - ![integrations](../../images/integrations/azure-devops/overview-page.png) - - - Make sure the newly created token has Read/Write access to the Release scope. - ![integrations](../../images/integrations/azure-devops/create-new-token.png) - - - Please make sure that the token has access to the following scopes: Variable Groups _(read, create, & manage)_, Release _(read/write)_, Project and Team _(read)_, Service Connections _(read & query)_ - - - - Copy the newly created token as this will be used to authenticate Infisical with Azure DevOps. - ![integrations](../../images/integrations/azure-devops/new-token-created.png) - - - -#### Setup the Infisical Azure DevOps integration -Navigate to your project's integrations tab and select the 'Azure DevOps' integration. -![integrations](../../images/integrations.png) - - - - Enter your credentials that you obtained from the previous step. - - 1. Azure DevOps API token is the personal access token (PAT) you created in the previous step. - 2. Azure DevOps organization name is the name of your Azure DevOps organization. - - ![integrations](../../images/integrations/azure-devops/new-infiscial-integration-step-1.png) - - - Select Infisical project and secret path you want to sync into Azure DevOps. - Finally, press create integration to start syncing secrets to Azure DevOps. - - ![integrations](../../images/integrations/azure-devops/new-infiscial-integration-step-2.png) - - - -Now you have successfully integrated Infisical with Azure DevOps. Your existing and future secret changes will automatically sync to Azure DevOps. -You can view your secrets by navigating to your Azure DevOps project and selecting the 'Library' tab under 'Pipelines' in the 'Library' section. diff --git a/docs/integrations/cloud/azure-key-vault.mdx b/docs/integrations/cloud/azure-key-vault.mdx deleted file mode 100644 index b0bd80c63..000000000 --- a/docs/integrations/cloud/azure-key-vault.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Azure Key Vault" -description: "How to sync secrets from Infisical to Azure Key Vault" ---- - - - The Azure Key Vault Native Integration will be deprecated in 2026. Please migrate to our new [Azure Key Vault Sync](../secret-syncs/azure-key-vault). - \ No newline at end of file diff --git a/docs/integrations/cloud/checkly.mdx b/docs/integrations/cloud/checkly.mdx deleted file mode 100644 index 00ec38d2f..000000000 --- a/docs/integrations/cloud/checkly.mdx +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Checkly" -description: "How to sync secrets from Infisical to Checkly" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Checkly API Key in User Settings > API Keys. - - ![integrations checkly dashboard](../../images/integrations/checkly/integrations-checkly-dashboard.png) - ![integrations checkly token](../../images/integrations/checkly/integrations-checkly-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Checkly tile and input your Checkly API Key to grant Infisical access to your Checkly account. - - ![integrations checkly authorization](../../images/integrations/checkly/integrations-checkly-auth.png) - - - - Select which Infisical environment secrets you want to sync to Checkly and press create integration to start syncing secrets. - - ![integrations checkly](../../images/integrations/checkly/integrations-checkly-create.png) - - - Infisical integrates with Checkly's environment variables at the **global** and **group** levels. - - To sync secrets to a specific group, you can select a group from the Checkly Group dropdown; otherwise, leaving it empty will sync secrets globally. - - - ![integrations checkly](../../images/integrations/checkly/integrations-checkly.png) - - - In the new version of the Checkly integration, you are able to specify suffixes that depend on the secrets' environment and path. - If you choose to do so, you should utilize such suffixes for ALL Checkly integrations – otherwise the integration system - might run into issues with deleting secrets from the wrong environments. - - - \ No newline at end of file diff --git a/docs/integrations/cloud/cloud-66.mdx b/docs/integrations/cloud/cloud-66.mdx deleted file mode 100644 index c087f6564..000000000 --- a/docs/integrations/cloud/cloud-66.mdx +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: "Cloud 66" -description: "How to sync secrets from Infisical to Cloud 66" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - -## Navigate to your project's integrations tab - -![integrations](../../images/integrations.png) - -## Enter your Cloud 66 Access Token - -In Cloud 66 Dashboard, click on the top right icon > Account Settings > Access Token -![integrations cloud 66 dashboard](../../images/integrations/cloud-66/integrations-cloud-66-dashboard.png) -![integrations cloud 66 access token](../../images/integrations/cloud-66/integrations-cloud-66-access-token.png) - -Create new Personal Access Token. -![integrations cloud 66 personal access token](../../images/integrations/cloud-66/integrations-cloud-66-pat.png) - -Name it **infisical** and check **Public** and **Admin**. Then click "Create Token" -![integrations cloud 66 personal access token setup](../../images/integrations/cloud-66/integrations-cloud-66-pat-setup.png) - -Copy and save your token. -![integrations cloud 66 copy API token](../../images/integrations/cloud-66/integrations-cloud-66-copy-pat.png) - -### Go to Infisical Integration Page - -Click on the Cloud 66 tile and enter your API token to grant Infisical access to your Cloud 66 account. -![integrations cloud 66 tile in infisical dashboard](../../images/integrations/cloud-66/integrations-cloud-66-infisical-dashboard.png) - -Enter your Cloud 66 Personal Access Token here. Then click "Connect to Cloud 66". -![integrations cloud 66 tile in infisical dashboard](../../images/integrations/cloud-66/integrations-cloud-66-paste-pat.png) - - -## Start integration - -Select which Infisical environment secrets you want to sync to which Cloud 66 stacks and press create integration to start syncing secrets to Cloud 66. -![integrations laravel forge](../../images/integrations/cloud-66/integrations-cloud-66-create.png) - - - Any existing environment variables in Cloud 66 will be deleted when you start syncing. Make sure to add all the secrets into the Infisical dashboard first before doing any integrations. - - -Done! -![integrations laravel forge](../../images/integrations/cloud-66/integrations-cloud-66-done.png) diff --git a/docs/integrations/cloud/cloudflare-pages.mdx b/docs/integrations/cloud/cloudflare-pages.mdx deleted file mode 100644 index addba4fcd..000000000 --- a/docs/integrations/cloud/cloudflare-pages.mdx +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Cloudflare Pages" -description: "How to sync secrets from Infisical to Cloudflare Pages" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Cloudflare [API token](https://dash.cloudflare.com/profile/api-tokens) and [Account ID](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/): - - Create a new [API token](https://dash.cloudflare.com/profile/api-tokens) in My Profile > API Tokens - - ![integrations cloudflare credentials 1](../../images/integrations/cloudflare/integrations-cloudflare-credentials-1.png) - ![integrations cloudflare credentials 2](../../images/integrations/cloudflare/integrations-cloudflare-credentials-2.png) - ![integrations cloudflare credentials 3](../../images/integrations/cloudflare/integrations-cloudflare-credentials-3.png) - - Copy your [Account ID](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/) from Account > Workers & Pages > Overview - - ![integrations cloudflare credentials 4](../../images/integrations/cloudflare/integrations-cloudflare-credentials-4.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Cloudflare Pages tile and input your Cloudflare API token and account ID to grant Infisical access to your Cloudflare Pages. - - ![integrations cloudflare authorization](../../images/integrations/cloudflare/integrations-cloudflare-auth.png) - - - - Select which Infisical environment secrets you want to sync to Cloudflare and press create integration to start syncing secrets. - - ![integrations cloudflare](../../images/integrations/cloudflare/integrations-cloudflare-create.png) - ![integrations cloudflare](../../images/integrations/cloudflare/integrations-cloudflare.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/cloudflare-workers.mdx b/docs/integrations/cloud/cloudflare-workers.mdx deleted file mode 100644 index 10a579701..000000000 --- a/docs/integrations/cloud/cloudflare-workers.mdx +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Cloudflare Workers" -description: "How to sync secrets from Infisical to Cloudflare Workers" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Cloudflare [API token](https://dash.cloudflare.com/profile/api-tokens) and [Account ID](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/): - - Create a new [API token](https://dash.cloudflare.com/profile/api-tokens) in My Profile > API Tokens - - ![integrations cloudflare credentials 1](../../images/integrations/cloudflare/integrations-cloudflare-credentials-1.png) - ![integrations cloudflare credentials 2](../../images/integrations/cloudflare/integrations-cloudflare-credentials-2.png) - ![integrations cloudflare credentials 3](../../images/integrations/cloudflare/integrations-cloudflare-workers-permission.png) - - Copy your [Account ID](https://developers.cloudflare.com/fundamentals/get-started/basic-tasks/find-account-and-zone-ids/) from Account > Workers & Pages > Overview - - ![integrations cloudflare credentials 4](../../images/integrations/cloudflare/integrations-cloudflare-credentials-4.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Cloudflare Workers tile and input your Cloudflare API token and account ID to grant Infisical access to your Cloudflare Workers. - - ![integrations cloudflare authorization](../../images/integrations/cloudflare/integration-cloudflare-workers-connect.png) - - - - Select which Infisical environment secrets you want to sync to Cloudflare Workers and press create integration to start syncing secrets. - - ![integrations cloudflare](../../images/integrations/cloudflare/integration-cloudflare-workers-create.png) - - - diff --git a/docs/integrations/cloud/databricks.mdx b/docs/integrations/cloud/databricks.mdx deleted file mode 100644 index e5ad22939..000000000 --- a/docs/integrations/cloud/databricks.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Databricks" -description: "Learn how to sync secrets from Infisical to Databricks." ---- - - - The Databricks Native Integration will be deprecated in 2026. Please migrate to our new [Databricks Sync](../secret-syncs/databricks). - \ No newline at end of file diff --git a/docs/integrations/cloud/digital-ocean-app-platform.mdx b/docs/integrations/cloud/digital-ocean-app-platform.mdx deleted file mode 100644 index a0ed545cc..000000000 --- a/docs/integrations/cloud/digital-ocean-app-platform.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Digital Ocean App Platform" -description: "How to sync secrets from Infisical to Digital Ocean App Platform" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - -## Get your Digital Ocean Personal Access Tokens - -On Digital Ocean dashboard, navigate to **API > Tokens** and click on "Generate New Token" -![integrations digital ocean dashboard](../../images/integrations/digital-ocean/integrations-do-dashboard.png) - -Name it **infisical**, choose **No expiry**, and make sure to check **Write (optional)**. Then click on "Generate Token" and copy your API token. -![integrations digital ocean token modal](../../images/integrations/digital-ocean/integrations-do-token-modal.png) - -## Navigate to your project's integrations tab - -Click on the **Digital Ocean App Platform** tile and enter your API token to grant Infisical access to your Digital Ocean account. -![integrations](../../images/integrations.png) - -Then enter your Digital Ocean Personal Access Token here. Then click "Connect to Digital Ocean App Platform". -![integrations infisical dashboard digital ocean integration](../../images/integrations/digital-ocean/integrations-do-enter-token.png) - -## Start integration - -Select which Infisical environment secrets you want to sync to which Digital Ocean App and click "Create Integration". -![integrations digital ocean select projects](../../images/integrations/digital-ocean/integrations-do-select-projects.png) - -Done! -![integrations digital ocean integration success](../../images/integrations/digital-ocean/integrations-do-success.png) diff --git a/docs/integrations/cloud/flyio.mdx b/docs/integrations/cloud/flyio.mdx deleted file mode 100644 index 2aa14a919..000000000 --- a/docs/integrations/cloud/flyio.mdx +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: "Fly.io" -description: "How to sync secrets from Infisical to Fly.io" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Fly.io access token in Access Tokens - - ![integrations fly dashboard](../../images/integrations/flyio/integrations-flyio-dashboard.png) - ![integrations fly token](../../images/integrations/flyio/integrations-flyio-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Fly.io tile and input your Fly.io access token to grant Infisical access to your Fly.io account. - - ![integrations fly authorization](../../images/integrations/flyio/integrations-flyio-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Fly.io app and press create integration to start syncing secrets to Fly.io. - - ![integrations fly](../../images/integrations/flyio/integrations-flyio-create.png) - ![integrations fly](../../images/integrations/flyio/integrations-flyio.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx deleted file mode 100644 index 22462feef..000000000 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "GCP Secret Manager" -description: "How to sync secrets from Infisical to GCP Secret Manager" ---- - - - The GCP Secret Manager Native Integration will be deprecated in 2026. Please migrate to our new [GCP Secret Manager Sync](../secret-syncs/gcp-secret-manager). - \ No newline at end of file diff --git a/docs/integrations/cloud/hashicorp-vault.mdx b/docs/integrations/cloud/hashicorp-vault.mdx deleted file mode 100644 index df2542ce7..000000000 --- a/docs/integrations/cloud/hashicorp-vault.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "HashiCorp Vault" -description: "How to sync secrets from Infisical to HashiCorp Vault" ---- - - - The Hashicorp Vault Native Integration will be deprecated in 2026. Please migrate to our new [Hashicorp Vault Sync](../secret-syncs/hashicorp-vault). - diff --git a/docs/integrations/cloud/hasura-cloud.mdx b/docs/integrations/cloud/hasura-cloud.mdx deleted file mode 100644 index f88c1eb50..000000000 --- a/docs/integrations/cloud/hasura-cloud.mdx +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: "Hasura Cloud" -description: "How to sync secrets from Infisical to Hasura Cloud" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Hasura Cloud Access Token in My Account > Access Tokens - - ![integrations hasura cloud tokens](../../images/integrations/hasura-cloud/integrations-hasura-cloud-tokens.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Hasura Cloud tile and input your Hasura Cloud access token to grant Infisical access to your Hasura Cloud account. - - ![integrations hasura cloud authorization](../../images/integrations/hasura-cloud/integrations-hasura-cloud-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Hasura Cloud project and press create integration to start syncing secrets to Hasura Cloud. - - ![integrations hasura cloud](../../images/integrations/hasura-cloud/integrations-hasura-cloud-create.png) - ![integrations hasura cloud](../../images/integrations/hasura-cloud/integrations-hasura-cloud.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/heroku.mdx b/docs/integrations/cloud/heroku.mdx deleted file mode 100644 index 75cf8c106..000000000 --- a/docs/integrations/cloud/heroku.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: "Heroku" -description: "How to sync secrets from Infisical to Heroku" ---- - - - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Heroku tile and grant Infisical access to your Heroku account. - - ![integrations heroku authorization](../../images/integrations/heroku/integrations-heroku-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Heroku app and press create integration to start syncing secrets to Heroku. - - ![integrations heroku](../../images/integrations/heroku/integrations-heroku-create.png) - - Here's some guidance on each field: - - - Project Environment: The environment in the current Infisical project from which you want to sync secrets from. - - Secrets Path: The path in the current Infisical project from which you want to sync secrets from such as `/` (for secrets that do not reside in a folder) or `/foo/bar` (for secrets nested in a folder, in this case a folder called `bar` in another folder called `foo`). - - Heroku App: The application in Heroku that you want to sync secrets to. - - Initial Sync Behavior (default is **Import - Prefer values from Infisical**): The behavior of the first sync operation triggered after creating the integration. - - **No Import - Overwrite all values in Heroku**: Sync secrets and overwrite any existing secrets in Heroku. - - **Import - Prefer values from Infisical**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, do nothing. Afterwards, sync secrets to Heroku. - - **Import - Prefer values from Heroku**: Import secrets from Heroku to Infisical; if a secret with the same name already exists in Infisical, replace its value with the one from Heroku. Afterwards, sync secrets to Heroku. - - ![integrations heroku](../../images/integrations/heroku/integrations-heroku.png) - - - - - Using the Heroku integration on a self-hosted instance of Infisical requires configuring an API client in Heroku - and registering your instance with it. - - - Navigate to your user Account settings > Applications to create a new API client. - - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-settings.png) - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-applications.png) - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-new-app.png) - - Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/integrations/heroku/oauth2/callback`. - - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-new-app-form.png) - - - Obtain the **Client ID** and **Client Secret** for your Heroku API client. - - ![integrations Heroku config](../../images/integrations/heroku/integrations-heroku-config-credentials.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your Heroku API client. - - - `CLIENT_ID_HEROKU`: The **Client ID** of your Heroku API client. - - `CLIENT_SECRET_HEROKU`: The **Client Secret** of your Heroku API client. - - Once added, restart your Infisical instance and use the Heroku integration. - - - - diff --git a/docs/integrations/cloud/laravel-forge.mdx b/docs/integrations/cloud/laravel-forge.mdx deleted file mode 100644 index c58c4a7be..000000000 --- a/docs/integrations/cloud/laravel-forge.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: "Laravel Forge" -description: "How to sync secrets from Infisical to Laravel Forge" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Laravel Forge access token in API Tokens - - ![integrations laravel forge dashboard](../../images/integrations/laravel-forge/integrations-laravelforge-dashboard.png) - ![integrations laravel forge api tokens](../../images/integrations/laravel-forge/integrations-laravelforge-api.png) - - Obtain your Laravel Forge Server ID in Servers > Server ID - - ![integrations laravel forge server](../../images/integrations/laravel-forge/integrations-laravelforge-servers.png) - ![integrations laravel forge server id](../../images/integrations/laravel-forge/integrations-laravelforge-serverid.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Laravel Forge tile and input your Laravel Forge access token and server ID to grant Infisical access to your Laravel Forge account. - - ![integrations laravel forge authorization](../../images/integrations/laravel-forge/integrations-laravelforge-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Laravel Forge site and press create integration to start syncing secrets to Laravel Forge. - - ![integrations laravel forge](../../images/integrations/laravel-forge/integrations-laravelforge-create.png) - ![integrations laravel forge](../../images/integrations/laravel-forge/integrations-laravelforge.png) - - - diff --git a/docs/integrations/cloud/netlify.mdx b/docs/integrations/cloud/netlify.mdx deleted file mode 100644 index f793aae10..000000000 --- a/docs/integrations/cloud/netlify.mdx +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: "Netlify" -description: "How to sync secrets from Infisical to Netlify" ---- - - - - - Infisical integrates with Netlify's new environment variable experience. If - your site uses Netlify's old environment variable experience, you'll have to - upgrade it to the new one to use this integration. - - - Prerequisites: - - - Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Netlify tile and grant Infisical access to your Netlify account. - - ![integrations netlify authorization](../../images/integrations/netlify/integrations-netlify-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Netlify app and context. Lastly, press create integration to start syncing secrets to Netlify. - - ![integrations netlify](../../images/integrations/netlify/integrations-netlify-create.png) - ![integrations netlify](../../images/integrations/netlify/integrations-netlify.png) - - - - - Using the Netlify integration on a self-hosted instance of Infisical requires configuring an OAuth application in Netlify - and registering your instance with it. - - - Navigate to your User settings > Applications > OAuth to create a new OAuth application. - - ![integrations Netlify config](../../images/integrations/netlify/integrations-netlify-config-user-settings.png) - ![integrations Netlify config](../../images/integrations/netlify/integrations-netlify-config-new-app.png) - - Create the OAuth application. As part of the form, set the **Redirect URI** to `https://your-domain.com/integrations/netlify/oauth2/callback`. - - ![integrations Netlify config](../../images/integrations/netlify/integrations-netlify-config-new-app-form.png) - - - Obtain the **Client ID** and **Secret** for your Netlify OAuth application. - - ![integrations Netlify config](../../images/integrations/netlify/integrations-netlify-config-credentials.png) - - Back in your Infisical instance, add two new environment variables for the credentials of your Netlify OAuth application. - - - `CLIENT_ID_NETLIFY`: The **Client ID** of your Netlify OAuth application. - - `CLIENT_SECRET_NETLIFY`: The **Secret** of your Netlify OAuth application. - - Once added, restart your Infisical instance and use the Netlify integration. - - - - - diff --git a/docs/integrations/cloud/northflank.mdx b/docs/integrations/cloud/northflank.mdx deleted file mode 100644 index 10dcb288e..000000000 --- a/docs/integrations/cloud/northflank.mdx +++ /dev/null @@ -1,33 +0,0 @@ ---- -title: "Northflank" -description: "How to sync secrets from Infisical to Northflank" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) -- Have a [Northflank](https://northflank.com) project with a secret group ready - - - - Obtain a Northflank API token in Account settings > API > Tokens - - ![integrations northflank dashboard](../../images/integrations/northflank/integrations-northflank-dashboard.png) - ![integrations northflank token](../../images/integrations/northflank/integrations-northflank-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Northflank tile and input your Northflank API token to grant Infisical access to your Northflank account. - - ![integrations northflank authorization](../../images/integrations/northflank/integrations-northflank-auth.png) - - - - Select which Infisical environment secrets you want to sync to which Northflank project and secret group. Finally, press create integration to start syncing secrets to Northflank. - - ![integrations northflank](../../images/integrations/northflank/integrations-northflank-create.png) - ![integrations northflank](../../images/integrations/northflank/integrations-northflank.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/qovery.mdx b/docs/integrations/cloud/qovery.mdx deleted file mode 100644 index 13aa6af46..000000000 --- a/docs/integrations/cloud/qovery.mdx +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: "Qovery" -description: "How to sync secrets from Infisical to Qovery" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Qovery API Token in Settings > API Token. - - ![integrations qovery api token](../../images/integrations/qovery/integrations-qovery-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Qovery tile and input your Qovery API Token to grant Infisical access to your Qovery account. - - ![integrations qovery authorization](../../images/integrations/qovery/integrations-qovery-auth.png) - - - - Select which Infisical environment secrets you want to sync to Qovery and press create integration to start syncing secrets. - - ![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-1.png) - - ![integrations qovery create](../../images/integrations/qovery/integrations-qovery-create-2.png) - - - Infisical supports syncing secrets to various Qovery scopes including applications, jobs, or containers. - - - ![integrations qovery settings](../../images/integrations/qovery/integrations-qovery.png) - - diff --git a/docs/integrations/cloud/railway.mdx b/docs/integrations/cloud/railway.mdx deleted file mode 100644 index 77b315517..000000000 --- a/docs/integrations/cloud/railway.mdx +++ /dev/null @@ -1,47 +0,0 @@ ---- -title: "Railway" -description: "How to sync secrets from Infisical to Railway" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Railway API Token in your Railway [Account Settings > Tokens](https://railway.app/account/tokens). - - ![integrations railway dashboard](../../images/integrations/railway/integrations-railway-dashboard.png) - ![integrations railway token](../../images/integrations/railway/integrations-railway-token.png) - - - If this is your first time creating a Railway API token, then you'll be prompted to join - Railway's Private Boarding Beta program on the Railway Account Settings > Tokens page. - - Note that Railway project tokens will not work for this integration since they don't work with - Railway's Public API. - - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Railway tile and input your Railway API Key to grant Infisical access to your Railway account. - - ![integrations railway authorization](../../images/integrations/railway/integrations-railway-authorization.png) - - - - Select which Infisical environment secrets you want to sync to which Railway project and environment (and optionally service). Lastly, press create integration to start syncing secrets to Railway. - - ![integrations create railway](../../images/integrations/railway/integrations-railway-create.png) - - - Infisical integrates with both Railway's [shared variables](https://blog.railway.app/p/shared-variables-release) at the project environment level as well as service variables at the service level. - - To sync secrets to a specific service in a project, you can select a service from the Railway Service dropdown; otherwise, leaving it empty will sync secrets to the shared variables of that project. - - - ![integrations railway](../../images/integrations/railway/integrations-railway.png) - - \ No newline at end of file diff --git a/docs/integrations/cloud/render.mdx b/docs/integrations/cloud/render.mdx deleted file mode 100644 index 1d4860ebd..000000000 --- a/docs/integrations/cloud/render.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: "Render" -description: "How to sync secrets from Infisical to Render" ---- - - - The Render Native Integration will be deprecated in 2026. Please migrate to - our new [Render Sync](../secret-syncs/render). - diff --git a/docs/integrations/cloud/supabase.mdx b/docs/integrations/cloud/supabase.mdx deleted file mode 100644 index b5179c45f..000000000 --- a/docs/integrations/cloud/supabase.mdx +++ /dev/null @@ -1,39 +0,0 @@ ---- -title: "Supabase" -description: "How to sync secrets from Infisical to Supabase" ---- - - - The Supabase integration is useful if your Supabase project uses sensitive-information such as [environment variables in edge functions](https://supabase.com/docs/guides/functions/secrets). - - Synced envars can be accessed in edge functions using Deno's built-in handler: `Deno.env.get(MY_SECRET_NAME)`. - - -Prerequisites: - -- Have an account and project set up at [Supabase](https://supabase.com/) -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) - - - - Obtain a Supabase Access Token in your Supabase [Account > Access Tokens](https://app.supabase.com/account/tokens). - ![integrations supabase dashboard](../../images/integrations/supabase/integrations-supabase-dashboard.png) - ![integrations supabase token](../../images/integrations/supabase/integrations-supabase-token.png) - - Navigate to your project's integrations tab in Infisical. - - ![integrations](../../images/integrations.png) - - Press on the Supabase tile and input your Supabase Access Token to grant Infisical access to your Supabase account. - - ![integrations supabase authorization](../../images/integrations/supabase/integrations-supabase-authorization.png) - - - - Select which Infisical environment secrets you want to sync to which Supabase project. Lastly, press create integration to start syncing secrets to Supabase. - - ![integrations supabase create](../../images/integrations/supabase/integrations-supabase-create.png) - - ![integrations supabase](../../images/integrations/supabase/integrations-supabase.png) - - diff --git a/docs/integrations/cloud/terraform-cloud.mdx b/docs/integrations/cloud/terraform-cloud.mdx deleted file mode 100644 index 63398ef4a..000000000 --- a/docs/integrations/cloud/terraform-cloud.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Terraform Cloud" -description: "How to sync secrets from Infisical to Terraform Cloud" ---- - - - The Terraform Cloud Native Integration will be deprecated in 2026. Please migrate to our new [Terraform Cloud Sync](../secret-syncs/terraform-cloud). - \ No newline at end of file diff --git a/docs/integrations/cloud/vercel.mdx b/docs/integrations/cloud/vercel.mdx deleted file mode 100644 index 7456776bd..000000000 --- a/docs/integrations/cloud/vercel.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Vercel" -description: "How to sync secrets from Infisical to Vercel" ---- - - - The Vercel Native Integration will be deprecated in 2026. Please migrate to our new [Vercel Sync](../secret-syncs/vercel). - \ No newline at end of file diff --git a/docs/integrations/cloud/windmill.mdx b/docs/integrations/cloud/windmill.mdx deleted file mode 100644 index 7d4c2cc82..000000000 --- a/docs/integrations/cloud/windmill.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Windmill" -description: "How to sync secrets from Infisical to Windmill" ---- - - - The Windmill Native Integration will be deprecated in 2026. Please migrate to our new [Windmill Sync](../secret-syncs/windmill). - diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx deleted file mode 100644 index ed7d47b30..000000000 --- a/docs/integrations/overview.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: "Overview" -description: "How to use Infisical to inject secrets and configs into various 3-rd party services and frameworks." ---- - -Integrations allow environment variables to be synced from Infisical into your local development workflow, CI/CD pipelines, and production infrastructure. - -Missing an integration? [Throw in a request](https://github.com/Infisical/infisical/issues). - -| Integration | Type | Status | -| ------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------- | -| [Docker](/integrations/platforms/docker) | Platform | Available | -| [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | -| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | -| [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) | Infrastructure as code | Available | -| [PM2](/integrations/platforms/pm2) | Platform | Available | -| [Heroku](/integrations/cloud/heroku) | Cloud | Available | -| [Vercel](/integrations/cloud/vercel) | Cloud | Available | -| [Netlify](/integrations/cloud/netlify) | Cloud | Available | -| [Render](/integrations/cloud/render) | Cloud | Available | -| [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | -| [Railway](/integrations/cloud/railway) | Cloud | Available | -| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | -| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | -| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | -| [Supabase](/integrations/cloud/supabase) | Cloud | Available | -| [Northflank](/integrations/cloud/northflank) | Cloud | Available | -| [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | -| [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | -| [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | -| [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | -| [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | -| [Windmill](/integrations/cloud/windmill) | Cloud | Available | -| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | -| [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | -| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | -| [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | -| [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | -| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | -| [Octopus Deploy](/integrations/cicd/octopus-deploy) | CI/CD | Available | -| [React](/integrations/frameworks/react) | Framework | Available | -| [Vue](/integrations/frameworks/vue) | Framework | Available | -| [Express](/integrations/frameworks/express) | Framework | Available | -| [Next.js](/integrations/frameworks/nextjs) | Framework | Available | -| [NestJS](/integrations/frameworks/nestjs) | Framework | Available | -| [SvelteKit](/integrations/frameworks/sveltekit) | Framework | Available | -| [Nuxt](/integrations/frameworks/nuxt) | Framework | Available | -| [Gatsby](/integrations/frameworks/gatsby) | Framework | Available | -| [Remix](/integrations/frameworks/remix) | Framework | Available | -| [Vite](/integrations/frameworks/vite) | Framework | Available | -| [Fiber](/integrations/frameworks/fiber) | Framework | Available | -| [Django](/integrations/frameworks/django) | Framework | Available | -| [Flask](/integrations/frameworks/flask) | Framework | Available | -| [Laravel](/integrations/frameworks/laravel) | Framework | Available | -| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | -| Jenkins | CI/CD | Available | diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 85f63079f..3eed68f05 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -36,7 +36,7 @@ You can either call modules by their Fully Qualified Collection Name (FQCN), suc ### Authentication -The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth) and [OIDC](/documentation/platform/identities/oidc-auth/general) for authenticating against Infisical. +The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth), [OIDC Auth](/documentation/platform/identities/oidc-auth/general), and [Token Auth](/documentation/platform/identities/token-auth) for authenticating against Infisical. @@ -77,6 +77,26 @@ The Infisical Ansible Collection supports [Universal Auth](/documentation/platfo | jwt | `INFISICAL_JWT` | + + + Token Auth is the simplest authentication method that allows you to authenticate directly with an access token. This can be either a [Machine Identity Token Auth](/documentation/platform/identities/token-auth) token or a User JWT token. + + + Please note that in order to use Token Auth, you must have `1.0.13` or newer of the `infisicalsdk` package installed. + + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="token_auth", token='' ...rest) + ``` + + You can also provide the `auth_method` and `token` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | -------------- | ------------------------- | + | auth_method | `INFISICAL_AUTH_METHOD` | + | token | `INFISICAL_TOKEN` | + + ### Examples diff --git a/docs/integrations/platforms/aws/lambda.mdx b/docs/integrations/platforms/aws/lambda.mdx new file mode 100644 index 000000000..8376e98c2 --- /dev/null +++ b/docs/integrations/platforms/aws/lambda.mdx @@ -0,0 +1,98 @@ +--- +title: "AWS Lambda" +sidebarTitle: "AWS Lambda" +description: "How to use Infisical secrets in AWS Lambda" +--- + +Learn how to sync Infisical secrets to AWS Lambda regardless of how you deploy your function. This guide covers the following strategies: + +- Infisical SDKs +- AWS Secrets Manager integration +- AWS Systems Manager Parameter Store integration +- AWS CLI + +## Choose your sync strategy + +### 1. Fetch secrets at runtime with Infisical SDKs + +If you control the Lambda code, the simplest method is to fetch secrets directly from Infisical using one of our SDKs. +You can read more about the Infisical SDKs [here](/sdks/overview). + +### 2. Push via secret sync + +Configure a secret sync from your Infisical project, and Infisical will keep your Secrets Manager or Parameter Store values up to date. Your Lambda function can then reference those secrets directly. +Learn more about the [AWS Secrets Manager integration](/integrations/secret-syncs/aws-secrets-manager) and the [AWS Parameter Store integration](/integrations/secret-syncs/aws-parameter-store). + +### 3. Push environment variables directly using the AWS CLI + +For straightforward workflows or quick rotations, you can push Infisical secrets directly into Lambda environment variables using the AWS CLI. + +## Prerequisites + +- AWS CLI v2 installed and authenticated +- `jq` installed locally +- An IAM principal with `lambda:UpdateFunctionConfiguration` +- Infisical CLI (`infisical`) configured + +### IAM permissions + +Attach a policy like the one below to the IAM user or role responsible for updating Lambda configuration: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "LambdaConfig", + "Effect": "Allow", + "Action": ["lambda:UpdateFunctionConfiguration"], + "Resource": "*" + } + ] +} +``` + + + {" "} + Replacing Lambda environment variables using the AWS CLI overwrites the entire + `Variables` object. Make sure to export your current values so you can import them + into Infisical.{" "} + + +#### Push secrets to Lambda + +Use the Infisical CLI to export secrets as JSON and pass them to the AWS CLI. +The example below targets a project by ID, but you can also use the `--project` and `--env` flags. +Learn more about `infisical export` [here](/cli/commands/export#infisical-export). + +```bash +FUNCTION_NAME=infisical-env-test +REGION=us-east-1 +PROJECT_ID=1234567890 + +aws lambda update-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --region "$REGION" \ + --environment "$( + infisical export \ + --format=json \ + --projectId="$PROJECT_ID" \ + | jq 'map({(.key): .value}) | add | {Variables: .}' + )" +``` + +On success, the updated `Environment.Variables` block will be returned. +Verify the values in the Lambda console or by invoking the function. + + + Automate this step in CI/CD. Run `infisical export` using an Infisical Token + scoped to your project and environment, and trigger the sync as part of your + deployment workflow. Learn more about the [Infisical + Token](/cli/commands/export#infisical-export:infisical-token). + + + + We recommend using automatic secret syncs to AWS Secrets Manager or AWS + Parameter Store to keep your secrets continuously in sync and avoid manually + updating the Lambda configuration. + diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index 43d322faa..883d248ae 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -8,12 +8,12 @@ It eliminates the need to modify application logic by enabling clients to decide ![agent diagram](/images/agent/infisical-agent-diagram.png) -### Key features: +## Key Features -- Token renewal: Automatically authenticates with Infisical and deposits renewed access tokens at specified path for applications to consume -- Templating: Renders secrets via user provided templates to desired formats for applications to consume +- **Token lifecycle management**: Automatically authenticates with Infisical and deposits renewed access tokens at specified path for applications to consume +- **Templating**: Renders secrets and dynamic secret leases via user provided templates to desired formats for applications to consume -### Token renewal +## Token Renewal The Infisical agent can help manage the life cycle of access tokens. The token renewal process is split into two main components: a `Method`, which is the authentication process suitable for your current setup, and `Sinks`, which are the places where the agent deposits the new access token whenever it receives updates. @@ -28,7 +28,7 @@ Every time the agent successfully retrieves a new access token, it writes the ne to retrieve secrets from Infisical -### Templating +## Templating The Infisical agent can help deliver formatted secrets to your application in a variety of environments. To achieve this, the agent will retrieve secrets from Infisical, format them using a specified template, and then save these formatted secrets to a designated file path. @@ -40,31 +40,203 @@ If this initial attempt is unsuccessful, the agent will momentarily pauses befor Once the agent successfully obtains a valid access token, the agent proceeds to fetch the secrets from Infisical using it. It then formats these secrets using the user provided templates and writes the formatted data to configured file paths. + +### Available secret template functions + +The secret template functions is what you will use to fetch resources such as static secrets and dynamic secret leases from Infisical. Below is a list of the available secret template functions that you can use in your templates. + + + + + ```bash + secret "" "environment-slug" "" "" + ``` + ```bash example-template-usage-1 + {{- with secret "6553ccb2b7da580d7f6e7260" "dev" "/" `{"recursive": false, "expandSecretReferences": true}` }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + ```bash example-template-usage-2 + {{- with secret "da8056c8-01e2-4d24-b39f-cb4e004b8d44" "staging" "/" `{"recursive": true, "expandSecretReferences": true}` }} + {{- range . }} + {{- if eq .SecretPath "/"}} + {{ .Key }}={{ .Value }} + {{- else}} + {{ .SecretPath }}/{{ .Key }}={{ .Value }} + {{- end}} + {{- end }} + {{- end }} + ``` + + + + **Function name**: `secret` + + **Description**: This function can be used to render the full list of secrets within a given project, environment and secret path. + + An optional JSON argument is also available. It includes the properties `recursive`, which defaults to false, and `expandSecretReferences`, which defaults to true and expands the returned secrets. + + + **Returns**: A single secret object with the following keys `Key, WorkspaceId, Value, SecretPath, Type, ID, and Comment` + + + + + ```bash + getSecretByName "" "" "" "" + ``` + + ```bash example-template-usage + {{ with getSecretByName "d821f21d-aa90-453b-8448-8c78c1160a0e" "dev" "/" "POSTHOG_HOST"}} + {{ if .Value }} + password = "{{ .Value }}" + {{ end }} + {{ end }} + ``` + + **Function name**: `getSecretByName` + + **Description**: This function can be used to render a single secret by it's name. + + **Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment` + + + + + ```bash + dynamic_secret "" "" "" "" "" + ``` + + ```bash example-redis-dynamic-secret + {{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }} + {{ .DB_USERNAME }}={{ .DB_PASSWORD }} + {{- end }} + + ``` + + **Function Name**: `dynamic_secret` + + **Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date. + + **Returns**: An object with keys corresponding to the dynamic secret lease credentials. + + + Note that if you have multiple dynamic secret templates with identical configurations, only one lease will be created in Infisical for those templates, and the same lease will be written to your specified destination paths. + + + + + +## Caching + +The Infisical Agent supports clientside caching of Dynamic Secret leases. If the cache is enabled, the agent will persist the dynamic secret leases to the cache across restarts of the agent. + +### Persistent Caching + +The Agent currently only supports persistent caching. To utilize persistent caching, you must be within a Kubernetes environment. We recommend using the [Infisical Agent Injector](/integrations/platforms/kubernetes-injector) to inject the agent into pods within your Kubernetes cluster on demand. + +### Cache eviction + +Cache eviction is the process of removing cached data from the cache. The Agent will automatically evict cached data when the cache is full during a garbage collection cycle which is triggered every 10 minutes. + +The cache will also automatically evict cached data that has gone stale or is about to go stale. For dynamic resources (such as dynamic secret leases), there's a TTL (Time-to-Live) associated with each lease which is used to determine if the lease is stale or about to go stale. +If a stale dynamic secret lease is detected, it will be automatically evicted from the cache and replaced with a new up-to-date lease. + + +### Cache Configuration + +Configuring the cache is done through the agent configuration file. The following fields are available to configure the cache: + + + + + The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. + + + The path to where your persistent cache will be stored. + + + + Persistent caching is only supported within kubernetes environments at the moment. Please refer to the [Infisical Agent Injector](/integrations/platforms/kubernetes-injector) documentation for more information on how to use persistent caching within Kubernetes environments. + + + ```yaml example-agent-config-file.yaml + cache: + persistent: + type: "kubernetes" + path: "/home/infisical/cache" + service-account-token-path: "/var/run/secrets/kubernetes.io/serviceaccount/token" + ``` + + + + +## Retrying mechanism + +The agent will automatically attempt to retry failed API requests such as authentication, secrets retrieval, dynamic secret lease provisioning, etc. +By default, the agent will retry up to 3 times with a base delay of 200ms and a maximum delay of 5s. + +You can configure the retrying mechanism through the agent configuration file. The following fields are available to configure the retrying mechanism: + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. + + + The maximum delay between retries. Defaults to `5s` (5 seconds). + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). + + +```yaml example-agent-config-file.yaml +infisical: + address: "https://app.infisical.com" + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# ... rest of the agent configuration file +``` + + + ## Agent configuration file To set up the authentication method for token renewal and to define secret templates, the Infisical agent requires a YAML configuration file containing properties defined below. While specifying an authentication method is mandatory to start the agent, configuring sinks and secret templates are optional. -| 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` | -| `auth.config.service-account-key` | Path to your GCP service account key file. This field is required when using `gcp-iam` auth type.

Please note that the file should be in JSON format. | -| `auth.config.client-id` | The file path where the universal-auth client id is stored. | -| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | -| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | -| `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[].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) | -| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | + + +| 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"`. | +| `infisical.retry-strategy.max-retries` | How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. | +| `infisical.retry-strategy.max-delay` | The maximum delay between retries. Defaults to `5s` (5 seconds). | +| `infisical.retry-strategy.base-delay` | The base delay between retries. Defaults to `200ms` (200 milliseconds). | +| `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` | +| `auth.config.service-account-key` | Path to your GCP service account key file. This field is required when using `gcp-iam` auth type.

Please note that the file should be in JSON format. | +| `auth.config.client-id` | The file path where the universal-auth client id is stored. | +| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | +| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | +| `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. | +| `cache.persistent.type` | The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. | +| `cache.persistent.path` | The path to where your persistent cache will be stored. | +| `cache.persistent.service-account-token-path` | The path to the Kubernetes service account token to use for encrypting the persistent cache. Required when using `kubernetes` cache type. Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token` | +| `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[].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: `5m` (5 minutes) (optional) | +| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | +| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | ## Authentication @@ -308,81 +480,4 @@ After defining the agent configuration file, run the command below pointing to t ```bash infisical agent --config example-agent-config-file.yaml -``` - -### Available secret template functions - - - ```bash - listSecrets "" "environment-slug" "" "" - ``` - ```bash example-template-usage-1 - {{- with listSecrets "6553ccb2b7da580d7f6e7260" "dev" "/" `{"recursive": false, "expandSecretReferences": true}` }} - {{- range . }} - {{ .Key }}={{ .Value }} - {{- end }} - {{- end }} - ``` - ```bash example-template-usage-2 -{{- with secret "da8056c8-01e2-4d24-b39f-cb4e004b8d44" "staging" "/" `{"recursive": true, "expandSecretReferences": true}` }} -{{- range . }} -{{- if eq .SecretPath "/"}} -{{ .Key }}={{ .Value }} -{{- else}} -{{ .SecretPath }}/{{ .Key }}={{ .Value }} -{{- end}} -{{- end }} -{{- end }} - ``` - - - -**Function name**: listSecrets - -**Description**: This function can be used to render the full list of secrets within a given project, environment and secret path. - -An optional JSON argument is also available. It includes the properties `recursive`, which defaults to false, and `expandSecretReferences`, which defaults to true and expands the returned secrets. - - -**Returns**: A single secret object with the following keys `Key, WorkspaceId, Value, SecretPath, Type, ID, and Comment` - - - - - ```bash - getSecretByName "" "" "" "" - ``` - -```bash example-template-usage -{{ with getSecretByName "d821f21d-aa90-453b-8448-8c78c1160a0e" "dev" "/" "POSTHOG_HOST"}} -{{ if .Value }} -password = "{{ .Value }}" -{{ end }} -{{ end }} -``` - -**Function name**: getSecretByName - -**Description**: This function can be used to render a single secret by it's name. - -**Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment` - - - - - ```bash - dynamic_secret "" "" "" "" "" - ``` - - ```bash example-redis-dynamic-secret - {{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }} - {{ .DB_USERNAME }}={{ .DB_PASSWORD }} - {{- end }} - - **Function Name**: dynamic_secret - - **Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date. - - **Returns**: An object with keys corresponding to the dynamic secret lease credentials. - ``` - \ No newline at end of file +``` \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index 9903dcbc3..f51a96ab2 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -120,19 +120,83 @@ You will need to set the `nodeSelector.kubernetes.io/os` label to `windows` and The Infisical Agent Injector supports the following annotations: - - The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create. - - - The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. + + + The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create. + + + The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. - - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. - - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. - - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. - - - The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. - + - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. + - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. + - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. + + + The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. + + + + Whether to enable client-side caching of dynamic secret leases. Defaults to `false`. If you set this to `true`, the agent will persist any dynamic secret leases across restarts of the agent. This is especially useful when using the `sidecar-init` inject mode, to pass the dynamic secret leases created in the init container to the sidecar container. + This will ensure that no new leases are created except those initially created in the init container. The sidecar container will register the leases created in the init container and start managing them from that point onwards. + + + + Whether to revoke all managed dynamic secret leases and machine identity access tokens on shutdown. Defaults to `false`. + + If you set this to `true`, all managed dynamic secret leases and machine 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. + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The maximum delay between retries. Defaults to `5s` (5 seconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The maximum CPU limit for the agent containers. + + Linux Pods: Defaults to `500m` (500 milliCPUs). + Windows Pods: Defaults to `500m` (500 milliCPUs). + + + + The minimum CPU request for the agent containers. + + Linux Pods: Defaults to `100m` (100 milliCPUs). + Windows Pods: Defaults to `100m` (100 milliCPUs). + + + + The maximum memory limit for the agent containers. + + Linux Pods: Defaults to `128Mi` (128 megabytes). + Windows Pods: Defaults to `512Mi` (512 megabytes). + + + + The minimum memory request for the agent containers. + + Linux Pods: Defaults to `64Mi` (64 megabytes). + Windows Pods: Defaults to `256Mi` (256 megabytes). + + + + The maximum ephemeral storage limit for the agent containers. Doesn't have an explicit default value. The default value will conform to the default ephemeral storage limit for the pod. + + + + The minimum ephemeral storage request for the agent containers. Doesn't have an explicit default value. The default value will conform to the default ephemeral storage request for the pod. + + + ## ConfigMap Configuration @@ -141,18 +205,22 @@ The Infisical Agent Injector supports the following annotations: When you are configuring a pod to use the injector, you must create a config map in the same namespace as the pod you want to inject secrets into. The entire config needs to be of string format and needs to be assigned to the `config.yaml` key in the config map. You can find a full example of the config at the end of this section. + 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"`. + Whether to revoke all managed dynamic secret leases and machine 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)_. + If this is set to `true`, all managed dynamic secret leases and machine 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. + This is currently unsupported on Windows-based pods, and will only work when injecting into Linux-based pods. + + It's recommended to use the annotation `org.infisical.com/agent-revoke-on-shutdown: "true"` instead of configuring the revoke on shutdown on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the revoke on shutdown through annotations. @@ -162,8 +230,59 @@ The entire config needs to be of string format and needs to be assigned to the ` Please note that the pod's default service account will be used to authenticate with Infisical. + - The ID of the machine identity to use to connect to Infisical. This field is required if the `infisical.auth.type` is set to `kubernetes`. + The ID of the machine identity to use for Kubernetes or LDAP authentication. This field is required if the `infisical.auth.type` is set to `kubernetes`. + + + + The LDAP username to use for LDAP authentication. + This field is required if the `infisical.auth.type` is set to `ldap-auth`. + + + + The LDAP password to use for LDAP authentication. + This field is required if the `infisical.auth.type` is set to `ldap-auth`. + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the max retries through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the max retries through annotations. + + + + + The maximum delay between retries. Defaults to `5s` (5 seconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the max delay through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the max delay through annotations. + + + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the base delay through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the base delay through annotations. + + + + + The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. + + + It is recommended to use the annotation `org.infisical.com/agent-cache-enabled: "true"` instead of configuring the cache on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the cache through annotations. + + + + + The path to the Kubernetes service account token to use for encrypting the persistent cache. Required when using `kubernetes` cache type. Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + It is recommended to use the annotation `org.infisical.com/agent-cache-enabled: "true"` instead of configuring the cache on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the cache through annotations. + @@ -180,6 +299,7 @@ The templates hold an array of templates that will be rendered and injected into This will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent) + ### Authentication @@ -271,7 +391,7 @@ The Infisical Agent Injector supports Machine Identity [Kubernetes Auth](/docume -To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. +To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. The config map must be in the same namespace as the pod you're injecting into. ```yaml apiVersion: v1 kind: Pod diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index 937c8d826..c341e6ea3 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -5,10 +5,6 @@ description: "Learn how to sync secrets to third-party services with Infisical." Secret Syncs enable you to sync secrets from Infisical to third-party services using [App Connections](/integrations/app-connections/overview). - - Secret Syncs will gradually replace Native Integrations as they become available. Native Integrations will be deprecated in the future, so opt for configuring a Secret Sync when available. - - ## Concept Secret Syncs are a project-level resource used to sync secrets, via an [App Connection](/integrations/app-connections/overview), from a particular project environment and folder path (source) @@ -92,7 +88,7 @@ via the UI or API for the third-party service you intend to sync secrets to. Infisical is continuously expanding it's Secret Sync third-party service support. If the service you need isn't available, - you can still use our Native Integrations in the interim, or contact us at team@infisical.com to make a request . + you can contact us at team@infisical.com to make a request. ## Key Schemas diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx index 74cffbc11..de83a2068 100644 --- a/docs/integrations/secret-syncs/vercel.mdx +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -43,6 +43,9 @@ description: "Learn how to configure a Vercel Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + + Vercel does not expose the values of [sensitive environment variables](https://vercel.com/docs/environment-variables/sensitive-environment-variables), so Infisical cannot import them during the initial sync. As a result, these secrets are created in Infisical with empty values. After the first sync, you'll need to manually re-enter their values in Infisical to ensure both platforms stay aligned. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. @@ -149,4 +152,5 @@ description: "Learn how to configure a Vercel Sync for Infisical." } ``` + diff --git a/docs/sdks/languages/dotnet.mdx b/docs/sdks/languages/dotnet.mdx index 7b3d12885..b02aa9d75 100644 --- a/docs/sdks/languages/dotnet.mdx +++ b/docs/sdks/languages/dotnet.mdx @@ -118,6 +118,22 @@ var _ = await sdk.Auth().UniversalAuth().LoginAsync( - `clientId` (string): The client ID of your Machine Identity. - `clientSecret` (string): The client secret of your Machine Identity. +### LDAP Auth + +#### Authenticating +```cs +var _ = await sdk.Auth().LdapAuth().LoginAsync( + "IDENTITY_ID", + "USERNAME", + "PASSWORD" +); +``` + +**Parameters:** +- `identityId` (string): The ID of your Machine Identity . +- `username` (string): The LDAP username for authentication. +- `password` (string): The LDAP password for authentication. + ### `Secrets()` The `Secrets()` sub-class handles operations related to the Infisical secrets management product. diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index 26f8a36d3..88dc6cf3b 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -284,6 +284,114 @@ if err != nil { } ``` +#### JWT Auth + + + Please note that this authentication method requires a valid JWT token from your JWT issuer. Please [read + more](/documentation/platform/identities/jwt-auth) about this authentication + method. + + +**Using the SDK** + +```go +credential, err := client.Auth().JwtAuthLogin("MACHINE_IDENTITY_ID", "JWT_TOKEN") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### LDAP Auth + + + Please note that this authentication method requires LDAP credentials. Please [read + more](/documentation/platform/identities/ldap-auth/general) about this authentication + method. + + +**Using environment variables** + +You can set the `INFISICAL_LDAP_AUTH_IDENTITY_ID` environment variable and pass empty string for the identity ID: + +```go +credential, err := client.Auth().LdapAuthLogin("", "LDAP_USERNAME", "LDAP_PASSWORD") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +**Using the SDK directly** + +```go +credential, err := client.Auth().LdapAuthLogin("MACHINE_IDENTITY_ID", "LDAP_USERNAME", "LDAP_PASSWORD") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### OCI Auth + + + Please note that this authentication method will only work if you're running + your application on Oracle Cloud Infrastructure. Please [read + more](/documentation/platform/identities/oci-auth) about this authentication + method. + + +**Using environment variables** + +You can set the `INFISICAL_OCI_AUTH_IDENTITY_ID` environment variable and omit the `IdentityID` field: + +```go +credential, err := client.Auth().OciAuthLogin(infisical.OciAuthLoginOptions{ + UserID: "USER_OCID", + TenancyID: "TENANCY_OCID", + Fingerprint: "FINGERPRINT", + PrivateKey: "PRIVATE_KEY", + Region: "REGION", +}) + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +**Using the SDK directly** + +```go +credential, err := client.Auth().OciAuthLogin(infisical.OciAuthLoginOptions{ + IdentityID: "MACHINE_IDENTITY_ID", + UserID: "USER_OCID", + TenancyID: "TENANCY_OCID", + Fingerprint: "FINGERPRINT", + PrivateKey: "PRIVATE_KEY", + Region: "REGION", + Passphrase: nil, // Optional: pointer to string if your private key has a passphrase +}) + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +**OciAuthLoginOptions fields:** + +- `IdentityID` (string) - Your Infisical Machine Identity ID. Can be set via `INFISICAL_OCI_AUTH_IDENTITY_ID` environment variable. +- `UserID` (string) - Your OCI user OCID. +- `TenancyID` (string) - Your OCI tenancy OCID. +- `Fingerprint` (string) - Your OCI API key fingerprint. +- `PrivateKey` (string) - Your OCI private key (PEM format). +- `Region` (string) - Your OCI region (e.g., `us-ashburn-1`). +- `Passphrase` (*string) - Optional: pointer to passphrase string if your private key is encrypted. + ## Secrets ### List Secrets diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 670e0975a..d69f19173 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -108,6 +108,15 @@ response = client.auth.oidc_auth.login( This authentication method is useful when integrating with OIDC-compliant identity providers like Okta, Auth0, or any service that issues OIDC tokens. +#### Token Auth + +```python +response = client.auth.token_auth.login(token="") +``` + +**Parameters:** +- `token` (str): The access token to authenticate with. This can be a [machine identity token](/documentation/platform/identities/token-auth) or a user access token. + ### `secrets` This sub-class handles operations related to secrets: diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index edebf1670..669c3aaa3 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -703,110 +703,6 @@ You can configure third-party app connections for re-use across Infisical Projec -## Native Secret Integrations - -To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. - - - - OAuth2 client ID for Heroku integration - - - OAuth2 client secret for Heroku integration - - - - - - OAuth2 client ID for Vercel integration - - -{" "} - - - OAuth2 client secret for Vercel integration - - - - OAuth2 slug for Vercel integration - - - - - - OAuth2 client ID for Netlify integration - - - - OAuth2 client secret for Netlify integration - - - - - - OAuth2 client ID for GitHub integration - - - - OAuth2 client secret for GitHub integration - - - - - - OAuth2 client ID for Bitbucket integration - - - - OAuth2 client secret for Bitbucket integration - - - - - - OAuth2 client id for GCP secrets manager integration - - - - OAuth2 client secret for GCP secrets manager integration - - - - - - The AWS IAM User access key for assuming roles. - - - - The AWS IAM User secret key for assuming roles. - - - - - - OAuth2 client id for Azure integration - - - - OAuth2 client secret for Azure integration - - - - - - OAuth2 client id for Gitlab integration - - - - OAuth2 client secret for Gitlab integration - - - ## Secret Scanning diff --git a/docs/self-hosting/ee.mdx b/docs/self-hosting/ee.mdx index 83b2772e4..9c3e7d644 100644 --- a/docs/self-hosting/ee.mdx +++ b/docs/self-hosting/ee.mdx @@ -14,14 +14,13 @@ This guide walks through how you can use these paid features on a self-hosted in Once purchased, you will be issued a license key. - Depending on whether or not the environment where Infisical is deployed has internet access, you may be issued a regular license or an offline license. + Set your license key as the value of the **LICENSE_KEY** environment variable within your Infisical instance. - - Assign the issued license key to the `LICENSE_KEY` environment variable in your Infisical instance. - - Your Infisical instance will need to communicate with the Infisical license server to validate the license key. + - Your Infisical instance will need to communicate with the Infisical license server to validate the license key. If you want to limit outgoing connections only to the Infisical license server, you can use the following IP addresses: `13.248.249.247` and `35.71.190.59` @@ -29,16 +28,18 @@ This guide walks through how you can use these paid features on a self-hosted in - - Assign the issued license key to the `LICENSE_KEY_OFFLINE` environment variable in your Infisical instance. + - Assign the issued offline license key to the `LICENSE_KEY` environment variable in your Infisical instance. + + - The system will automatically detect that it's an offline license based on the key format. - How you set the environment variable will depend on the deployment method you used. Please refer to the documentation of your deployment method for specific instructions. + While the LICENSE_KEY_OFFLINE environment variable continues to be supported for compatibility with existing configurations, we recommend transitioning to LICENSE_KEY for all license types going forward. - Once your instance starts up, the license key will be validated and you’ll be able to use the paid features. + Once your instance starts up, the license key will be validated and you'll be able to use the paid features. However, when the license expires, Infisical will continue to run, but EE features will be disabled until the license is renewed or a new one is purchased. - + diff --git a/docs/self-hosting/guides/cdn-caching.mdx b/docs/self-hosting/guides/cdn-caching.mdx new file mode 100644 index 000000000..4a6f237f8 --- /dev/null +++ b/docs/self-hosting/guides/cdn-caching.mdx @@ -0,0 +1,106 @@ +--- +title: "CDN Caching for Static Assets" +description: "How to set up CDN caching to prevent version skew issues during deployments" +--- + +This guide explains a common issue with frontend asset caching during deployments and how to solve it using a CDN. + +## The Problem: Version Skew + +Modern frontend build tools like Vite generate content-hashed filenames for static assets (e.g., `main-abc123.js`). Each build produces unique filenames based on file contents. During deployments, this can cause a race condition: + +1. User loads `index.html` which references `main-abc123.js` +2. New deployment replaces containers with a new build +3. New containers only serve `main-xyz789.js` (new build) +4. User's browser requests `main-abc123.js` from cached HTML +5. Request returns **404** — the old asset no longer exists + +This results in broken pages, failed SPA navigation, and requires users to manually refresh. + + +This is a documented limitation in Vite's official guidance: [Load Error Handling](https://vite.dev/guide/build#load-error-handling) + + +### Current Behavior + +Infisical includes a built-in workaround that detects version mismatches and triggers a page reload. While functional, this introduces a noticeable delay for users during deployments. + +## The Solution: External Asset Storage + +The solution is to store static assets externally (e.g., S3, GCS, Azure Blob) and serve them through a CDN (e.g., CloudFront, Cloud CDN, Cloudflare). Assets are uploaded **before** container deployment, ensuring old versions remain available. + +### How It Works + +```mermaid +flowchart LR + User[User Browser] + CDN[CDN] + S3[(Object Storage)] + App[Your Infrastructure] + + User --> CDN + CDN -->|"/assets/*"| S3 + CDN -->|"/* (default)"| App +``` + +The key points: + +- **Asset persistence**: Old assets remain available even after new deployments +- **Deployment order**: Upload new assets before deploying new containers +- **Long cache TTL**: Content-hashed files can be cached indefinitely (we recommend 30 days) +- **Automatic cleanup**: Configure lifecycle rules to expire old assets after 30 days + +At Infisical, we use **CloudFront + S3** for this purpose, but you can use any CDN and object storage combination that fits your infrastructure. + +## Exporting Assets + +Infisical provides a built-in command to export frontend assets from the Docker image: + +```bash +# Export as tar archive to stdout +docker run --rm infisical/infisical npm run --silent assets:export > assets.tar + +# Extract the archive +tar -xf assets.tar +ls assets/ # Content-hashed JS/CSS files +``` + +Or export directly to a mounted directory: + +```bash +docker run --rm -v $(pwd)/cdn-assets:/output \ + infisical/infisical npm run --silent assets:export /output +``` + +### What Gets Exported + +The command exports the `/assets` directory containing: + +- JavaScript bundles (e.g., `main-abc123.js`, `chunk-def456.js`) +- CSS files (e.g., `styles-789xyz.css`) +- Other static assets with content hashes + +These files are safe to cache with long TTLs because their filenames change whenever the content changes. + +## Integration with Your Pipeline + +The general deployment flow should be: + +1. **Build** your new Docker image (or pull the official Infisical image) +2. **Export** assets using `npm run assets:export` +3. **Upload** assets to your object storage +4. **Deploy** the new container version + +```bash +# Example: Export and upload to S3 +docker run --rm infisical/infisical:$VERSION npm run --silent assets:export > assets.tar +tar -xf assets.tar +aws s3 sync assets s3://your-bucket/assets --cache-control "public, max-age=2592000" + +# Then deploy your container +``` + + +Always upload assets **before** deploying the new container. This ensures the assets referenced by the new `index.html` exist before users can access them. + + diff --git a/docs/self-hosting/guides/production-hardening.mdx b/docs/self-hosting/guides/production-hardening.mdx index dfd7b575f..8982d6b62 100644 --- a/docs/self-hosting/guides/production-hardening.mdx +++ b/docs/self-hosting/guides/production-hardening.mdx @@ -694,4 +694,20 @@ For enterprise deployments requiring compliance certifications: ### Standards Compliance -**FIPS 140-3 Compliance**. Infisical is actively working on FIPS 140-3 compliance to meet U.S. and Canadian government cryptographic standards. This will provide validated cryptographic modules for organizations requiring certified encryption implementations. +#### FIPS 140-3 Compliance + +Infisical is compliant with FIPS 140-3, meeting U.S. and Canadian government cryptographic standards through validated cryptographic modules. +This certification is designed for organizations that require government-approved encryption implementations. +To deploy a FIPS-compliant instance, use the [infisical/infisical-fips](https://hub.docker.com/r/infisical/infisical-fips) Docker image, available to Enterprise customers. +Our FIPS 140-3 attestation letter is available in the [Infisical Trust Center](https://trust.infisical.com/). + +#### SOC 2 Compliance + +Infisical is SOC 2 compliant, demonstrating adherence to rigorous security, availability, and confidentiality standards established by the American Institute of CPAs (AICPA). +This certification validates our security controls and operational practices for organizations requiring third-party audited security assurance. Our SOC 2 report is available in the [Infisical Trust Center](https://trust.infisical.com/). + +#### HIPAA Compliance + +Infisical is HIPAA compliant, meeting the security and privacy requirements of the Health Insurance Portability and Accountability Act. +This compliance framework ensures appropriate safeguards for protected health information (PHI) for healthcare organizations and their business associates. +Our HIPAA certification is available in the [Infisical Trust Center](https://trust.infisical.com/). \ No newline at end of file diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index dfe574547..d7001d6eb 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -1,70 +1,388 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo } from "react"; export const AppConnectionsBrowser = () => { - const [searchTerm, setSearchTerm] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('All'); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("All"); - const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + const categories = [ + "All", + "Cloud Providers", + "Databases", + "CI/CD", + "Monitoring", + "Directory Services", + "Identity & Auth", + "Data Analytics", + "Hosting", + "DevOps Tools", + "Security", + "Networking & DNS", + ]; const connections = [ - {"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"}, - {"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"}, - {"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"}, - {"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"}, - {"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"}, - {"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, - {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, - {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Chef", "slug": "chef", "path": "/integrations/app-connections/chef", "description": "Learn how to connect your Chef to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} - ].sort(function(a, b) { - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + { + name: "AWS", + slug: "aws", + path: "/integrations/app-connections/aws", + description: + "Learn how to connect your AWS applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure Key Vault", + slug: "azure-key-vault", + path: "/integrations/app-connections/azure-key-vault", + description: + "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure App Configuration", + slug: "azure-app-configuration", + path: "/integrations/app-connections/azure-app-configuration", + description: + "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure Client Secrets", + slug: "azure-client-secrets", + path: "/integrations/app-connections/azure-client-secrets", + description: + "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure DevOps", + slug: "azure-devops", + path: "/integrations/app-connections/azure-devops", + description: + "Learn how to connect your Azure DevOps to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Azure ADCS", + slug: "azure-adcs", + path: "/integrations/app-connections/azure-adcs", + description: + "Learn how to connect your Azure ADCS to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "GCP", + slug: "gcp", + path: "/integrations/app-connections/gcp", + description: + "Learn how to connect your GCP applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "HashiCorp Vault", + slug: "hashicorp-vault", + path: "/integrations/app-connections/hashicorp-vault", + description: + "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", + category: "Security", + }, + { + name: "1Password", + slug: "1password", + path: "/integrations/app-connections/1password", + description: + "Learn how to connect your 1Password to pull secrets from Infisical.", + category: "Security", + }, + { + name: "Vercel", + slug: "vercel", + path: "/integrations/app-connections/vercel", + description: + "Learn how to connect your Vercel application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Netlify", + slug: "netlify", + path: "/integrations/app-connections/netlify", + description: + "Learn how to connect your Netlify application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Railway", + slug: "railway", + path: "/integrations/app-connections/railway", + description: + "Learn how to connect your Railway application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Fly.io", + slug: "flyio", + path: "/integrations/app-connections/flyio", + description: + "Learn how to connect your Fly.io application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Render", + slug: "render", + path: "/integrations/app-connections/render", + description: + "Learn how to connect your Render application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Heroku", + slug: "heroku", + path: "/integrations/app-connections/heroku", + description: + "Learn how to connect your Heroku application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "DigitalOcean", + slug: "digital-ocean", + path: "/integrations/app-connections/digital-ocean", + description: + "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Supabase", + slug: "supabase", + path: "/integrations/app-connections/supabase", + description: + "Learn how to connect your Supabase application to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Checkly", + slug: "checkly", + path: "/integrations/app-connections/checkly", + description: + "Learn how to connect your Checkly application to pull secrets from Infisical.", + category: "Monitoring", + }, + { + name: "GitHub", + slug: "github", + path: "/integrations/app-connections/github", + description: + "Learn how to connect your GitHub application to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "GitHub Radar", + slug: "github-radar", + path: "/integrations/app-connections/github-radar", + description: + "Learn how to connect your GitHub Radar to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "GitLab", + slug: "gitlab", + path: "/integrations/app-connections/gitlab", + description: + "Learn how to connect your GitLab application to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "TeamCity", + slug: "teamcity", + path: "/integrations/app-connections/teamcity", + description: + "Learn how to connect your TeamCity to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Bitbucket", + slug: "bitbucket", + path: "/integrations/app-connections/bitbucket", + description: + "Learn how to connect your Bitbucket to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Terraform Cloud", + slug: "terraform-cloud", + path: "/integrations/app-connections/terraform-cloud", + description: + "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Cloudflare", + slug: "cloudflare", + path: "/integrations/app-connections/cloudflare", + description: + "Learn how to connect your Cloudflare application to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Databricks", + slug: "databricks", + path: "/integrations/app-connections/databricks", + description: + "Learn how to connect your Databricks to pull secrets from Infisical.", + category: "Data Analytics", + }, + { + name: "DNS Made Easy", + slug: "dns-made-easy", + path: "/integrations/app-connections/dns-made-easy", + description: "Learn how to connect Infisical to DNS Made Easy.", + category: "Networking & DNS", + }, + { + name: "Windmill", + slug: "windmill", + path: "/integrations/app-connections/windmill", + description: + "Learn how to connect your Windmill to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Camunda", + slug: "camunda", + path: "/integrations/app-connections/camunda", + description: + "Learn how to connect your Camunda to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Humanitec", + slug: "humanitec", + path: "/integrations/app-connections/humanitec", + description: + "Learn how to connect your Humanitec to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "OCI", + slug: "oci", + path: "/integrations/app-connections/oci", + description: + "Learn how to connect your OCI applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Zabbix", + slug: "zabbix", + path: "/integrations/app-connections/zabbix", + description: + "Learn how to connect your Zabbix to pull secrets from Infisical.", + category: "Monitoring", + }, + { + name: "MySQL", + slug: "mysql", + path: "/integrations/app-connections/mysql", + description: + "Learn how to connect your MySQL database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "PostgreSQL", + slug: "postgres", + path: "/integrations/app-connections/postgres", + description: + "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Microsoft SQL Server", + slug: "mssql", + path: "/integrations/app-connections/mssql", + description: + "Learn how to connect your SQL Server database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Oracle Database", + slug: "oracledb", + path: "/integrations/app-connections/oracledb", + description: + "Learn how to connect your Oracle database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Redis", + slug: "redis", + path: "/integrations/app-connections/redis", + description: "Learn how to connect Redis to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "LDAP", + slug: "ldap", + path: "/integrations/app-connections/ldap", + description: + "Learn how to connect your LDAP to pull secrets from Infisical.", + category: "Directory Services", + }, + { + name: "Auth0", + slug: "auth0", + path: "/integrations/app-connections/auth0", + description: + "Learn how to connect your Auth0 to pull secrets from Infisical.", + category: "Identity & Auth", + }, + { + name: "Okta", + slug: "okta", + path: "/integrations/app-connections/okta", + description: + "Learn how to connect your Okta to pull secrets from Infisical.", + category: "Identity & Auth", + }, + { + name: "Laravel Forge", + slug: "laravel-forge", + path: "/integrations/app-connections/laravel-forge", + description: + "Learn how to connect your Laravel Forge to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Chef", + slug: "chef", + path: "/integrations/app-connections/chef", + description: + "Learn how to connect your Chef to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Northflank", + slug: "northflank", + path: "/integrations/app-connections/northflank", + description: + "Learn how to connect your Northflank projects to pull secrets from Infisical.", + category: "Hosting", + }, + ].sort(function (a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); const filteredConnections = useMemo(() => { let filtered = connections; - - if (selectedCategory !== 'All') { - filtered = filtered.filter(connection => connection.category === selectedCategory); + + if (selectedCategory !== "All") { + filtered = filtered.filter( + (connection) => connection.category === selectedCategory + ); } if (searchTerm) { - filtered = filtered.filter(connection => - connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || - connection.description.toLowerCase().includes(searchTerm.toLowerCase()) || - connection.category.toLowerCase().includes(searchTerm.toLowerCase()) + filtered = filtered.filter( + (connection) => + connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.description + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + connection.category.toLowerCase().includes(searchTerm.toLowerCase()) ); } @@ -77,8 +395,18 @@ export const AppConnectionsBrowser = () => {
- - + +
{ {/* Category Filter */}
- {categories.map(category => ( + {categories.map((category) => (
); -}; \ No newline at end of file +}; diff --git a/frontend/index.html b/frontend/index.html index e3a051915..b1dca0a76 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,15 +8,15 @@ http-equiv="Content-Security-Policy" content=" default-src 'self'; - connect-src 'self' https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm; - script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm; - style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; + connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm; + script-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm; + style-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; - connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com; - img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; - media-src https://js.intercomcdn.com; - font-src 'self' https://fonts.intercomcdn.com/ https://fonts.gstatic.com; + connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com; + img-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; + media-src https://d1zwf0dwl0k2ky.cloudfront.net https://js.intercomcdn.com; + font-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://fonts.intercomcdn.com/ https://fonts.gstatic.com; " /> Infisical diff --git a/frontend/public/images/integrations/DNSMadeEasy.svg b/frontend/public/images/integrations/DNSMadeEasy.svg new file mode 100644 index 000000000..be77b9840 --- /dev/null +++ b/frontend/public/images/integrations/DNSMadeEasy.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index de3b99f11..a290a28a3 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -41,7 +41,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "A project with this name already exists.", - "no-mobile": " To use Infisical, please log in through a device with larger dimensions. ", "email": "Email", "password": "Password", "first-name": "First Name", @@ -290,7 +289,7 @@ } }, "project": { - "title": "Settings", + "title": "Project Settings", "description": "These settings only apply to the currently selected Project.", "danger-zone": "Danger Zone", "delete-project": "Delete Project", diff --git a/frontend/public/locales/es/translations.json b/frontend/public/locales/es/translations.json index d7ee331f4..1d217b89d 100644 --- a/frontend/public/locales/es/translations.json +++ b/frontend/public/locales/es/translations.json @@ -41,7 +41,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "Ya existe un proyecto con este nombre.", - "no-mobile": "Para usar Infisical, inicia sesión con un dispositivo de mayores dimesiones.", "email": "Correo electrónico", "password": "Contraseña", "first-name": "Nombre", diff --git a/frontend/public/locales/fr/translations.json b/frontend/public/locales/fr/translations.json index 60d5cf8cf..538b700c6 100644 --- a/frontend/public/locales/fr/translations.json +++ b/frontend/public/locales/fr/translations.json @@ -41,7 +41,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "Un projet avec ce nom existe déjà.", - "no-mobile": " Pour utiliser Infisical, veuillez vous connecter avec un appareil avec des dimensions plus grandes. ", "email": "Email", "password": "Mot de passe", "first-name": "Prénom", diff --git a/frontend/public/locales/ko/translations.json b/frontend/public/locales/ko/translations.json index e8169eaba..a9eb653cc 100644 --- a/frontend/public/locales/ko/translations.json +++ b/frontend/public/locales/ko/translations.json @@ -30,7 +30,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "동일한 이름을 가진 프로젝트가 이미 존재해요.", - "no-mobile": " Infisical을 사용하려면, 큰 화면을 가진 디바이스로 로그인하여 주세요.", "email": "메일", "password": "비밀번호", "first-name": "이름", diff --git a/frontend/public/locales/pt-BR/translations.json b/frontend/public/locales/pt-BR/translations.json index a491d3d74..9a0ab6768 100644 --- a/frontend/public/locales/pt-BR/translations.json +++ b/frontend/public/locales/pt-BR/translations.json @@ -41,7 +41,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "Já exite um projeto com este nome.", - "no-mobile": "Para usar o Infisical, faça o login através de um dispositivo com dimensões maiores.", "email": "Email", "password": "Senha", "first-name": "Primeiro Nome", diff --git a/frontend/public/locales/tr/translations.json b/frontend/public/locales/tr/translations.json index 93f228f96..5a564eb60 100644 --- a/frontend/public/locales/tr/translations.json +++ b/frontend/public/locales/tr/translations.json @@ -41,7 +41,6 @@ "common": { "head-title": "{{title}} | Infisical", "error_project-already-exists": "Bu isimle bir proje zaten mevcut.", - "no-mobile": " Infisical'ı kullanmak için, lütfen daha büyük boyutlara sahip bir cihaz üzerinden giriş yapın. ", "email": "Email", "password": "Şifre", "first-name": "Adınız", diff --git a/frontend/src/components/auth/TeamInviteStep.tsx b/frontend/src/components/auth/TeamInviteStep.tsx index ccb1e6f57..c03229a33 100644 --- a/frontend/src/components/auth/TeamInviteStep.tsx +++ b/frontend/src/components/auth/TeamInviteStep.tsx @@ -20,9 +20,14 @@ export default function TeamInviteStep(): JSX.Element { const { mutateAsync } = useAddUsersToOrg(); const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["setUpEmail"] as const); + const orgId = String(localStorage.getItem("orgData.id")); + // Redirect user to the getting started page const redirectToHome = async () => { - navigate({ to: "/organization/projects" as const }); + navigate({ + to: orgId ? ("/organizations/$orgId/projects" as const) : "/", + params: { orgId } + }); }; const inviteUsers = async ({ emails: inviteEmails }: { emails: string }) => { @@ -32,7 +37,7 @@ export default function TeamInviteStep(): JSX.Element { .map(async (email) => { mutateAsync({ inviteeEmails: [email], - organizationId: String(localStorage.getItem("orgData.id")), + organizationId: orgId, organizationRoleSlug: "member" }); }); diff --git a/frontend/src/components/navigation/NavHeader.tsx b/frontend/src/components/navigation/NavHeader.tsx index a912980f1..127b1066e 100644 --- a/frontend/src/components/navigation/NavHeader.tsx +++ b/frontend/src/components/navigation/NavHeader.tsx @@ -70,7 +70,8 @@ export default function NavHeader({ {currentOrg?.name?.charAt(0)}
{currentOrg?.name} @@ -90,8 +91,8 @@ export default function NavHeader({ {pageName === "Secrets" ? ( {pageName} @@ -126,8 +127,8 @@ export default function NavHeader({
{userAvailableEnvs?.find(({ slug }) => slug === currentEnv)?.name} @@ -188,8 +189,9 @@ export default function NavHeader({
) : ( { + const { currentOrg } = useOrganization(); const [, isCopying, setIsCopying] = useTimedReset({ initialState: false }); @@ -69,8 +71,9 @@ export const SecretDashboardPathBreadcrumb = ({
) : ( = ({ isOpen, onClose }) => }); navigate({ - to: "/organization/projects" + to: "/organizations/$orgId/projects", + params: { orgId: organization.id } }); localStorage.setItem("orgData.id", organization.id); diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts index a40398ba7..b05ea1c52 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -87,17 +87,24 @@ const shouldShowConditionalAccess = ( folderPath: string, conditionalFields: string[] ): boolean => { - return actionRuleMap.some((rule) => { + // Find all rules that apply to this environment/path + const applicableRules = actionRuleMap.filter((rule) => { const ruleConditions = rule[action]?.conditions; if (!ruleConditions) return false; - - // Check if any of the conditional fields are present - const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]); - if (!hasConditionalField) return false; - - // Check if base conditions (environment and secretPath) apply return doBaseConditionsApply(ruleConditions, environment, folderPath); }); + + // If no rules apply, don't show conditional + if (applicableRules.length === 0) return false; + + // Check if ALL applicable rules have conditional fields and if at least one rule applies without conditional fields, show full access + const allRulesHaveConditionalFields = applicableRules.every((rule) => { + const ruleConditions = rule[action]?.conditions; + if (!ruleConditions) return false; + return conditionalFields.some((field) => ruleConditions[field]); + }); + + return allRulesHaveConditionalFields; }; const determineAccessLevel = ( diff --git a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx index 0169b2296..5b38415a5 100644 --- a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx +++ b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx @@ -64,7 +64,7 @@ export const CreatePkiSyncModal = ({ "Add Sync" ) } - className="max-w-2xl" + className="max-w-3xl" bodyClassName="overflow-visible" subTitle={ selectedSync ? undefined : "Select a third-party service to sync certificates to." diff --git a/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx index db5745aad..0fe3dabe6 100644 --- a/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx +++ b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx @@ -15,11 +15,13 @@ type Props = { export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => { if (!pkiSync) return null; + const modalClassName = fields === PkiSyncEditFields.Mappings ? "max-w-4xl" : "max-w-2xl"; + return ( } - className="max-w-2xl" + className={modalClassName} bodyClassName="overflow-visible" > onOpenChange(false)} fields={fields} pkiSync={pkiSync} /> diff --git a/frontend/src/components/pki-syncs/forms/AwsSecretsManagerPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AwsSecretsManagerPkiSyncFields.tsx new file mode 100644 index 000000000..fe9e52efb --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/AwsSecretsManagerPkiSyncFields.tsx @@ -0,0 +1,50 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Select, SelectItem } from "@app/components/v2"; +import { AWS_REGIONS } from "@app/helpers/appConnections"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; + +export const AwsSecretsManagerPkiSyncFields = () => { + const { control, setValue } = useFormContext< + TPkiSyncForm & { destination: PkiSync.AwsSecretsManager } + >(); + + return ( + <> + { + setValue("destinationConfig.region", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/ChefPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/ChefPkiSyncFields.tsx new file mode 100644 index 000000000..ffd4de907 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/ChefPkiSyncFields.tsx @@ -0,0 +1,35 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; + +export const ChefPkiSyncFields = () => { + const { control, setValue } = useFormContext(); + + return ( + <> + { + setValue("destinationConfig.dataBagName", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx index 1dee6eaa7..068518a11 100644 --- a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -4,7 +4,6 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab } from "@headlessui/react"; import { zodResolver } from "@hookform/resolvers/zod"; -import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Switch } from "@app/components/v2"; @@ -16,6 +15,7 @@ import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema"; import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; @@ -26,13 +26,38 @@ type Props = { initialData?: any; }; -const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ - { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, - { name: "Sync Options", key: "options", fields: ["syncOptions"] }, - { name: "Details", key: "details", fields: ["name", "description"] }, - { name: "Certificates", key: "certificates", fields: ["certificateIds"] }, - { name: "Review", key: "review", fields: [] } -]; +const getFormTabs = ( + destination: PkiSync +): { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] => { + const baseTabs = [ + { + name: "Destination", + key: "destination", + fields: ["connection", "destinationConfig"] as (keyof TPkiSyncForm)[] + }, + { name: "Sync Options", key: "options", fields: ["syncOptions"] as (keyof TPkiSyncForm)[] } + ]; + + if (destination === PkiSync.Chef || destination === PkiSync.AwsSecretsManager) { + baseTabs.push({ + name: "Mappings", + key: "mappings", + fields: ["syncOptions"] as (keyof TPkiSyncForm)[] + }); + } + + baseTabs.push( + { name: "Details", key: "details", fields: ["name", "description"] as (keyof TPkiSyncForm)[] }, + { + name: "Certificates", + key: "certificates", + fields: ["certificateIds"] as (keyof TPkiSyncForm)[] + }, + { name: "Review", key: "review", fields: [] as (keyof TPkiSyncForm)[] } + ); + + return baseTabs; +}; export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => { const createPkiSync = useCreatePkiSync(); @@ -42,6 +67,7 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa const [showConfirmation, setShowConfirmation] = useState(false); const [selectedTabIndex, setSelectedTabIndex] = useState(0); + const FORM_TABS = getFormTabs(destination); const { syncOption } = usePkiSyncOption(destination); @@ -55,7 +81,19 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa canImportCertificates: false, canRemoveCertificates: false, preserveArn: true, - certificateNameSchema: syncOption?.defaultCertificateNameSchema + certificateNameSchema: syncOption?.defaultCertificateNameSchema, + ...((destination === PkiSync.Chef || destination === PkiSync.AwsSecretsManager) && { + fieldMappings: { + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + } + }), + ...(destination === PkiSync.AwsSecretsManager && { + preserveSecretOnRenewal: true, + updateExistingCertificates: true + }) }, ...initialData } as Partial, @@ -167,10 +205,10 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa ); return ( -
+ - + {FORM_TABS.map((tab, index) => ( { @@ -191,11 +229,11 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialDa ))} - - + + - + - + {(destination === PkiSync.Chef || destination === PkiSync.AwsSecretsManager) && ( + + + + )} + - + - + -
+
diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx index 9041d32f2..380ba039e 100644 --- a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -11,6 +11,7 @@ import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs"; import { TUpdatePkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas/pki-sync-schema"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncFieldMappingsFields } from "./PkiSyncFieldMappingsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; @@ -66,6 +67,9 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { case PkiSyncEditFields.Options: Component = ; break; + case PkiSyncEditFields.Mappings: + Component = ; + break; case PkiSyncEditFields.Source: Component = ; break; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx index e7f90670d..56514c262 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -4,7 +4,9 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs"; import { TPkiSyncForm } from "./schemas/pki-sync-schema"; import { AwsCertificateManagerPkiSyncFields } from "./AwsCertificateManagerPkiSyncFields"; +import { AwsSecretsManagerPkiSyncFields } from "./AwsSecretsManagerPkiSyncFields"; import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields"; +import { ChefPkiSyncFields } from "./ChefPkiSyncFields"; export const PkiSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -16,6 +18,10 @@ export const PkiSyncDestinationFields = () => { return ; case PkiSync.AwsCertificateManager: return ; + case PkiSync.AwsSecretsManager: + return ; + case PkiSync.Chef: + return ; default: return (
diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncFieldMappingsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncFieldMappingsFields.tsx new file mode 100644 index 000000000..922ad8e6f --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncFieldMappingsFields.tsx @@ -0,0 +1,103 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; + +type Props = { + destination?: PkiSync; +}; + +export const PkiSyncFieldMappingsFields = ({ destination }: Props) => { + const { control, watch } = useFormContext(); + const currentDestination = destination || watch("destination"); + + if (currentDestination !== PkiSync.Chef && currentDestination !== PkiSync.AwsSecretsManager) { + return null; + } + + return ( + <> +

+ Configure how certificate fields are mapped to your{" "} + {currentDestination === PkiSync.Chef ? "Chef data bag items" : "AWS secrets"}. +

+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> +
+ +
+

Preview JSON Structure

+
+          {`{
+  "id": "certificate-item-name",
+  "${watch("syncOptions.fieldMappings.certificate") || "certificate"}": "",
+  "${watch("syncOptions.fieldMappings.privateKey") || "private_key"}": "",
+  "${watch("syncOptions.fieldMappings.certificateChain") || "certificate_chain"}": "",
+  "${watch("syncOptions.fieldMappings.caCertificate") || "ca_certificate"}": ""
+}`}
+        
+
+ + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx index c1313e684..b1763d2d8 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx @@ -95,6 +95,48 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => { )} /> + ( + + +

+ Include Root CA in Certificate Chain{" "} + +

+ When enabled, the full certificate chain including the root CA will be + synced to the destination. +

+

+ When disabled, the root CA will be excluded from the certificate chain + during sync operations, reducing the size of the synced certificate chain. +

+

+ Most applications and services work correctly with intermediate certificates + only, as they can validate the trust chain up to a root CA they already + trust. +

+ + } + > + + +

+
+
+ )} + /> + {currentDestination === PkiSync.AwsCertificateManager && ( { /> )} + {currentDestination === PkiSync.AwsSecretsManager && ( + ( + + +

+ Preserve Secret on Renewal{" "} + +

+ Only applies to certificate renewals: When a certificate + is renewed in Infisical, this option controls how the renewed certificate + is handled in AWS Secrets Manager. +

+

+ When enabled, the renewed certificate will update the existing secret, + preserving the same secret name and ARN. This allows consuming services to + continue using the same secret reference without requiring updates. +

+

+ When disabled, the renewed certificate will be created as a new secret + with a new name, and the old secret will be removed. +

+ + } + > + + +

+
+
+ )} + /> + )} + + {currentDestination === PkiSync.Chef && ( + ( + + +

+ Preserve Data Bag Item on Renewal{" "} + +

+ Only applies to certificate renewals: When a certificate + is renewed in Infisical, this option controls how the renewed certificate + is handled in Chef. +

+

+ When enabled, the renewed certificate will update the existing data bag + item, preserving the same item name. This allows consuming services to + continue using the same data bag item without requiring updates to Chef + cookbooks or recipes. +

+

+ When disabled, the renewed certificate will be created as a new data bag + item with a new name, and the old item will be removed. +

+ + } + > + + +

+
+
+ )} + /> + )} + { + if (!val) return true; + + const allowedOptionalPlaceholders = [ + "{{environment}}", + "{{profileId}}", + "{{commonName}}", + "{{friendlyName}}" + ]; + + const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) + .join("|"); + + const allowedContentRegex = new RegExp( + `^([a-zA-Z0-9_\\-]|${allowedPlaceholdersRegexPart})*$` + ); + const contentIsValid = allowedContentRegex.test(val); + + if (val.trim()) { + const certificateIdRegex = /\{\{certificateId\}\}/; + const certificateIdIsPresent = certificateIdRegex.test(val); + return contentIsValid && certificateIdIsPresent; + } + + return contentIsValid; + }, + { + message: + "Certificate name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}}, {{profileId}}, {{commonName}}, or {{friendlyName}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed besides the placeholders." + } + ), + fieldMappings: AwsSecretsManagerFieldMappingsSchema.optional().default({ + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + }) +}); + +export const AwsSecretsManagerPkiSyncDestinationSchema = BasePkiSyncSchema( + AwsSecretsManagerSyncOptionsSchema +).merge( + z.object({ + destination: z.literal(PkiSync.AwsSecretsManager), + destinationConfig: z.object({ + region: z.string().min(1, "AWS region is required") + }) + }) +); + +export const UpdateAwsSecretsManagerPkiSyncDestinationSchema = + AwsSecretsManagerPkiSyncDestinationSchema.partial().merge( + z.object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .max(255, "Name must be less than 255 characters"), + destination: z.literal(PkiSync.AwsSecretsManager), + connection: z.object({ + id: z.string().uuid("Invalid connection ID format"), + name: z + .string() + .min(1, "Connection name is required") + .max(255, "Connection name must be less than 255 characters") + }) + }) + ); diff --git a/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts index 2a8f3ee53..653c0fa0e 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts @@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema"; const AzureKeyVaultSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + includeRootCa: z.boolean().default(false), enableVersioning: z.boolean().default(true), certificateNameSchema: z .string() diff --git a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts index 73da1f6af..0ff6121cf 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts @@ -6,6 +6,7 @@ export const BasePkiSyncSchema = { + if (!val) return true; + + const allowedOptionalPlaceholders = [ + "{{environment}}", + "{{profileId}}", + "{{commonName}}", + "{{friendlyName}}" + ]; + + const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) + .join("|"); + + const allowedContentRegex = new RegExp( + `^([a-zA-Z0-9_\\-]|${allowedPlaceholdersRegexPart})*$` + ); + const contentIsValid = allowedContentRegex.test(val); + + if (val.trim()) { + const certificateIdRegex = /\{\{certificateId\}\}/; + const certificateIdIsPresent = certificateIdRegex.test(val); + return contentIsValid && certificateIdIsPresent; + } + + return contentIsValid; + }, + { + message: + "Certificate item name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}}, {{profileId}}, {{commonName}}, or {{friendlyName}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), and underscores (_) are allowed besides the placeholders." + } + ), + fieldMappings: ChefFieldMappingsSchema.optional().default({ + certificate: "certificate", + privateKey: "private_key", + certificateChain: "certificate_chain", + caCertificate: "ca_certificate" + }) +}); + +export const ChefPkiSyncDestinationSchema = BasePkiSyncSchema(ChefSyncOptionsSchema).merge( + z.object({ + destination: z.literal(PkiSync.Chef), + destinationConfig: z.object({ + dataBagName: z + .string() + .min(1, "Data bag name is required") + .max(255, "Data bag name must be less than 255 characters") + .regex( + /^[a-zA-Z0-9_-]+$/, + "Data bag name can only contain alphanumeric characters, underscores, and hyphens" + ) + }) + }) +); + +export const UpdateChefPkiSyncDestinationSchema = ChefPkiSyncDestinationSchema.partial().merge( + z.object({ + name: z + .string() + .trim() + .min(1, "Name is required") + .max(255, "Name must be less than 255 characters"), + destination: z.literal(PkiSync.Chef), + connection: z.object({ + id: z.string().uuid("Invalid connection ID format"), + name: z + .string() + .min(1, "Connection name is required") + .max(255, "Connection name must be less than 255 characters") + }) + }) +); diff --git a/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts index 6efa5e24d..559fcdebc 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/pki-sync-schema.ts @@ -4,19 +4,31 @@ import { AwsCertificateManagerPkiSyncDestinationSchema, UpdateAwsCertificateManagerPkiSyncDestinationSchema } from "./aws-certificate-manager-pki-sync-destination-schema"; +import { + AwsSecretsManagerPkiSyncDestinationSchema, + UpdateAwsSecretsManagerPkiSyncDestinationSchema +} from "./aws-secrets-manager-pki-sync-destination-schema"; import { AzureKeyVaultPkiSyncDestinationSchema, UpdateAzureKeyVaultPkiSyncDestinationSchema } from "./azure-key-vault-pki-sync-destination-schema"; +import { + ChefPkiSyncDestinationSchema, + UpdateChefPkiSyncDestinationSchema +} from "./chef-pki-sync-destination-schema"; const PkiSyncUnionSchema = z.discriminatedUnion("destination", [ AzureKeyVaultPkiSyncDestinationSchema, - AwsCertificateManagerPkiSyncDestinationSchema + AwsCertificateManagerPkiSyncDestinationSchema, + AwsSecretsManagerPkiSyncDestinationSchema, + ChefPkiSyncDestinationSchema ]); const UpdatePkiSyncUnionSchema = z.discriminatedUnion("destination", [ UpdateAzureKeyVaultPkiSyncDestinationSchema, - UpdateAwsCertificateManagerPkiSyncDestinationSchema + UpdateAwsCertificateManagerPkiSyncDestinationSchema, + UpdateAwsSecretsManagerPkiSyncDestinationSchema, + UpdateChefPkiSyncDestinationSchema ]); export const PkiSyncFormSchema = PkiSyncUnionSchema; diff --git a/frontend/src/components/pki-syncs/types/index.ts b/frontend/src/components/pki-syncs/types/index.ts index 093d065ab..954be4ff2 100644 --- a/frontend/src/components/pki-syncs/types/index.ts +++ b/frontend/src/components/pki-syncs/types/index.ts @@ -1,6 +1,7 @@ export enum PkiSyncEditFields { Details = "details", Options = "options", + Mappings = "mappings", Source = "source", Destination = "destination" } diff --git a/frontend/src/components/project/ProjectOverviewChangeSection.tsx b/frontend/src/components/project/ProjectOverviewChangeSection.tsx index 455b0806a..dba917915 100644 --- a/frontend/src/components/project/ProjectOverviewChangeSection.tsx +++ b/frontend/src/components/project/ProjectOverviewChangeSection.tsx @@ -76,8 +76,8 @@ export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) = return (
-
-

Project Overview

+
+

Project Overview

-
- -

- {` ${t("common.no-mobile")} `} -

-
); diff --git a/frontend/src/layouts/AdminLayout/AdminNavBar.tsx b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx index 49cc1edf9..791a15a10 100644 --- a/frontend/src/layouts/AdminLayout/AdminNavBar.tsx +++ b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx @@ -14,6 +14,7 @@ import { Link, useMatchRoute } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Tab, TabList, Tabs, Tooltip } from "@app/components/v2"; +import { useOrganization } from "@app/context"; const generalTabs = [ { @@ -60,6 +61,7 @@ const generalTabs = [ export const AdminNavBar = () => { const matchRoute = useMatchRoute(); + const { currentOrg } = useOrganization(); return (
@@ -74,7 +76,7 @@ export const AdminNavBar = () => { - + diff --git a/frontend/src/layouts/KmsLayout/KmsLayout.tsx b/frontend/src/layouts/KmsLayout/KmsLayout.tsx index c4fdf32eb..8ffe0da44 100644 --- a/frontend/src/layouts/KmsLayout/KmsLayout.tsx +++ b/frontend/src/layouts/KmsLayout/KmsLayout.tsx @@ -2,19 +2,20 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Tab, TabList, Tabs } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { useOrganization, useProject, useProjectPermission } from "@app/context"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; export const KmsLayout = () => { const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); const { assumedPrivilegeDetails } = useProjectPermission(); const location = useLocation(); return ( -
-
+
+
{ {({ isActive }) => Overview} {({ isActive }) => KMIP} @@ -62,16 +66,18 @@ export const KmsLayout = () => { )} {({ isActive }) => Audit Logs} diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index c1cc10e41..c91917423 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -1,6 +1,3 @@ -import { useTranslation } from "react-i18next"; -import { faMobile } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Outlet, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -27,8 +24,6 @@ export const OrganizationLayout = () => { const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); - const { t } = useTranslation(); - const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; const { data: serverDetails, isLoading } = useFetchServerStatus(); @@ -38,7 +33,7 @@ export const OrganizationLayout = () => { <> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx index d55c1b5ef..1da9e8ebe 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx @@ -258,7 +258,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }; }; - const { control, handleSubmit, reset, watch, setValue, formState } = useForm< + const { control, handleSubmit, reset, watch, setValue, formState, trigger } = useForm< FormData & { preset: TemplatePresetId } >({ resolver: zodResolver(templateSchema), @@ -286,10 +286,11 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }; const watchedPreset = watch("preset") || TEMPLATE_PRESET_IDS.CUSTOM; - const handlePresetChange = (presetId: TemplatePresetId) => { + const handlePresetChange = async (presetId: TemplatePresetId) => { setValue("preset", presetId); if (presetId === TEMPLATE_PRESET_IDS.CUSTOM) { + await trigger(); return; } @@ -313,6 +314,8 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" if (selectedPreset.formData.keyAlgorithm) { setValue("keyAlgorithm", selectedPreset.formData.keyAlgorithm); } + + await trigger(); } }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx index 108fe966c..a2aa5f78c 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/TemplateList.tsx @@ -1,6 +1,17 @@ -import { faCircleInfo, faEdit, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useCallback } from "react"; +import { subject } from "@casl/ability"; +import { + faCheck, + faCircleInfo, + faCopy, + faEdit, + faEllipsis, + faTrash +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { DropdownMenu, DropdownMenuContent, @@ -17,11 +28,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { useProject } from "@app/context"; import { ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; +import { useToggle } from "@app/hooks"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; @@ -31,8 +43,8 @@ interface Props { } export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => { - const { permission } = useProjectPermission(); const { currentProject } = useProject(); + const [isIdCopied, setIsIdCopied] = useToggle(false); const { data, isLoading } = useListCertificateTemplatesV2({ projectId: currentProject?.id || "", @@ -42,20 +54,25 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => { const templates = data?.certificateTemplates || []; + const handleCopyId = useCallback( + (templateId: string) => { + setIsIdCopied.on(); + navigator.clipboard.writeText(templateId); + + createNotification({ + text: "Template ID copied to clipboard", + type: "info" + }); + + setTimeout(() => setIsIdCopied.off(), 2000); + }, + [setIsIdCopied] + ); + if (!currentProject?.id) { return null; } - const canEditTemplate = permission.can( - ProjectPermissionPkiTemplateActions.Edit, - ProjectPermissionSub.CertificateTemplates - ); - - const canDeleteTemplate = permission.can( - ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionSub.CertificateTemplates - ); - const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString(); }; @@ -110,28 +127,55 @@ export const TemplateList = ({ onEditTemplate, onDeleteTemplate }: Props) => {
- {canEditTemplate && ( - { - e.stopPropagation(); - onEditTemplate(template); - }} - icon={} - > - Edit Template - - )} - {canDeleteTemplate && ( - { - e.stopPropagation(); - onDeleteTemplate(template); - }} - icon={} - > - Delete Template - - )} + { + e.stopPropagation(); + handleCopyId(template.id); + }} + icon={} + > + Copy Template ID + + + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + onEditTemplate(template); + }} + icon={} + > + Edit Template + + ) + } + + + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + onDeleteTemplate(template); + }} + icon={} + > + Delete Template + + ) + } + diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx index 1db3b90d9..807d69bb9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PoliciesPage } from "./PoliciesPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/policies" )({ component: PoliciesPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx b/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx index b75ab2afc..81eb4e170 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx @@ -1,7 +1,10 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; @@ -15,6 +18,7 @@ const tabs = [ export const SettingsPage = () => { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); return (
@@ -22,7 +26,17 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.project.title") })}
- + + + Looking for organization settings? + + {tabs.map((tab) => ( diff --git a/frontend/src/pages/cert-manager/SettingsPage/route.tsx b/frontend/src/pages/cert-manager/SettingsPage/route.tsx index f1400f30e..59eccb028 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/route.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { SettingsPage } from "./SettingsPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/settings" )({ component: SettingsPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/layout.tsx b/frontend/src/pages/cert-manager/layout.tsx index c8ec6a23b..0462c230d 100644 --- a/frontend/src/pages/cert-manager/layout.tsx +++ b/frontend/src/pages/cert-manager/layout.tsx @@ -8,7 +8,7 @@ import { PkiManagerLayout } from "@app/layouts/PkiManagerLayout"; import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout" )({ component: PkiManagerLayout, beforeLoad: async ({ params, context }) => { diff --git a/frontend/src/pages/kms/KmipPage/route.tsx b/frontend/src/pages/kms/KmipPage/route.tsx index 662bfc32c..cd67ae9ac 100644 --- a/frontend/src/pages/kms/KmipPage/route.tsx +++ b/frontend/src/pages/kms/KmipPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { KmipPage } from "./KmipPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/kmip" )({ component: KmipPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx b/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx index be3be8286..3069772a1 100644 --- a/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx @@ -20,7 +20,7 @@ export const OverviewPage = () => {
{ diff --git a/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx b/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx index 4c3c12efa..3a0771302 100644 --- a/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx @@ -1,7 +1,10 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; @@ -16,6 +19,8 @@ const tabs = [ export const SettingsPage = () => { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + return (
@@ -24,9 +29,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + {tabs.map((tab) => ( diff --git a/frontend/src/pages/kms/SettingsPage/route.tsx b/frontend/src/pages/kms/SettingsPage/route.tsx index b47df3f86..636a3cd79 100644 --- a/frontend/src/pages/kms/SettingsPage/route.tsx +++ b/frontend/src/pages/kms/SettingsPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { SettingsPage } from "./SettingsPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/settings" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout/settings" )({ component: SettingsPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/kms/layout.tsx b/frontend/src/pages/kms/layout.tsx index f29a7627a..9e835d479 100644 --- a/frontend/src/pages/kms/layout.tsx +++ b/frontend/src/pages/kms/layout.tsx @@ -8,7 +8,7 @@ import { KmsLayout } from "@app/layouts/KmsLayout"; import { ProjectSelect } from "@app/layouts/ProjectLayout/components/ProjectSelect"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/kms/$projectId/_kms-layout" )({ component: KmsLayout, beforeLoad: async ({ params, context }) => { diff --git a/frontend/src/pages/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 03005ce05..3998dde63 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -44,7 +44,7 @@ export const Route = createFileRoute("/_authenticate")({ if ( !data.organizationId && location.pathname !== ROUTE_PATHS.Auth.PasswordSetupPage.path && - location.pathname !== "/organization/none" + location.pathname !== "/organizations/none" ) { throw redirect({ to: "/login/select-organization" }); } diff --git a/frontend/src/pages/middlewares/inject-org-details.tsx b/frontend/src/pages/middlewares/inject-org-details.tsx index d2f3e97ab..35d60a537 100644 --- a/frontend/src/pages/middlewares/inject-org-details.tsx +++ b/frontend/src/pages/middlewares/inject-org-details.tsx @@ -6,8 +6,15 @@ import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subs // Route context to fill in organization's data like details, subscription etc export const Route = createFileRoute("/_authenticate/_inject-org-details")({ - beforeLoad: async ({ context }) => { - const organizationId = context.organizationId!; + beforeLoad: async ({ context, params }) => { + let organizationId: string; + + if ((params as { orgId?: string })?.orgId) { + organizationId = (params as { orgId: string }).orgId; + } else { + organizationId = context.organizationId!; + } + await context.queryClient.ensureQueryData({ queryKey: organizationKeys.getOrgById(organizationId), queryFn: () => fetchOrganizationById(organizationId) diff --git a/frontend/src/pages/middlewares/restrict-login-signup.tsx b/frontend/src/pages/middlewares/restrict-login-signup.tsx index 7d60d9d95..ec1958d14 100644 --- a/frontend/src/pages/middlewares/restrict-login-signup.tsx +++ b/frontend/src/pages/middlewares/restrict-login-signup.tsx @@ -118,7 +118,8 @@ export const Route = createFileRoute("/_restrict-login-signup")({ throw redirect({ to: "/login/select-organization" }); } throw redirect({ - to: "/organization/projects" + to: "/organizations/$orgId/projects", + params: { orgId: data.organizationId } }); }, component: AuthConsentWrapper diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index d3e93bcea..0d5456f6d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -58,7 +58,7 @@ export const AccessManagementPage = () => { }, { key: OrgAccessControlTabSections.Identities, - label: "Identities", + label: "Machine Identities", isHidden: permission.cannot( OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity @@ -83,8 +83,8 @@ export const AccessManagementPage = () => {
{!currentOrg.shouldUseNewPrivilegeSystem && (
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index c9a0a23f4..2f66c6644 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -49,21 +49,21 @@ export const OrgGroupsSection = () => { return (
-
+
-

Groups

+

Organization Groups

{(isAllowed) => ( )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx index cc6d7c7aa..e0cd09d83 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx @@ -159,7 +159,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search groups..." + placeholder="Search organization groups..." /> @@ -205,7 +205,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { navigate({ - to: "/organization/groups/$groupId", + to: "/organizations/$orgId/groups/$groupId", params: { + orgId, groupId: id } }) @@ -334,8 +335,9 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { icon={} onClick={() => navigate({ - to: "/organization/groups/$groupId", + to: "/organizations/$orgId/groups/$groupId", params: { + orgId, groupId: id } }) diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx index 6db81f19e..6871dd3f3 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthTemplateModal.tsx @@ -154,7 +154,9 @@ export const IdentityAuthTemplateModal = ({ popUp, handlePopUpToggle }: Props) = onOpenChange={handleClose} >
-
+
-

Identities

+

+ Organization Machine Identities +

@@ -116,7 +118,7 @@ export const IdentitySection = withPermission( if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { description: - "You can add more identities if you upgrade your Infisical Pro plan." + "You can add more machine identities if you upgrade your Infisical Pro plan." }); return; } @@ -129,7 +131,9 @@ export const IdentitySection = withPermission( }} isDisabled={!isAllowed} > - Create Identity + {isSubOrganization + ? "Add Machine Identity to Sub-Organization" + : "Create Organization Machine Identity"} )} @@ -139,9 +143,11 @@ export const IdentitySection = withPermission(
{/* Identity Auth Templates Section */}
-
+
-

Identity Auth Templates

+

+ Machine Identity Auth Templates +

{(isAllowed) => (
-
Assign Existing Identity
+
Assign Existing Machine Identity
- Assign an existing identity from your parent organization. The identity will - continue to be managed at its original scope. + Assign an existing machine identity from your parent organization. The machine + identity will continue to be managed at its original scope.
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 2f8ce56c5..b94846f07 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -152,7 +152,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { }); createNotification({ - text: "Successfully updated identity role", + text: "Successfully updated machine identity role", type: "success" }); }; @@ -178,7 +178,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { { - Apply Roles to Filter Identities + Filter Organization Machine Identities by Role {roles?.map(({ id, slug, name }) => ( { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search identities by name..." + placeholder="Search machine identities by name..." />
@@ -258,7 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
- + + + + + + ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx new file mode 100644 index 000000000..d6997c6cb --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsSection.tsx @@ -0,0 +1,90 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { DeleteActionModal, IconButton } from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { useDeleteGroupFromWorkspace as useRemoveProjectFromGroup } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AddGroupProjectModal } from "../AddGroupProjectModal"; +import { GroupProjectsTable } from "./GroupProjectsTable"; + +type Props = { + groupId: string; + groupSlug: string; +}; + +export const GroupProjectsSection = ({ groupId, groupSlug }: Props) => { + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "addGroupProjects", + "removeProjectFromGroup" + ] as const); + + const { mutateAsync: removeProjectFromGroupMutateAsync } = useRemoveProjectFromGroup(); + + const handleRemoveProjectFromGroup = async (projectId: string, projectName: string) => { + await removeProjectFromGroupMutateAsync({ + groupId, + projectId + }); + + createNotification({ + text: `Successfully removed the group from project ${projectName}`, + type: "success" + }); + + handlePopUpToggle("removeProjectFromGroup", false); + }; + + return ( +
+
+

Projects

+ + {(isAllowed) => ( + { + handlePopUpOpen("addGroupProjects", { + groupId, + slug: groupSlug + }); + }} + > + + + )} + +
+
+ +
+ + handlePopUpToggle("removeProjectFromGroup", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => { + const projectData = popUp?.removeProjectFromGroup?.data as { + projectId: string; + projectName: string; + }; + + return handleRemoveProjectFromGroup(projectData.projectId, projectData.projectName); + }} + /> +
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx new file mode 100644 index 000000000..688e5a692 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/GroupProjectsTable.tsx @@ -0,0 +1,183 @@ +import { + faArrowDown, + faArrowUp, + faFolder, + faMagnifyingGlass, + faSearch +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + IconButton, + Input, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { + getUserTablePreference, + PreferenceKey, + setUserTablePreference +} from "@app/helpers/userTablePreferences"; +import { usePagination, useResetPageHelper } from "@app/hooks"; +import { useListGroupProjects } from "@app/hooks/api"; +import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { EFilterReturnedProjects } from "@app/hooks/api/groups/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { GroupProjectRow } from "./GroupProjectRow"; + +type Props = { + groupId: string; + groupSlug: string; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["removeProjectFromGroup", "addGroupProjects"]>, + data?: object + ) => void; +}; + +enum GroupProjectsOrderBy { + Name = "name" +} + +export const GroupProjectsTable = ({ groupId, groupSlug, handlePopUpOpen }: Props) => { + const { + search, + debouncedSearch, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + orderBy, + toggleOrderDirection + } = usePagination(GroupProjectsOrderBy.Name, { + initPerPage: getUserTablePreference("groupProjectsTable", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("groupProjectsTable", PreferenceKey.PerPage, newPerPage); + }; + + const { data: groupMemberships, isPending } = useListGroupProjects({ + id: groupId, + offset, + limit: perPage, + search: debouncedSearch, + orderBy, + orderDirection, + filter: EFilterReturnedProjects.ASSIGNED_PROJECTS + }); + + const totalCount = groupMemberships?.totalCount ?? 0; + const isEmpty = !isPending && totalCount === 0; + const projects = groupMemberships?.projects ?? []; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + return ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + +
- Role + Organization Role {
- Role + Organization Role { key={`identity-${id}`} onClick={() => navigate({ - to: "/organization/identities/$identityId", + to: "/organizations/$orgId/identities/$identityId", params: { - identityId: id + identityId: id, + orgId } }) } @@ -397,15 +398,16 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { onClick={(e) => { e.stopPropagation(); navigate({ - to: "/organization/identities/$identityId", + to: "/organizations/$orgId/identities/$identityId", params: { - identityId: id + identityId: id, + orgId } }); }} isDisabled={!isAllowed} > - Edit Identity {isSubOrgIdentity ? "" : "Membership"} + Edit Machine Identity {isSubOrgIdentity ? "" : "Membership"} )} @@ -426,7 +428,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { icon={} > {isSubOrgIdentity - ? "Delete Identity" + ? "Delete Machine Identity" : "Remove From Sub-Organization"} )} @@ -453,8 +455,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { 0 || filter.roles?.length > 0 - ? "No identities match search filter" - : "No identities have been created in this organization" + ? "No machine identities match search filter" + : "No machine identities have been created in this organization" } icon={faServer} /> diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx index 909391474..bf6eba63e 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/MachineAuthTemplateUsagesModal.tsx @@ -61,9 +61,10 @@ export const MachineAuthTemplateUsagesModal = ({ key={`usage-${usage.identityId}`} onClick={() => navigate({ - to: "/organization/identities/$identityId", + to: "/organizations/$orgId/identities/$identityId", params: { - identityId: usage.identityId + identityId: usage.identityId, + orgId: organizationId } }) } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx index 24406382e..9f7141802 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx @@ -64,9 +64,10 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => { type: "success" }); navigate({ - to: "/organization/identities/$identityId", + to: "/organizations/$orgId/identities/$identityId", params: { - identityId: identity.id + identityId: identity.id, + orgId: currentOrg.id } }); }; @@ -77,11 +78,11 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => { control={control} name="identity" render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.id} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx index 9c54a1e54..737c454d1 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx @@ -155,15 +155,16 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { handlePopUpToggle("identity", false); navigate({ - to: "/organization/identities/$identityId", + to: "/organizations/$orgId/identities/$identityId", params: { - identityId: createdId + identityId: createdId, + orgId } }); } createNotification({ - text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} identity`, + text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} machine identity`, type: "success" }); @@ -254,9 +255,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { />
- {i === 0 && ( - - )} + {i === 0 && } 0) { + setCompleteInviteLinks(data.completeInviteLinks); + } // only show this notification when email is configured. // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLinks) { + if (!data.completeInviteLinks?.length) { createNotification({ - text: "Successfully invited user to the organization.", + text: `Successfully invited user${usernames.length > 1 ? "s" : ""} to the organization.`, type: "success" }); } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 0b71dea25..6a4d93141 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -1,7 +1,8 @@ -import { useState } from "react"; -import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useEffect, useState } from "react"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { BanIcon } from "lucide-react"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { BanIcon, UserPlusIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -16,6 +17,7 @@ import { Tooltip } from "@app/components/v2"; import { Badge, DocumentationLinkBadge } from "@app/components/v3"; +import { ROUTE_PATHS } from "@app/const/routes"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -35,6 +37,7 @@ import { OrgMembersTable } from "./OrgMembersTable"; export const OrgMembersSection = () => { const { subscription } = useSubscription(); const { currentOrg, isSubOrganization } = useOrganization(); + const navigate = useNavigate(); const orgId = currentOrg?.id ?? ""; const { user } = useUser(); const userId = user?.id || ""; @@ -55,6 +58,22 @@ export const OrgMembersSection = () => { const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const urlAction = useSearch({ + from: ROUTE_PATHS.Organization.AccessControlPage.id, + select: (el) => el.action, + structuralSharing: true + }); + + useEffect(() => { + if (urlAction === "invite-members") { + handlePopUpOpen("addMember"); + navigate({ + to: ".", + search: ({ action, ...search }) => search + }); + } + }, [urlAction]); + const { mutateAsync: deleteMutateAsync } = useDeleteOrgMembership(); const { mutateAsync: deleteBatchMutateAsync } = useDeleteOrgMembershipBatch(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); @@ -184,23 +203,25 @@ export const OrgMembersSection = () => {
-
+
-

Users

+

Organization Users

{(isAllowed) => ( )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 66e1a3fa7..55b593ffe 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -336,7 +336,7 @@ export const OrgMembersTable = ({ - Apply Roles to Filter Users + Filter Organization Users by Role {roles?.map(({ id, slug, name }) => ( setSearch(e.target.value)} leftIcon={} - placeholder="Search members..." + placeholder="Search organization users..." />
@@ -392,7 +392,7 @@ export const OrgMembersTable = ({ }} />
+
Name
- Role + Organization Role navigate({ - to: "/organization/members/$membershipId" as const, + to: "/organizations/$orgId/members/$membershipId" as const, params: { - membershipId: orgMembershipId + membershipId: orgMembershipId, + orgId } }) } @@ -505,7 +506,7 @@ export const OrgMembersTable = ({
-
+

{name ?? Not Set}

@@ -619,9 +620,10 @@ export const OrgMembersTable = ({ onClick={(e) => { e.stopPropagation(); navigate({ - to: "/organization/members/$membershipId" as const, + to: "/organizations/$orgId/members/$membershipId" as const, params: { - membershipId: orgMembershipId + membershipId: orgMembershipId, + orgId } }); }} @@ -725,8 +727,8 @@ export const OrgMembersTable = ({ diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx index aa14ac130..63b4284f1 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -189,7 +189,7 @@ export const OrgRoleTable = () => { return (
-
+

{isSubOrganization ? "Sub-" : ""}Organization Roles @@ -199,7 +199,7 @@ export const OrgRoleTable = () => { {(isAllowed) => ( )} @@ -216,7 +216,7 @@ export const OrgRoleTable = () => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search roles..." + placeholder="Search organization roles..." className="flex-1" containerClassName="mb-4" /> @@ -280,9 +280,10 @@ export const OrgRoleTable = () => { className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" onClick={() => navigate({ - to: "/organization/roles/$roleId", + to: "/organizations/$orgId/roles/$roleId", params: { - roleId: id + roleId: id, + orgId } }) } @@ -339,9 +340,10 @@ export const OrgRoleTable = () => { onClick={(e) => { e.stopPropagation(); navigate({ - to: "/organization/roles/$roleId", + to: "/organizations/$orgId/roles/$roleId", params: { - roleId: id + roleId: id, + orgId } }); }} @@ -439,7 +441,7 @@ export const OrgRoleTable = () => {

diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index aca33ffa2..af26a940d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -24,6 +24,7 @@ import { ChefConnectionForm } from "./ChefConnectionForm"; import { CloudflareConnectionForm } from "./CloudflareConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { DigitalOceanConnectionForm } from "./DigitalOceanConnectionForm"; +import { DNSMadeEasyConnectionForm } from "./DNSMadeEasyConnectionForm"; import { FlyioConnectionForm } from "./FlyioConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; @@ -148,6 +149,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: @@ -306,6 +309,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { ); case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx new file mode 100644 index 000000000..9d3c23743 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx @@ -0,0 +1,157 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TDNSMadeEasyConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { DNSMadeEasyConnectionMethod } from "@app/hooks/api/appConnections/types/dns-made-easy-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TDNSMadeEasyConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.DNSMadeEasy) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: z.object({ + apiKey: z.string().trim().min(1, "API Key required"), + secretKey: z.string().trim().min(1, "Secret Key required") + }) + }) +]); + +type FormData = z.infer; + +export const DNSMadeEasyConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.DNSMadeEasy, + method: DNSMadeEasyConnectionMethod.APIKeySecret, + credentials: { + apiKey: "", + secretKey: "" + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + + + {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + placeholder="af1b628f-3272-46aa-9cde-837d0c59155d" + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx index 8a9f1f775..b8caa8678 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx @@ -29,7 +29,7 @@ import { Tr } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { OrgPermissionSubjects, ProjectPermissionSub } from "@app/context"; +import { OrgPermissionSubjects, ProjectPermissionSub, useOrganization } from "@app/context"; import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; @@ -52,6 +52,7 @@ export const AppConnectionRow = ({ onEditDetails, isProjectView }: Props) => { + const { currentOrg } = useOrganization(); const { id, name, method, app, description, isPlatformManagedCredentials, project } = appConnection; @@ -127,6 +128,7 @@ export const AppConnectionRow = ({ // @ts-expect-error app-connections aren't in kms/ssh to={`${getProjectBaseURL(project.type)}/app-connections`} params={{ + orgId: currentOrg?.id || "", projectId: project.id }} className="underline" diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx index 635377f2e..1af82c0c5 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx @@ -209,7 +209,7 @@ export const AppConnectionsTable = ({ projectId, projectType }: Props) => { return (
-
+

App Connections

diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/route.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/route.tsx index 86dfe4530..a036c1e89 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/route.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/route.tsx @@ -4,7 +4,7 @@ import { z } from "zod"; import { AppConnectionsPage } from "./AppConnectionsPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/app-connections/" )({ component: AppConnectionsPage, validateSearch: z.object({ diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx index 4a4dfa848..558a92091 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/route.tsx @@ -11,7 +11,7 @@ const GitHubOAuthCallbackPageQueryParamsSchema = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/app-connections/$appConnection/oauth/callback" )({ component: OAuthCallbackPage, validateSearch: zodValidator(GitHubOAuthCallbackPageQueryParamsSchema), diff --git a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx index 37d73125c..5f2290ab3 100644 --- a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx @@ -19,7 +19,7 @@ export const AuditLogsPage = () => {
diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx index 1fabac5f5..8463c303a 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsTableRow.tsx @@ -1,7 +1,8 @@ import { faCaretDown, faCaretRight } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { InfoIcon } from "lucide-react"; -import { Td, Tr } from "@app/components/v2"; +import { Td, Tooltip, Tr } from "@app/components/v2"; import { formatDateTime, Timezone } from "@app/helpers/datetime"; import { useToggle } from "@app/hooks"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; @@ -24,6 +25,15 @@ const Tag = ({ label, value }: TagProps) => {
{label}:
{value}
+ {value === "unknownUser" && ( + + + + )}
); }; diff --git a/frontend/src/pages/organization/AuditLogsPage/route.tsx b/frontend/src/pages/organization/AuditLogsPage/route.tsx index 3646b4d16..829ba6066 100644 --- a/frontend/src/pages/organization/AuditLogsPage/route.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { AuditLogsPage } from "./AuditLogsPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/audit-logs" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/audit-logs" )({ component: AuditLogsPage, context: () => ({ diff --git a/frontend/src/pages/organization/BillingPage/route.tsx b/frontend/src/pages/organization/BillingPage/route.tsx index 605a8a949..c271351c1 100644 --- a/frontend/src/pages/organization/BillingPage/route.tsx +++ b/frontend/src/pages/organization/BillingPage/route.tsx @@ -3,13 +3,14 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { BillingPage } from "./BillingPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/billing" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/billing" )({ component: BillingPage, - beforeLoad: ({ search }) => { + beforeLoad: ({ search, params }) => { if (search.subOrganization) { throw redirect({ - to: "/organization/projects", + to: "/organizations/$orgId/projects", + params: { orgId: params.orgId }, search }); } diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index e6d3736fb..8c606f07c 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -27,6 +27,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { GroupCreateUpdateModal } from "./components/GroupCreateUpdateModal"; import { GroupDetailsSection } from "./components/GroupDetailsSection"; import { GroupMembersSection } from "./components/GroupMembersSection"; +import { GroupProjectsSection } from "./components/GroupProjectsSection"; export enum TabSections { Member = "members", @@ -44,7 +45,7 @@ const Page = () => { const { data, isPending } = useGetGroupById(groupId); - const { isSubOrganization } = useOrganization(); + const { isSubOrganization, currentOrg } = useOrganization(); const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); @@ -62,7 +63,8 @@ const Page = () => { type: "success" }); navigate({ - to: "/organization/access-management" as const, + to: "/organizations/$orgId/access-management" as const, + params: { orgId: currentOrg.id }, search: { selectedTab: TabSections.Groups } @@ -78,14 +80,15 @@ const Page = () => { {data && (
- Groups + Organization Groups { -
-
+
+
- +
+ + +
)} diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx new file mode 100644 index 000000000..c7040ec54 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/AddGroupProjectModal.tsx @@ -0,0 +1,181 @@ +import { useState } from "react"; +import { faFolder, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Button, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { OrgPermissionGroupActions, OrgPermissionSubjects } from "@app/context"; +import { getProjectTitle } from "@app/helpers/project"; +import { useDebounce, useResetPageHelper } from "@app/hooks"; +import { + useAddGroupToWorkspace as useAddProjectToGroup, + useListGroupProjects +} from "@app/hooks/api"; +import { EFilterReturnedProjects } from "@app/hooks/api/groups/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["addGroupProjects"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["addGroupProjects"]>, + state?: boolean + ) => void; +}; + +export const AddGroupProjectModal = ({ popUp, handlePopUpToggle }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); + const [searchProjectFilter, setSearchProjectFilter] = useState(""); + const [debouncedSearch] = useDebounce(searchProjectFilter); + + const popUpData = popUp?.addGroupProjects?.data as { + groupId: string; + slug: string; + }; + + const offset = (page - 1) * perPage; + + const { data, isPending } = useListGroupProjects({ + id: popUpData?.groupId, + offset, + limit: perPage, + search: debouncedSearch, + filter: EFilterReturnedProjects.UNASSIGNED_PROJECTS + }); + + const { totalCount = 0 } = data ?? {}; + + useResetPageHelper({ + totalCount, + offset, + setPage + }); + + const { mutateAsync: addProjectToGroupMutateAsync, isPending: isAdding } = useAddProjectToGroup(); + + const handleAddProject = async (projectId: string, projectName: string) => { + if (!popUpData?.groupId) { + createNotification({ + text: "Some data is missing, please refresh the page and try again", + type: "error" + }); + return; + } + + await addProjectToGroupMutateAsync({ + groupId: popUpData.groupId, + projectId + }); + + createNotification({ + text: `Successfully assigned the group to project ${projectName}`, + type: "success" + }); + }; + + return ( + { + handlePopUpToggle("addGroupProjects", isOpen); + }} + > + + setSearchProjectFilter(e.target.value)} + leftIcon={} + placeholder="Search projects..." + /> + + + + + + + + + + {isPending && } + {!isPending && + data?.projects?.map((project) => { + return ( + + + + + + ); + })} + +
ProjectType +
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+

{getProjectTitle(project.type as ProjectType)}

+
+ + {(isAllowed) => { + return ( + + ); + }} + +
+ {!isPending && totalCount > 0 && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isPending && !data?.projects?.length && ( + + )} +
+
+
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx index c78b5404e..024bc54d6 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersSection.tsx @@ -46,7 +46,7 @@ export const GroupMembersSection = ({ groupId, groupSlug }: Props) => { return (
-

Group Members

+

Members

{(isAllowed) => ( , + data?: object + ) => void; +}; + +export const GroupProjectRow = ({ project, handlePopUpOpen }: Props) => { + return ( +
+

{project.name}

+
+

{getProjectTitle(project.type as ProjectType)}

+
+ +

{new Date(project.joinedGroupAt).toLocaleDateString()}

+
+
+ + + + + + + + + + {(isAllowed) => { + return ( + } + onClick={() => + handlePopUpOpen("removeProjectFromGroup", { + projectId: project.id, + projectName: project.name + }) + } + isDisabled={!isAllowed} + > + Remove group from project + + ); + }} + + + + +
+ + + + + + + + + {isPending && } + {!isPending && + projects.map((project) => { + return ( + + ); + })} + +
+
+ Name + + + +
+
TypeAdded On +
+ {!isEmpty && ( + + )} + {isEmpty && ( + + )} + {isEmpty && ( + + {(isAllowed) => ( +
+ +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx new file mode 100644 index 000000000..d61ad6124 --- /dev/null +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/GroupProjectsSection/index.tsx @@ -0,0 +1 @@ +export { GroupProjectsSection } from "./GroupProjectsSection"; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx index 003c47910..7a1d71454 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/components/index.tsx @@ -1 +1,2 @@ export { GroupDetailsSection } from "./GroupDetailsSection"; +export { GroupProjectsSection } from "./GroupProjectsSection"; diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/route.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/route.tsx index 3e6e53cb6..c98453fbc 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/route.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/route.tsx @@ -3,14 +3,17 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { GroupDetailsByIDPage } from "./GroupDetailsByIDPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/groups/$groupId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/groups/$groupId" )({ component: GroupDetailsByIDPage, - context: () => ({ + context: ({ params }) => ({ breadcrumbs: [ { label: "Access Control", - link: linkOptions({ to: "/organization/access-management" }) + link: linkOptions({ + to: "/organizations/$orgId/access-management" as const, + params + }) }, { label: "groups" diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 62f21ffa4..47428f2b8 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -55,13 +55,14 @@ const Page = () => { }); createNotification({ - text: "Successfully deleted identity", + text: "Successfully deleted machine identity", type: "success" }); handlePopUpClose("deleteIdentity"); navigate({ - to: "/organization/access-management", + to: "/organizations/$orgId/access-management" as const, + params: { orgId }, search: { selectedTab: OrgAccessControlTabSections.Identities } @@ -73,18 +74,19 @@ const Page = () => { {data && (
- Identities + Organization Machine Identities
@@ -109,15 +111,15 @@ const Page = () => { }) } > - Unlink Identity + Unlink Machine Identity )} )}
-
-
+
+
{ > diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx index b19901bef..f9bbdce9d 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx @@ -45,7 +45,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdent return data ? (
-

Identity Details

+

Details

-

Identity ID

+

Machine Identity ID

{data.identity.id}

diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx index 5c4dcd6dd..fb11c758e 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx @@ -167,7 +167,7 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle handlePopUpToggle("addIdentityToProject", isOpen); }} > - + diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx index 6bb25e2c1..fe62a7902 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx @@ -6,6 +6,7 @@ import { format } from "date-fns"; import { createNotification } from "@app/components/notifications"; import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { getProjectBaseURL } from "@app/helpers/project"; import { formatProjectRoleName } from "@app/helpers/roles"; import { useGetUserProjects } from "@app/hooks/api"; @@ -33,6 +34,7 @@ export const IdentityProjectRow = ({ }: Props) => { const { data: workspaces } = useGetUserProjects(); const navigate = useNavigate(); + const { currentOrg } = useOrganization(); const isAccessible = useMemo(() => { const workspaceIds = new Map(); @@ -53,6 +55,7 @@ export const IdentityProjectRow = ({ navigate({ to: `${getProjectBaseURL(project.type)}/access-management` as const, params: { + orgId: currentOrg?.id || "", projectId: project.id }, search: { diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 4dcc1996e..daf40c21b 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -151,7 +151,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => title={ projectMemberships.length ? "No projects match search..." - : "This identity has not been assigned to any projects" + : "This machine identity has not been assigned to any projects" } icon={projectMemberships.length ? faSearch : faFolder} /> diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx index d0d0b0fcd..5d7bf30e9 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx @@ -22,6 +22,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { CopyButton } from "@app/components/v2/CopyButton"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, @@ -153,6 +154,7 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => {
+ ({ + context: ({ params }) => ({ breadcrumbs: [ { label: "Access Control", - link: linkOptions({ to: "/organization/access-management" }) + link: linkOptions({ to: "/organizations/$orgId/access-management" as const, params }) }, { label: "Identities" diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx index fd2e9b444..8ea1807cb 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx @@ -55,10 +55,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -183,7 +183,7 @@ export const GatewayCliDeploymentMethod = () => { }; const command = useMemo(() => { - const relayPart = relay?.id !== "_auto" ? ` --relay=${relay?.name || ""}` : ""; + const relayPart = relay?.id !== "_auto" ? ` --target-relay-name=${relay?.name || ""}` : ""; return `sudo infisical gateway start --name=${name}${relayPart} --domain=${siteURL} --token=${identityToken}`; }, [name, relay, identityToken, siteURL]); @@ -275,8 +275,8 @@ export const GatewayCliDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -300,14 +300,14 @@ export const GatewayCliDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -325,15 +325,15 @@ export const GatewayCliDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx new file mode 100644 index 000000000..1603f3bdd --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx @@ -0,0 +1,389 @@ +import { useMemo, useState } from "react"; +import { SingleValue } from "react-select"; +import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FilterableSelect, + FormLabel, + IconButton, + Input, + ModalClose, + Tooltip +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission +} from "@app/context"; +import { + useAddIdentityTokenAuth, + useCreateTokenIdentityTokenAuth, + useGetIdentityMembershipOrgs, + useGetIdentityTokenAuth, + useGetRelays +} from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +import { RelayOption } from "./RelayOption"; + +const baseFormSchema = z.object({ + name: slugSchema({ field: "name" }), + relay: z + .object( + { + id: z.string(), + name: z.string() + }, + { required_error: "Relay is required" } + ) + .nullable() + .refine((val) => val !== null, { message: "Relay is required" }) +}); + +const formSchemaWithIdentity = baseFormSchema.extend({ + identity: z + .object( + { + id: z.string(), + name: z.string() + }, + { required_error: "Machine identity is required" } + ) + .nullable() + .refine((val) => val !== null, { message: "Machine identity is required" }) +}); + +const formSchemaWithToken = baseFormSchema.extend({ + identityToken: z.string().min(1, "Token is required") +}); + +export const GatewayCliSystemdDeploymentMethod = () => { + const { protocol, hostname, port } = window.location; + const portSuffix = port && port !== "80" ? `:${port}` : ""; + const siteURL = `${protocol}//${hostname}${portSuffix}`; + + const navigate = useNavigate({ + from: ROUTE_PATHS.Organization.NetworkingPage.path + }); + + const [autogenerateToken, setAutogenerateToken] = useState(true); + const [step, setStep] = useState<"form" | "command">("form"); + const [name, setName] = useState(""); + const [relay, setRelay] = useState({ id: "_auto", name: "Auto Select Relay" }); + const [identity, setIdentity] = useState(null); + const [identityToken, setIdentityToken] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const errors = useMemo(() => { + const errorMap: Record = {}; + formErrors.forEach((issue) => { + if (issue.path.length > 0) { + errorMap[String(issue.path[0])] = issue.message; + } + }); + return errorMap; + }, [formErrors]); + + const { data: relays, isPending: isRelaysLoading } = useGetRelays(); + + const { currentOrg } = useOrganization(); + const organizationId = currentOrg?.id || ""; + + const { permission } = useOrgPermission(); + const canCreateToken = permission.can( + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ); + + const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = + useGetIdentityMembershipOrgs({ + organizationId, + limit: 20000 + }); + const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; + + const { mutateAsync: createToken, isPending: isCreatingToken } = + useCreateTokenIdentityTokenAuth(); + const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = + useAddIdentityTokenAuth(); + const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); + + const handleGenerateCommand = async () => { + setFormErrors([]); + + if (canCreateToken && autogenerateToken) { + const validation = formSchemaWithIdentity.safeParse({ + name, + relay, + identity + }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + const validatedIdentity = validation.data.identity; + + try { + const { data: identityTokenAuth } = await refetch(); + if (!identityTokenAuth) { + await addIdentityTokenAuth({ + identityId: validatedIdentity.id, + organizationId, + accessTokenTTL: 2592000, + accessTokenMaxTTL: 2592000, + accessTokenNumUsesLimit: 0, + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + createNotification({ + text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", + type: "warning" + }); + } + + const token = await createToken({ + identityId: validatedIdentity.id, + name: `gateway token for ${name} (autogenerated)` + }); + setIdentityToken(token.accessToken); + createNotification({ + text: "Automatically generated a token for the selected identity.", + type: "info" + }); + setStep("command"); + } catch { + setIdentityToken(""); + } + } else { + const validation = formSchemaWithToken.safeParse({ + name, + relay, + identityToken + }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + setStep("command"); + } + }; + + const installCommand = useMemo(() => { + const relayPart = relay?.id !== "_auto" ? ` --target-relay-name=${relay?.name || ""}` : ""; + return `sudo infisical gateway systemd install --name=${name}${relayPart} --domain=${siteURL} --token=${identityToken}`; + }, [name, relay, identityToken, siteURL]); + + const startServiceCommand = "sudo systemctl start infisical-gateway"; + + if (step === "command") { + return ( + <> + +
+ + { + navigator.clipboard.writeText(installCommand); + createNotification({ + text: "Installation command copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+ + +
+ + { + navigator.clipboard.writeText(startServiceCommand); + createNotification({ + text: "Start service command copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+ + Install the Infisical CLI + + +
+ + + +
+ + ); + } + + return ( + <> + + setName(e.target.value)} + placeholder="Enter gateway name..." + isError={Boolean(errors.name)} + /> + {errors.name &&

{errors.name}

} + + + { + if ((newValue as SingleValue<{ id: string }>)?.id === "_create") { + navigate({ + search: (prev) => ({ ...prev, selectedTab: "relays", action: "deploy-relay" }) + }); + return; + } + + setRelay(newValue as SingleValue<{ id: string; name: string }>); + }} + isLoading={isRelaysLoading} + options={[ + { + id: "_auto", + name: "Auto Select Relay" + }, + { + id: "_create", + name: "Deploy New Relay" + }, + ...(relays || []) + ]} + placeholder="Select relay..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + components={{ Option: RelayOption }} + /> + {errors.relay &&

{errors.relay}

} + + {canCreateToken && autogenerateToken ? ( + <> + + + setIdentity( + e as SingleValue<{ + id: string; + name: string; + }> + ) + } + isLoading={isIdentitiesLoading} + placeholder="Select machine identity..." + options={identityMembershipOrgs.map((membership) => membership.identity)} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + {errors.identity &&

{errors.identity}

} + + ) : ( + <> + + setIdentityToken(e.target.value)} + placeholder="Enter machine identity token..." + isError={Boolean(errors.identityToken)} + /> + {errors.identityToken &&

{errors.identityToken}

} + + )} + + {canCreateToken && ( +
+ { + setAutogenerateToken(Boolean(e)); + }} + id="autogenerate-token" + className="mr-2" + > +
+ Automatically enable token auth and generate a token for machine identity + + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control. +
+
A token will automatically be generated to be used with the CLI command. + + } + > + +
+
+
+
+ )} + +
+ + + + +
+ + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx index 170ea2eab..dacd08e74 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx @@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { GatewayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect"; import { GatewayCliDeploymentMethod } from "./GatewayCliDeploymentMethod"; +import { GatewayCliSystemdDeploymentMethod } from "./GatewayCliSystemdDeploymentMethod"; type Props = { isOpen: boolean; @@ -11,7 +12,8 @@ type Props = { }; export const GatewayDeploymentInfoMap = { - cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod } + cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod }, + systemd: { name: "CLI (systemd)", image: "SSH.png", component: GatewayCliSystemdDeploymentMethod } } as const; export type GatewayDeploymentMethod = keyof typeof GatewayDeploymentInfoMap; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx index 86aab570d..ce8e988c0 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx @@ -41,10 +41,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -156,15 +156,6 @@ export const RelayCliDeploymentMethod = () => { } }; - const handleIdentityChange = ( - selectedIdentity: SingleValue<{ - id: string; - name: string; - }> - ) => { - setIdentity(selectedIdentity); - }; - const command = useMemo(() => { return `infisical relay start --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`; }, [name, siteURL, host, identityToken]); @@ -238,14 +229,14 @@ export const RelayCliDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> - handleIdentityChange( + setIdentity( e as SingleValue<{ id: string; name: string; @@ -253,7 +244,7 @@ export const RelayCliDeploymentMethod = () => { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -263,14 +254,14 @@ export const RelayCliDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -288,15 +279,15 @@ export const RelayCliDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx new file mode 100644 index 000000000..635e07eba --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx @@ -0,0 +1,362 @@ +import { useMemo, useState } from "react"; +import { SingleValue } from "react-select"; +import { faCopy, faQuestionCircle, faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FilterableSelect, + FormLabel, + IconButton, + Input, + ModalClose, + Tooltip +} from "@app/components/v2"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission +} from "@app/context"; +import { + useAddIdentityTokenAuth, + useCreateTokenIdentityTokenAuth, + useGetIdentityMembershipOrgs, + useGetIdentityTokenAuth +} from "@app/hooks/api"; +import { slugSchema } from "@app/lib/schemas"; + +const baseFormSchema = z.object({ + name: slugSchema({ field: "name" }), + host: z.string().min(1, "Host is required") +}); + +const formSchemaWithIdentity = baseFormSchema.extend({ + identity: z + .object( + { + id: z.string(), + name: z.string() + }, + { required_error: "Machine identity is required" } + ) + .nullable() + .refine((val) => val !== null, { message: "Machine identity is required" }) +}); + +const formSchemaWithToken = baseFormSchema.extend({ + identityToken: z.string().min(1, "Token is required") +}); + +export const RelayCliSystemdDeploymentMethod = () => { + const { protocol, hostname, port } = window.location; + const portSuffix = port && port !== "80" ? `:${port}` : ""; + const siteURL = `${protocol}//${hostname}${portSuffix}`; + + const [autogenerateToken, setAutogenerateToken] = useState(true); + const [step, setStep] = useState<"form" | "command">("form"); + const [name, setName] = useState(""); + const [host, setHost] = useState(""); + + const [identity, setIdentity] = useState(null); + const [identityToken, setIdentityToken] = useState(""); + const [formErrors, setFormErrors] = useState([]); + + const errors = useMemo(() => { + const errorMap: Record = {}; + formErrors.forEach((issue) => { + if (issue.path.length > 0) { + errorMap[String(issue.path[0])] = issue.message; + } + }); + return errorMap; + }, [formErrors]); + + const { currentOrg } = useOrganization(); + const organizationId = currentOrg?.id || ""; + + const { permission } = useOrgPermission(); + const canCreateToken = permission.can( + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ); + + const { data: identityMembershipOrgsData, isPending: isIdentitiesLoading } = + useGetIdentityMembershipOrgs({ + organizationId, + limit: 20000 + }); + const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships || []; + + const { mutateAsync: createToken, isPending: isCreatingToken } = + useCreateTokenIdentityTokenAuth(); + const { mutateAsync: addIdentityTokenAuth, isPending: isAddingTokenAuth } = + useAddIdentityTokenAuth(); + const { refetch } = useGetIdentityTokenAuth(identity?.id ?? ""); + + const handleGenerateCommand = async () => { + setFormErrors([]); + + if (canCreateToken && autogenerateToken) { + const validation = formSchemaWithIdentity.safeParse({ name, host, identity }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + + const validatedIdentity = validation.data.identity; + + try { + const { data: identityTokenAuth } = await refetch(); + if (!identityTokenAuth) { + await addIdentityTokenAuth({ + identityId: validatedIdentity.id, + organizationId, + accessTokenTTL: 2592000, + accessTokenMaxTTL: 2592000, + accessTokenNumUsesLimit: 0, + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }] + }); + createNotification({ + text: "Token authentication has been automatically enabled for the selected identity. By default, it is configured to allow all IP addresses with a default token TTL of 30 days. You can manage these settings in Access Control.", + type: "warning" + }); + } + + const token = await createToken({ + identityId: validatedIdentity.id, + name: `relay token for ${name} (autogenerated)` + }); + setIdentityToken(token.accessToken); + createNotification({ + text: "Automatically generated a token for the selected identity.", + type: "info" + }); + setStep("command"); + } catch { + setIdentityToken(""); + } + } else { + const validation = formSchemaWithToken.safeParse({ + name, + host, + identityToken + }); + if (!validation.success) { + setFormErrors(validation.error.issues); + return; + } + setStep("command"); + } + }; + + const installCommand = useMemo(() => { + return `sudo infisical relay systemd install --name=${name} --domain=${siteURL} --host=${host} --token=${identityToken}`; + }, [name, siteURL, host, identityToken]); + + const startServiceCommand = "sudo systemctl start infisical-relay"; + const enableServiceCommand = "sudo systemctl enable infisical-relay"; + + if (step === "command") { + return ( + <> + +
+ + { + navigator.clipboard.writeText(installCommand); + createNotification({ + text: "Installation command copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+ + +
+ + { + navigator.clipboard.writeText(startServiceCommand); + createNotification({ + text: "Start service command copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+
+ + { + navigator.clipboard.writeText(enableServiceCommand); + createNotification({ + text: "Enable service command copied to clipboard", + type: "info" + }); + }} + className="w-10" + > + + +
+ + Install the Infisical CLI + + +
+ + + +
+ + ); + } + + return ( + <> + + setName(e.target.value)} + placeholder="Enter relay name..." + isError={Boolean(errors.name)} + /> + {errors.name &&

{errors.name}

} + + + setHost(e.target.value)} + placeholder="0.0.0.0" + isError={Boolean(errors.host)} + /> + {errors.host &&

{errors.host}

} + + {canCreateToken && autogenerateToken ? ( + <> + + + setIdentity( + e as SingleValue<{ + id: string; + name: string; + }> + ) + } + isLoading={isIdentitiesLoading} + placeholder="Select machine identity..." + options={identityMembershipOrgs.map((membership) => membership.identity)} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + {errors.identity &&

{errors.identity}

} + + ) : ( + <> + + setIdentityToken(e.target.value)} + placeholder="Enter machine identity token..." + isError={Boolean(errors.identityToken)} + /> + {errors.identityToken &&

{errors.identityToken}

} + + )} + + {canCreateToken && ( +
+ { + setAutogenerateToken(Boolean(e)); + }} + id="autogenerate-token" + className="mr-2" + > +
+ Automatically enable token auth and generate a token for machine identity + + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control. +
+
A token will automatically be generated to be used with the CLI command. + + } + > + +
+
+
+
+ )} + +
+ + + + +
+ + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx index aab599925..ab4a58a32 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx @@ -4,6 +4,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect"; import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod"; +import { RelayCliSystemdDeploymentMethod } from "./RelayCliSystemdDeploymentMethod"; import { RelayTerraformDeploymentMethod } from "./RelayTerraformDeploymentMethod"; type Props = { @@ -13,6 +14,7 @@ type Props = { export const RelayDeploymentInfoMap = { cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod }, + systemd: { name: "CLI (systemd)", image: "SSH.png", component: RelayCliSystemdDeploymentMethod }, terraform: { name: "Terraform", image: "Terraform.png", diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx index a8e1693a5..301930741 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx @@ -42,10 +42,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -190,15 +190,6 @@ export const RelayTerraformDeploymentMethod = () => { } }; - const handleIdentityChange = ( - selectedIdentity: SingleValue<{ - id: string; - name: string; - }> - ) => { - setIdentity(selectedIdentity); - }; - const terraformCommand = useMemo(() => { return `terraform { required_providers { @@ -358,14 +349,14 @@ resource "aws_eip_association" "eip_assoc" { {canCreateToken && autogenerateToken ? ( <> - handleIdentityChange( + setIdentity( e as SingleValue<{ id: string; name: string; @@ -373,7 +364,7 @@ resource "aws_eip_association" "eip_assoc" { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -383,14 +374,14 @@ resource "aws_eip_association" "eip_assoc" { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -408,15 +399,15 @@ resource "aws_eip_association" "eip_assoc" { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/route.tsx b/frontend/src/pages/organization/NetworkingPage/route.tsx index fb81e3725..09b089ba7 100644 --- a/frontend/src/pages/organization/NetworkingPage/route.tsx +++ b/frontend/src/pages/organization/NetworkingPage/route.tsx @@ -10,7 +10,7 @@ const NetworkingPageQueryParams = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/networking" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/networking" )({ component: NetworkingPage, validateSearch: zodValidator(NetworkingPageQueryParams), diff --git a/frontend/src/pages/organization/NoOrgPage/route.tsx b/frontend/src/pages/organization/NoOrgPage/route.tsx index 5d34fed93..597fcc931 100644 --- a/frontend/src/pages/organization/NoOrgPage/route.tsx +++ b/frontend/src/pages/organization/NoOrgPage/route.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { NoOrgPage } from "./NoOrgPage"; -export const Route = createFileRoute("/_authenticate/organization/none")({ +export const Route = createFileRoute("/_authenticate/organizations/none")({ component: NoOrgPage }); diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index aba2e752d..c0117b758 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Outlet, useMatches } from "@tanstack/react-router"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { NewProjectModal } from "@app/components/projects"; @@ -27,6 +28,17 @@ import { ProjectListView } from "./components/ProjectListToggle"; export const ProjectsPage = () => { const { t } = useTranslation(); + const matches = useMatches(); + + const hasChildRoute = matches.some( + (match) => + match.pathname.includes("/secret-management/") || + match.pathname.includes("/cert-management/") || + match.pathname.includes("/kms/") || + match.pathname.includes("/pam/") || + match.pathname.includes("/ssh/") || + match.pathname.includes("/secret-scanning/") + ); const [projectListView, setProjectListView] = useState(() => { const storedView = localStorage.getItem("projectListView"); @@ -57,6 +69,10 @@ export const ProjectsPage = () => { ? subscription.workspacesUsed < subscription.workspaceLimit : true; + if (hasChildRoute) { + return ; + } + return (
@@ -65,7 +81,7 @@ export const ProjectsPage = () => { {projectListView === ProjectListView.MyProjects ? ( diff --git a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx index a1bd8c9e4..d775f256a 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx @@ -30,7 +30,7 @@ import { Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { OrgPermissionAdminConsoleAction } from "@app/context/OrgPermissionContext/types"; import { getProjectHomePage, getProjectLottieIcon, getProjectTitle } from "@app/helpers/project"; import { @@ -62,6 +62,7 @@ export const AllProjectView = ({ onProjectListViewChange }: Props) => { const navigate = useNavigate(); + const { currentOrg } = useOrganization(); const [searchFilter, setSearchFilter] = useState(""); const [debouncedSearch] = useDebounce(searchFilter); const [projectTypeFilter, setProjectTypeFilter] = useState(); @@ -100,7 +101,8 @@ export const AllProjectView = ({ const handleAccessProject = async ( type: ProjectType, projectId: string, - environments: ProjectEnv[] + environments: ProjectEnv[], + orgId: string ) => { await orgAdminAccessProject.mutateAsync({ projectId @@ -108,6 +110,7 @@ export const AllProjectView = ({ await navigate({ to: getProjectHomePage(type, environments), params: { + orgId, projectId } }); @@ -125,7 +128,7 @@ export const AllProjectView = ({ return (
-
+
{ e.stopPropagation(); e.preventDefault(); - handleAccessProject(workspace.type, workspace.id, workspace.environments); + handleAccessProject( + workspace.type, + workspace.id, + workspace.environments, + workspace.orgId + ); }} disabled={ orgAdminAccessProject.variables?.projectId === workspace.id && diff --git a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx index 32301923b..c59ecf57a 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/MyProjectView.tsx @@ -180,6 +180,7 @@ export const MyProjectView = ({ navigate({ to: getProjectHomePage(workspace.type, workspace.environments), params: { + orgId: currentOrg?.id || "", projectId: workspace.id } }); @@ -231,6 +232,7 @@ export const MyProjectView = ({ navigate({ to: getProjectHomePage(workspace.type, workspace.environments), params: { + orgId: currentOrg?.id || "", projectId: workspace.id } }); @@ -360,7 +362,7 @@ export const MyProjectView = ({ return (
-
+
({ diff --git a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx index 78c5393a7..ff3594ef3 100644 --- a/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/RoleByIDPage.tsx @@ -56,7 +56,8 @@ export const Page = () => { handlePopUpClose("deleteOrgRole"); navigate({ - to: "/organization/access-management" as const, + to: "/organizations/$orgId/access-management" as const, + params: { orgId }, search: { selectedTab: OrgAccessControlTabSections.Roles } @@ -70,7 +71,8 @@ export const Page = () => { {data && (
{ }); navigate({ - to: "/organization/roles/$roleId", + to: "/organizations/$orgId/roles/$roleId" as const, params: { + orgId: role.orgId, roleId: newRole.id } }); diff --git a/frontend/src/pages/organization/RoleByIDPage/components/RoleModal.tsx b/frontend/src/pages/organization/RoleByIDPage/components/RoleModal.tsx index 73da4f161..f47d5ed2c 100644 --- a/frontend/src/pages/organization/RoleByIDPage/components/RoleModal.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/components/RoleModal.tsx @@ -97,8 +97,9 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { handlePopUpToggle("role", false); navigate({ - to: "/organization/roles/$roleId", + to: "/organizations/$orgId/roles/$roleId" as const, params: { + orgId, roleId: newRole.id } }); diff --git a/frontend/src/pages/organization/RoleByIDPage/route.tsx b/frontend/src/pages/organization/RoleByIDPage/route.tsx index c9036c500..94664fb51 100644 --- a/frontend/src/pages/organization/RoleByIDPage/route.tsx +++ b/frontend/src/pages/organization/RoleByIDPage/route.tsx @@ -3,14 +3,17 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { RoleByIDPage } from "./RoleByIDPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/roles/$roleId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/roles/$roleId" )({ component: RoleByIDPage, - context: () => ({ + context: ({ params }) => ({ breadcrumbs: [ { label: "Access Control", - link: linkOptions({ to: "/organization/access-management" }) + link: linkOptions({ + to: "/organizations/$orgId/access-management", + params: { orgId: params.orgId } + }) }, { label: "Roles" diff --git a/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx b/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx index 134e341a6..7b8b16860 100644 --- a/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/ShareSecretSection.tsx @@ -17,7 +17,7 @@ enum SecretSharingPageTabs { export const ShareSecretSection = () => { const navigate = useNavigate(); - const { isSubOrganization } = useOrganization(); + const { isSubOrganization, currentOrg } = useOrganization(); const { selectedTab } = useSearch({ from: ROUTE_PATHS.Organization.SecretSharing.id @@ -26,6 +26,7 @@ export const ShareSecretSection = () => { const updateSelectedTab = (tab: string) => { navigate({ to: ROUTE_PATHS.Organization.SecretSharing.path, + params: { orgId: currentOrg.id }, search: (prev) => ({ ...prev, selectedTab: tab as SecretSharingPageTabs }) }); }; diff --git a/frontend/src/pages/organization/SecretSharingPage/route.tsx b/frontend/src/pages/organization/SecretSharingPage/route.tsx index 728fce0b6..05476a117 100644 --- a/frontend/src/pages/organization/SecretSharingPage/route.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/route.tsx @@ -9,7 +9,7 @@ const SecretSharingQueryParams = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/secret-sharing/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/secret-sharing/" )({ component: SecretSharingPage, diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx index 5efec93d9..054455e05 100644 --- a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/OauthCallbackPage.tsx @@ -57,7 +57,8 @@ export const OAuthCallbackPage = () => { }); navigate({ - to: ROUTE_PATHS.Organization.SettingsPage.path + to: ROUTE_PATHS.Organization.SettingsPage.path, + params: { orgId: currentOrg.id } }); }, []); diff --git a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx index 8c6af10f3..5e479303e 100644 --- a/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx +++ b/frontend/src/pages/organization/SettingsPage/OauthCallbackPage/route.tsx @@ -20,7 +20,7 @@ const SettingsOAuthCallbackPageQueryParamsSchema = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/settings/oauth/callback" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/oauth/callback" )({ component: OAuthCallbackPage, validateSearch: zodValidator(SettingsOAuthCallbackPageQueryParamsSchema) diff --git a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx index b3a7c1043..cec33b289 100644 --- a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx @@ -20,7 +20,7 @@ export const SettingsPage = () => {
diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx index 34f1937b5..8a793536c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/ExternalMigrationsTab.tsx @@ -37,7 +37,7 @@ export const ExternalMigrationsTab = () => {

-
+

diff --git a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx index e802e250b..070d9929d 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ExternalMigrationsTab/components/VaultConnectionSection.tsx @@ -17,6 +17,7 @@ import { THead, Tr } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { useListAppConnections } from "@app/hooks/api/appConnections/queries"; import { useDeleteVaultExternalMigrationConfig, @@ -33,6 +34,8 @@ export const VaultConnectionSection = () => { const [configToDelete, setConfigToDelete] = useState(null); const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs(); + + const { currentOrg } = useOrganization(); const { data: appConnections = [] } = useListAppConnections(); const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig(); @@ -71,7 +74,7 @@ export const VaultConnectionSection = () => { return (

-
+
{ Configure namespace-specific connections to enable in-platform migration features. Manage connections in the{" "} App Connections diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx index 251cebcbe..fe063d88a 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx @@ -11,7 +11,8 @@ import { AddExternalKmsType, ExternalKmsProvider, Kms, - KmsAwsCredentialType + KmsAwsCredentialType, + UpdateExternalKmsSchema } from "@app/hooks/api/kms/types"; const AWS_REGIONS = [ @@ -50,9 +51,12 @@ type Props = { onCompleted: () => void; onCancel: () => void; kms?: Kms; + mode?: "full" | "credentials" | "details"; }; -export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { +export const AwsKmsForm = ({ onCompleted, onCancel, kms, mode = "full" }: Props) => { + const validationSchema = kms ? UpdateExternalKmsSchema : AddExternalKmsSchema; + const { control, handleSubmit, @@ -60,24 +64,35 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { setValue, formState: { isSubmitting } } = useForm({ - resolver: zodResolver(AddExternalKmsSchema), + resolver: zodResolver(validationSchema), defaultValues: { name: kms?.name, description: kms?.description ?? "", - provider: { + configuration: { type: ExternalKmsProvider.Aws, inputs: { - credential: { - type: kms?.external?.providerInput?.credential?.type, - data: { - accessKey: kms?.external?.providerInput?.credential?.data?.accessKey, - secretKey: kms?.external?.providerInput?.credential?.data?.secretKey, - assumeRoleArn: kms?.external?.providerInput?.credential?.data?.assumeRoleArn, - externalId: kms?.external?.providerInput?.credential?.data?.externalId - } - }, - awsRegion: kms?.external?.providerInput?.awsRegion, - kmsKeyId: kms?.external?.providerInput?.kmsKeyId + ...(mode !== "details" && + kms?.externalKms?.configuration?.credential?.type && + kms.externalKms.configuration.credential.data + ? { + credential: { + type: kms.externalKms.configuration.credential.type, + data: { + accessKey: kms.externalKms.configuration.credential.data?.accessKey ?? "", + secretKey: kms.externalKms.configuration.credential.data?.secretKey ?? "", + assumeRoleArn: + kms.externalKms.configuration.credential.data?.assumeRoleArn ?? "", + externalId: kms.externalKms.configuration.credential.data?.externalId ?? "" + } + } + } + : {}), + ...(mode !== "credentials" + ? { + awsRegion: kms?.externalKms?.configuration?.awsRegion ?? "", + kmsKeyId: kms?.externalKms?.configuration?.kmsKeyId ?? "" + } + : {}) } } } @@ -85,30 +100,59 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { const { currentOrg } = useOrganization(); const { mutateAsync: addAwsExternalKms } = useAddExternalKms(currentOrg.id); - const { mutateAsync: updateAwsExternalKms } = useUpdateExternalKms(currentOrg.id); + const { mutateAsync: updateAwsExternalKms } = useUpdateExternalKms( + currentOrg.id, + ExternalKmsProvider.Aws + ); - const selectedAwsAuthType = watch("provider.inputs.credential.type"); + const selectedAwsAuthType = watch("configuration.inputs.credential.type"); const handleAwsKmsFormSubmit = async (data: AddExternalKmsType) => { - const { name, description, provider } = data; + const { name, description, configuration } = data; try { if (kms) { - await updateAwsExternalKms({ - kmsId: kms.id, - name, - description, - provider - }); + if (configuration.type !== ExternalKmsProvider.Aws) { + throw new Error("Invalid configuration type"); + } + const awsInputs = configuration.inputs; + + if (mode === "credentials") { + await updateAwsExternalKms({ + kmsId: kms.id, + configuration: { + type: ExternalKmsProvider.Aws, + inputs: { + credential: { ...awsInputs.credential } + } + } + }); + } else { + await updateAwsExternalKms({ + kmsId: kms.id, + name, + description, + configuration: { + type: ExternalKmsProvider.Aws, + inputs: { + awsRegion: awsInputs.awsRegion, + kmsKeyId: awsInputs.kmsKeyId + } + } + }); + } createNotification({ - text: "Successfully updated AWS External KMS", + text: + mode === "credentials" + ? "Successfully updated AWS External KMS credentials" + : "Successfully updated AWS External KMS Details", type: "success" }); } else { await addAwsExternalKms({ name, description, - provider + configuration }); createNotification({ @@ -125,104 +169,149 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { return (
- ( - - - - )} - /> - ( - - - - )} - /> - ( - - - - )} - /> - - {selectedAwsAuthType === KmsAwsCredentialType.AccessKey ? ( + {(mode === "full" || mode === "details") && ( <> ( - + )} /> ( - - + + )} /> - ) : ( + )} + {(mode === "full" || mode === "credentials") && ( <> ( + name="configuration.inputs.credential.type" + defaultValue={KmsAwsCredentialType.AssumeRole} + render={({ field: { onChange, ...field }, fieldState: { error } }) => ( - + + + )} + /> + + {selectedAwsAuthType === KmsAwsCredentialType.AccessKey ? ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + ) : ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} + + )} + {(mode === "full" || mode === "details") && ( + <> + ( + + )} /> ( @@ -232,38 +321,9 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { /> )} - ( - - - - )} - /> - ( - - - - )} - />
); -export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { +export const GcpKmsForm = ({ onCompleted, onCancel, kms, mode = "full" }: Props) => { const [isCredentialValid, setIsCredentialValid] = useState(false); const [keys, setKeys] = useState<{ value: string; label: string }[]>([]); @@ -88,7 +89,7 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { getValues, resetField, setValue, - formState: { isSubmitting } + formState: { isSubmitting, isDirty, isValid } } = useForm({ resolver: zodResolver(AddExternalKmsGcpFormSchema), defaultValues: { @@ -98,9 +99,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { gcpRegion: kms ? { label: - GCP_REGIONS.find((r) => r.value === kms.external.providerInput.gcpRegion)?.label ?? + GCP_REGIONS.find((r) => r.value === kms.externalKms.configuration.gcpRegion)?.label ?? "", - value: kms.external.providerInput.gcpRegion + value: kms.externalKms.configuration.gcpRegion } : undefined, keyObject: undefined @@ -109,7 +110,11 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { const { currentOrg } = useOrganization(); const { mutateAsync: addGcpExternalKms } = useAddExternalKms(currentOrg.id); - const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(currentOrg.id); + const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms( + currentOrg.id, + ExternalKmsProvider.Gcp + ); + const { mutateAsync: fetchGcpKeys, isPending: isFetchGcpKeysLoading } = useExternalKmsFetchGcpKeys(currentOrg?.id); @@ -140,36 +145,62 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { // handles the form submission const handleGcpKmsFormSubmit = async (data: AddExternalKmsGcpFormSchemaType) => { - const { name, description, gcpRegion: gcpRegionObject, keyObject } = data; - const gcpRegion = gcpRegionObject.value; - if (!keys.find((k) => k.value === keyObject?.value)) { - setError("keyObject", { - message: "Please select a valid key." - }); - resetField("keyObject"); - return; - } + const { name, description, formType, gcpRegion: gcpRegionObject, keyObject } = data; try { if (kms) { - await updateGcpExternalKms({ - kmsId: kms.id, - name, - description, - provider: { - type: ExternalKmsProvider.Gcp, - inputs: { - gcpRegion, - keyName: keyObject?.value - } + if (formType === "updateGcpKms") { + const gcpRegion = gcpRegionObject?.value; + if (!gcpRegion) { + setError("gcpRegion", { + message: "Please select a GCP region." + }); + return; } - }); - createNotification({ - text: "Successfully updated GCP External KMS", - type: "success" - }); - } else { + if (keyObject && !keys.find((k) => k.value === keyObject.value)) { + setError("keyObject", { + message: "Please select a valid key." + }); + resetField("keyObject"); + return; + } + + await updateGcpExternalKms({ + kmsId: kms.id, + name, + description, + configuration: { + type: ExternalKmsProvider.Gcp, + inputs: { + gcpRegion, + keyName: keyObject?.value ?? kms.externalKms.configuration.keyName + } + } + }); + + createNotification({ + text: "Successfully updated GCP External KMS Details", + type: "success" + }); + } + } else if (formType === "newGcpKms") { + const gcpRegion = gcpRegionObject?.value; + if (!gcpRegion) { + setError("gcpRegion", { + message: "Please select a GCP region." + }); + return; + } + + if (!keys.find((k) => k.value === keyObject?.value)) { + setError("keyObject", { + message: "Please select a valid key." + }); + resetField("keyObject"); + return; + } + const credentialJson = await getCredentialFileJson(); if (!credentialJson) { return; @@ -177,11 +208,11 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { await addGcpExternalKms({ name, description, - provider: { + configuration: { type: ExternalKmsProvider.Gcp, inputs: { gcpRegion, - keyName: keyObject?.value, + keyName: keyObject?.value ?? "", credential: credentialJson } } @@ -208,8 +239,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { if (!kms && !credentialJson) { return; } - const gcpRegion = getValues("gcpRegion").value; - if (!gcpRegion.length) { + const gcpRegionObject = getValues("gcpRegion"); + const gcpRegion = gcpRegionObject?.value; + if (!gcpRegion) { setError("gcpRegion", { message: "Please select a GCP region to fetch GCP Keys." }); @@ -231,7 +263,9 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { setKeys(returnedKeys); if (kms) { - const existingKey = returnedKeys.find((k) => k.value === kms.external.providerInput.keyName); + const existingKey = returnedKeys.find( + (k) => k.value === kms.externalKms.configuration.keyName + ); if (existingKey) { setValue("keyObject", existingKey); } @@ -260,96 +294,104 @@ export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { return ( - ( - - - - )} - /> - ( - - - - )} - /> - ( - - { - resetField("keyObject"); - field.onChange(e); - fetchGCPKeys(); - }} - formatOptionLabel={formatOptionLabel} + {(mode === "full" || mode === "details") && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + { + resetField("keyObject"); + field.onChange(e); + fetchGCPKeys(); + }} + formatOptionLabel={formatOptionLabel} + /> + + )} + /> + {!kms && ( + ( + + { + onChange(e.target.files); + fetchGCPKeys(); + }} + /> + + )} /> - - )} - /> - {!kms && ( - ( - - { - onChange(e.target.files); - fetchGCPKeys(); - }} - /> - )} - /> + ( + + + + )} + /> + )} - ( - - - - )} - /> - {kms && ( + {kms && mode === "credentials" && ( To change your GCP credentials, create a new external KMS and assign it to project you want to use it with. )}
- diff --git a/frontend/src/pages/organization/SettingsPage/route.tsx b/frontend/src/pages/organization/SettingsPage/route.tsx index ca104cf4f..28b71488a 100644 --- a/frontend/src/pages/organization/SettingsPage/route.tsx +++ b/frontend/src/pages/organization/SettingsPage/route.tsx @@ -9,7 +9,7 @@ const SettingsPageQueryParams = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/settings/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/settings/" )({ component: SettingsPage, validateSearch: zodValidator(SettingsPageQueryParams), diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index d607bc100..608348029 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -91,7 +91,8 @@ const Page = withPermission( handlePopUpClose("removeMember"); navigate({ - to: "/organization/access-management" as const, + to: "/organizations/$orgId/access-management" as const, + params: { orgId }, search: { selectedTab: OrgAccessControlTabSections.Member } @@ -103,14 +104,15 @@ const Page = withPermission( {membership && (
- Users + Organization Users -
-
+
+
diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx index a7a88a72c..71dc22394 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/components/UserProjectsSection/UserProjectRow.tsx @@ -5,6 +5,7 @@ import { useNavigate } from "@tanstack/react-router"; import { createNotification } from "@app/components/notifications"; import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { getProjectBaseURL } from "@app/helpers/project"; import { formatProjectRoleName } from "@app/helpers/roles"; import { useGetUserProjects } from "@app/hooks/api"; @@ -26,6 +27,7 @@ export const UserProjectRow = ({ }: Props) => { const { data: workspaces = [] } = useGetUserProjects(); const navigate = useNavigate(); + const { currentOrg } = useOrganization(); const isAccessible = useMemo(() => { const workspaceIds = new Map(); @@ -46,6 +48,7 @@ export const UserProjectRow = ({ navigate({ to: `${getProjectBaseURL(project.type)}/access-management` as const, params: { + orgId: currentOrg?.id || "", projectId: project.id }, search: { diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/route.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/route.tsx index 4b733029b..7293066f8 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/route.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/route.tsx @@ -3,14 +3,14 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { UserDetailsByIDPage } from "./UserDetailsByIDPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/members/$membershipId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/members/$membershipId" )({ component: UserDetailsByIDPage, - context: () => ({ + context: ({ params }) => ({ breadcrumbs: [ { label: "Access Control", - link: linkOptions({ to: "/organization/access-management" }) + link: linkOptions({ to: "/organizations/$orgId/access-management" as const, params }) }, { label: "Users" diff --git a/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx b/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx index 92b2f7859..51e568551 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/AccountViewToggle.tsx @@ -1,13 +1,9 @@ import { Button } from "@app/components/v2"; - -export enum AccountView { - Flat = "flat", - Nested = "nested" -} +import { PamAccountView } from "@app/hooks/api/pam"; type Props = { - value: AccountView; - onChange: (value: AccountView) => void; + value: PamAccountView; + onChange: (value: PamAccountView) => void; }; export const AccountViewToggle = ({ value, onChange }: Props) => { @@ -16,11 +12,11 @@ export const AccountViewToggle = ({ value, onChange }: Props) => {