diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a8a64e7b4b..2803cbbb55 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 0000000000..1e590139c4 --- /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/.infisicalignore b/.infisicalignore index 7a07a95045..46e38aa23f 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -57,3 +57,6 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93 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 +docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:142 \ No newline at end of file diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index ab1d6fbb7c..9302578fec 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 0674a00e97..faf489b2db 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -174,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/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index 21c63329f9..d02eabe817 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -22,6 +22,28 @@ 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/cert-manager/acme/profiles/{acme_profile.id}/directory" @@ -63,13 +85,12 @@ Feature: Challenge 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}" """ { @@ -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}" """ @@ -206,3 +192,28 @@ Feature: Challenge And the value response with jq ".status" should be equal to 400 And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR" And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers" + + Scenario: Get certificate without passing challenge when skip DNS ownership verification is enabled + Given I create an ACME profile with config as "acme_profile" + """ + { + "skipDnsOwnershipVerification": true + } + """ + 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 the value order.body with jq ".status" should be equal to "ready" + 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" diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 2a900dd107..5a2cef0ccb 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -369,3 +369,349 @@ Feature: External CA | 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/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index e895ed69e4..1ce839638f 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 @@ -260,6 +266,46 @@ def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str): ) +@given( + 'I create an ACME profile with config as "{profile_var}"' +) +def step_impl(context: Context, profile_var: str): + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + acme_config = replace_vars(json.loads(context.text), context.vars) + response = context.http_client.post( + "/api/v1/cert-manager/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": acme_config, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/cert-manager/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + @given('I have an ACME cert profile with external ACME CA as "{profile_var}"') def step_impl(context: Context, profile_var: str): profile_id = context.vars.get("PROFILE_ID") @@ -595,12 +641,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, ) @@ -608,10 +699,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], ) @@ -724,6 +814,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 @@ -797,22 +896,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 @@ -865,6 +981,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 ...", @@ -889,18 +1006,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") @@ -909,12 +1036,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/package-lock.json b/backend/package-lock.json index 808cf3204e..d25407aca7 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -53,7 +53,7 @@ "@opentelemetry/semantic-conventions": "^1.27.0", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.12.1", - "@react-email/components": "0.0.36", + "@react-email/components": "^1.0.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", @@ -145,7 +145,7 @@ "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/preset-env": "^7.18.10", "@babel/preset-react": "^7.24.7", - "@react-email/preview-server": "^4.3.0", + "@react-email/preview-server": "^5.0.6", "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", @@ -183,7 +183,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", - "react-email": "^4.3.0", + "react-email": "^5.0.6", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -203,19 +203,6 @@ "node": ">=0.10.0" } }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -674,6 +661,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz", "integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2120,6 +2108,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.682.0.tgz", "integrity": "sha512-ZPZ7Y/r/w3nx/xpPzGSqSQsB090Xk5aZZOH+WBhTDn/pBEuim09BYXCLzvvxb7R7NnuoQdrTJiwimdJAhHl7ZQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2173,6 +2162,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.682.0.tgz", "integrity": "sha512-xKuo4HksZ+F8m9DOfx/ZuWNhaPuqZFPwwy0xqcBT6sWH7OAuBjv/fnpOTzyQhpVTWddlf+ECtMAMrxjxuOExGQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2672,6 +2662,7 @@ "version": "3.632.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.632.0.tgz", "integrity": "sha512-Oh1fIWaoZluihOCb/zDEpRTi+6an82fgJz7fyRBugyLhEtDjmvpCQ3oKjzaOhoN+4EvXAm1ZS/ZgpvXBlIRTgw==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2748,6 +2739,7 @@ "version": "3.632.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.632.0.tgz", "integrity": "sha512-Ss5cBH09icpTvT+jtGGuQlRdwtO7RyE9BF4ZV/CEPATdd9whtJt4Qxdya8BUnkWR7h5HHTrQHqai3YVYjku41A==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -5185,6 +5177,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -7216,6 +7209,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -7237,6 +7231,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -7446,9 +7441,9 @@ "license": "BSD-3-Clause" }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", "dev": true, "license": "MIT", "optional": true, @@ -7813,23 +7808,6 @@ "node": ">=18" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", - "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/sunos-x64": { "version": "0.25.11", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", @@ -8336,48 +8314,6 @@ "p-limit": "^3.1.0" } }, - "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" - } - }, - "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/dom": "^1.7.4" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@gitbeaker/core": { "version": "42.5.0", "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-42.5.0.tgz", @@ -8685,14 +8621,15 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=18" } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -8709,13 +8646,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -8732,13 +8669,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -8753,9 +8690,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -8770,9 +8707,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -8787,9 +8724,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -8804,9 +8741,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -8820,10 +8757,27 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -8838,9 +8792,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -8855,9 +8809,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -8872,9 +8826,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -8889,9 +8843,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -8908,13 +8862,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -8931,13 +8885,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -8954,13 +8908,36 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -8977,13 +8954,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -9000,13 +8977,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -9023,13 +9000,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -9046,13 +9023,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], @@ -9060,7 +9037,7 @@ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.5.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -9070,9 +9047,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -9090,9 +9067,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -9110,9 +9087,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -9235,6 +9212,9 @@ "win32" ] }, + "node_modules/@infisical/quic/node_modules/@infisical/quic-linux-arm": { + "optional": true + }, "node_modules/@ioredis/commands": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", @@ -9338,28 +9318,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/source-map/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -9468,26 +9426,6 @@ "resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz", "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==" }, - "node_modules/@lottiefiles/dotlottie-react": { - "version": "0.13.3", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.3.tgz", - "integrity": "sha512-V4FfdYlqzjBUX7f0KV6vfQOOI0Cp+3XeG/ZqSDFSEVg5P7fpROpDv5/I9aTM8sOCESK1SWT96Fem+QVUnBV1wQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@lottiefiles/dotlottie-web": "0.42.0" - }, - "peerDependencies": { - "react": "^17 || ^18 || ^19" - } - }, - "node_modules/@lottiefiles/dotlottie-web": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.42.0.tgz", - "integrity": "sha512-Zr2LCaOAoPCsdAQgeLyCSiQ1+xrAJtRCyuEYDj0qR5heUwpc+Pxbb88JyTVumcXFfKOBMOMmrlsTScLz2mrvQQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@lukeed/ms": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", @@ -9727,16 +9665,16 @@ } }, "node_modules/@next/env": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", - "integrity": "sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.7.tgz", + "integrity": "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==", "dev": true, "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.2.tgz", - "integrity": "sha512-8bGt577BXGSd4iqFygmzIfTYizHb0LGWqH+qgIF/2EDxS5JsSdERJKA8WgwDyNBZgTIIA4D8qUtoQHmxIIquoQ==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.7.tgz", + "integrity": "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg==", "cpu": [ "arm64" ], @@ -9751,9 +9689,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.2.tgz", - "integrity": "sha512-2DjnmR6JHK4X+dgTXt5/sOCu/7yPtqpYt8s8hLkHFK3MGkka2snTv3yRMdHvuRtJVkPwCGsvBSwmoQCHatauFQ==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.7.tgz", + "integrity": "sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==", "cpu": [ "x64" ], @@ -9768,9 +9706,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.2.tgz", - "integrity": "sha512-3j7SWDBS2Wov/L9q0mFJtEvQ5miIqfO4l7d2m9Mo06ddsgUK8gWfHGgbjdFlCp2Ek7MmMQZSxpGFqcC8zGh2AA==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.7.tgz", + "integrity": "sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==", "cpu": [ "arm64" ], @@ -9785,9 +9723,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.2.tgz", - "integrity": "sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.7.tgz", + "integrity": "sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==", "cpu": [ "arm64" ], @@ -9802,9 +9740,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.2.tgz", - "integrity": "sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.7.tgz", + "integrity": "sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==", "cpu": [ "x64" ], @@ -9819,9 +9757,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.2.tgz", - "integrity": "sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.7.tgz", + "integrity": "sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==", "cpu": [ "x64" ], @@ -9836,9 +9774,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.2.tgz", - "integrity": "sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.7.tgz", + "integrity": "sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==", "cpu": [ "arm64" ], @@ -9853,9 +9791,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.2.tgz", - "integrity": "sha512-W5VvyZHnxG/2ukhZF/9Ikdra5fdNftxI6ybeVKYvBPDtyx7x4jPPSNduUkfH5fo3zG0JQ0bPxgy41af2JX5D4Q==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.7.tgz", + "integrity": "sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==", "cpu": [ "x64" ], @@ -10321,6 +10259,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -10764,6 +10703,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -11399,801 +11339,22 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "node_modules/@radix-ui/colors": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", - "integrity": "sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@radix-ui/react-arrow": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popover": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", - "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-popper": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", - "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", - "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", - "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", - "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-toggle": "1.1.10", - "@radix-ui/react-use-controllable-state": "1.2.2" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", - "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-visually-hidden": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", - "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", - "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/rect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-use-size": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", - "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", - "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/rect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", - "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", - "dev": true, - "license": "MIT" - }, "node_modules/@react-email/body": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.0.11.tgz", - "integrity": "sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz", + "integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/button": { - "version": "0.0.19", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.0.19.tgz", - "integrity": "sha512-HYHrhyVGt7rdM/ls6FuuD6XE7fa7bjZTJqB2byn6/oGsfiEZaogY77OtoLL/mrQHjHjZiJadtAMSik9XLcm7+A==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz", + "integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12202,15 +11363,16 @@ } }, "node_modules/@react-email/code-block": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.12.tgz", - "integrity": "sha512-Faw3Ij9+/Qwq6moWaeHnV8Hn7ekc/EqyAzPi6yUar21dhcqYugCC4Da1x4d9nA9zC0H9KU3lYVJczh8D3cA+Eg==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz", + "integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==", "license": "MIT", + "peer": true, "dependencies": { - "prismjs": "1.30.0" + "prismjs": "^1.30.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" @@ -12221,6 +11383,7 @@ "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz", "integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12241,14 +11404,14 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.36.tgz", - "integrity": "sha512-VMh+OQplAnG8JMLlJjdnjt+ThJZ+JVkp0q2YMS2NEz+T88N22bLD2p7DZO0QgtNaKgumOhJI/0a2Q7VzCrwu5g==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.1.tgz", + "integrity": "sha512-HnL0Y/up61sOBQT2cQg9N/kCoW0bP727gDs2MkFWQYELg6+iIHidMDvENXFC0f1ZE6hTB+4t7sszptvTcJWsDA==", "license": "MIT", "dependencies": { - "@react-email/body": "0.0.11", - "@react-email/button": "0.0.19", - "@react-email/code-block": "0.0.12", + "@react-email/body": "0.2.0", + "@react-email/button": "0.2.0", + "@react-email/code-block": "0.2.0", "@react-email/code-inline": "0.0.5", "@react-email/column": "0.0.13", "@react-email/container": "0.0.15", @@ -12259,26 +11422,44 @@ "@react-email/html": "0.0.11", "@react-email/img": "0.0.11", "@react-email/link": "0.0.12", - "@react-email/markdown": "0.0.14", - "@react-email/preview": "0.0.12", - "@react-email/render": "1.0.6", + "@react-email/markdown": "0.0.17", + "@react-email/preview": "0.0.13", + "@react-email/render": "2.0.0", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", - "@react-email/tailwind": "1.0.4", - "@react-email/text": "0.1.1" + "@react-email/tailwind": "2.0.1", + "@react-email/text": "0.1.5" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/components/node_modules/@react-email/render": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.0.tgz", + "integrity": "sha512-rdjNj6iVzv8kRKDPFas+47nnoe6B40+nwukuXwY4FCwM7XBg6tmYr+chQryCuavUj2J65MMf6fztk1bxOUiSVA==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, "node_modules/@react-email/container": { "version": "0.0.15", "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", "integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12312,6 +11493,7 @@ "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz", "integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12324,6 +11506,7 @@ "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz", "integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12348,6 +11531,7 @@ "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz", "integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12360,6 +11544,7 @@ "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz", "integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12368,25 +11553,26 @@ } }, "node_modules/@react-email/markdown": { - "version": "0.0.14", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.14.tgz", - "integrity": "sha512-5IsobCyPkb4XwnQO8uFfGcNOxnsg3311GRXhJ3uKv51P7Jxme4ycC/MITnwIZ10w2zx7HIyTiqVzTj4XbuIHbg==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.17.tgz", + "integrity": "sha512-6op3AfsBC9BJKkhG+eoMFRFWlr0/f3FYbtQrK+VhGzJocEAY0WINIFN+W8xzXr//3IL0K/aKtnH3FtpIuescQQ==", "license": "MIT", "dependencies": { - "md-to-react-email": "5.0.5" + "marked": "^15.0.12" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/preview": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.12.tgz", - "integrity": "sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz", + "integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -12395,864 +11581,13 @@ } }, "node_modules/@react-email/preview-server": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@react-email/preview-server/-/preview-server-4.3.0.tgz", - "integrity": "sha512-cUaSrxezCzdg2hF6PzIxVrtagLdw3z3ovHeB3y2RDkmDZpp7EeIoNyJm22Ch2S0uAqTZNAgqu67aroLn3mFC1A==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@react-email/preview-server/-/preview-server-5.0.6.tgz", + "integrity": "sha512-hyaQyNeDTJKHrzdnPFdvw7nbohS+jBRzgdQLVVyBcRhiSV3iltqLlsaDVI/x+GJBWxtigbneL9SQ4v/EtOcHKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "7.26.10", - "@babel/parser": "7.27.0", - "@babel/traverse": "7.27.0", - "@lottiefiles/dotlottie-react": "0.13.3", - "@radix-ui/colors": "3.0.0", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-dropdown-menu": "2.1.16", - "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-tabs": "1.1.13", - "@radix-ui/react-toggle-group": "1.1.11", - "@radix-ui/react-tooltip": "1.2.8", - "@types/node": "22.14.1", - "@types/normalize-path": "3.0.2", - "@types/react": "19.0.10", - "@types/react-dom": "19.0.4", - "@types/webpack": "5.28.5", - "autoprefixer": "10.4.21", - "clsx": "2.1.1", - "esbuild": "0.25.10", - "framer-motion": "12.23.22", - "json5": "2.2.3", - "log-symbols": "4.1.0", - "module-punycode": "npm:punycode@2.3.1", - "next": "15.5.2", - "node-html-parser": "7.0.1", - "ora": "5.4.1", - "pretty-bytes": "6.1.1", - "prism-react-renderer": "2.4.1", - "react": "19.0.0", - "react-dom": "19.0.0", - "sharp": "0.34.4", - "socket.io-client": "4.8.1", - "sonner": "2.0.3", - "source-map-js": "1.2.1", - "spamc": "0.0.5", - "stacktrace-parser": "0.1.11", - "tailwind-merge": "3.2.0", - "tailwindcss": "3.4.0", - "use-debounce": "10.0.4", - "zod": "3.24.3" - } - }, - "node_modules/@react-email/preview-server/node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", - "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/android-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", - "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/android-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", - "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/android-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", - "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", - "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/darwin-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", - "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", - "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", - "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-arm": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", - "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", - "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", - "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-loong64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", - "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", - "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", - "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", - "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-s390x": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", - "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/linux-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", - "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", - "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", - "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", - "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", - "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/sunos-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", - "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-arm64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", - "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-ia32": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", - "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@esbuild/win32-x64": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", - "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@react-email/preview-server/node_modules/@types/node": { - "version": "22.14.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz", - "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/@types/react": { - "version": "19.0.10", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", - "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@react-email/preview-server/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/@react-email/preview-server/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@react-email/preview-server/node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@react-email/preview-server/node_modules/esbuild": { - "version": "0.25.10", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", - "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.10", - "@esbuild/android-arm": "0.25.10", - "@esbuild/android-arm64": "0.25.10", - "@esbuild/android-x64": "0.25.10", - "@esbuild/darwin-arm64": "0.25.10", - "@esbuild/darwin-x64": "0.25.10", - "@esbuild/freebsd-arm64": "0.25.10", - "@esbuild/freebsd-x64": "0.25.10", - "@esbuild/linux-arm": "0.25.10", - "@esbuild/linux-arm64": "0.25.10", - "@esbuild/linux-ia32": "0.25.10", - "@esbuild/linux-loong64": "0.25.10", - "@esbuild/linux-mips64el": "0.25.10", - "@esbuild/linux-ppc64": "0.25.10", - "@esbuild/linux-riscv64": "0.25.10", - "@esbuild/linux-s390x": "0.25.10", - "@esbuild/linux-x64": "0.25.10", - "@esbuild/netbsd-arm64": "0.25.10", - "@esbuild/netbsd-x64": "0.25.10", - "@esbuild/openbsd-arm64": "0.25.10", - "@esbuild/openbsd-x64": "0.25.10", - "@esbuild/openharmony-arm64": "0.25.10", - "@esbuild/sunos-x64": "0.25.10", - "@esbuild/win32-arm64": "0.25.10", - "@esbuild/win32-ia32": "0.25.10", - "@esbuild/win32-x64": "0.25.10" - } - }, - "node_modules/@react-email/preview-server/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@react-email/preview-server/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-email/preview-server/node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-email/preview-server/node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@react-email/preview-server/node_modules/react": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", - "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/react-dom": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", - "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.25.0" - }, - "peerDependencies": { - "react": "^19.0.0" - } - }, - "node_modules/@react-email/preview-server/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@react-email/preview-server/node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@react-email/preview-server/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@react-email/preview-server/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@react-email/render": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz", - "integrity": "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==", - "license": "MIT", - "dependencies": { - "html-to-text": "9.0.5", - "prettier": "3.5.3", - "react-promise-suspense": "0.3.4" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "react": "^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + "next": "16.0.7" } }, "node_modules/@react-email/row": { @@ -13280,22 +11615,69 @@ } }, "node_modules/@react-email/tailwind": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-1.0.4.tgz", - "integrity": "sha512-tJdcusncdqgvTUYZIuhNC6LYTfL9vNTSQpwWdTCQhQ1lsrNCEE4OKCSdzSV3S9F32pi0i0xQ+YPJHKIzGjdTSA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.1.tgz", + "integrity": "sha512-/xq0IDYVY7863xPY7cdI45Xoz7M6CnIQBJcQvbqN7MNVpopfH9f+mhjayV1JGfKaxlGWuxfLKhgi9T2shsnEFg==", "license": "MIT", + "dependencies": { + "tailwindcss": "^4.1.12" + }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "peerDependencies": { + "@react-email/body": "0.2.0", + "@react-email/button": "0.2.0", + "@react-email/code-block": "0.2.0", + "@react-email/code-inline": "0.0.5", + "@react-email/container": "0.0.15", + "@react-email/heading": "0.0.15", + "@react-email/hr": "0.0.11", + "@react-email/img": "0.0.11", + "@react-email/link": "0.0.12", + "@react-email/preview": "0.0.13", + "@react-email/text": "0.1.5", "react": "^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@react-email/body": { + "optional": true + }, + "@react-email/button": { + "optional": true + }, + "@react-email/code-block": { + "optional": true + }, + "@react-email/code-inline": { + "optional": true + }, + "@react-email/container": { + "optional": true + }, + "@react-email/heading": { + "optional": true + }, + "@react-email/hr": { + "optional": true + }, + "@react-email/img": { + "optional": true + }, + "@react-email/link": { + "optional": true + }, + "@react-email/preview": { + "optional": true + } } }, "node_modules/@react-email/text": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.1.tgz", - "integrity": "sha512-Zo9tSEzkO3fODLVH1yVhzVCiwETfeEL5wU93jXKWo2DHoMuiZ9Iabaso3T0D0UjhrCB1PBMeq2YiejqeToTyIQ==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz", + "integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -15138,28 +13520,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -15306,6 +13666,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -15344,13 +13705,6 @@ "@types/node": "*" } }, - "node_modules/@types/normalize-path": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/normalize-path/-/normalize-path-3.0.2.tgz", - "integrity": "sha512-DO++toKYPaFn0Z8hQ7Tx+3iT9t77IJo/nDiqTXilgEP+kPNIYdpS9kh3fXuc53ugqwp9pxC1PVjCpV1tQDyqMA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/oauth": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.4.tgz", @@ -15494,13 +13848,6 @@ "pkcs11js": "*" } }, - "node_modules/@types/prismjs": { - "version": "1.26.5", - "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.5.tgz", - "integrity": "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/prompt-sync": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/@types/prompt-sync/-/prompt-sync-4.2.3.tgz", @@ -15528,16 +13875,6 @@ "csstype": "^3.0.2" } }, - "node_modules/@types/react-dom": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.4.tgz", - "integrity": "sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.0.0" - } - }, "node_modules/@types/readable-stream": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.14.tgz", @@ -15710,18 +14047,6 @@ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" }, - "node_modules/@types/webpack": { - "version": "5.28.5", - "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", - "integrity": "sha512-wR87cgvxj3p6D0Crt1r5avwqffqPXUkNlnQ1mjU93G7gCuFjufZR4I6j8cz5g1F1tTYpfOOFvly+cmIQwL9wvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "tapable": "^2.2.0", - "webpack": "^5" - } - }, "node_modules/@types/whatwg-url": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", @@ -15820,6 +14145,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz", "integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.20.0", "@typescript-eslint/types": "6.20.0", @@ -16188,167 +14514,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, "node_modules/@xmldom/is-dom-node": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", @@ -16367,20 +14532,6 @@ "node": ">=10.0.0" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@yao-pkg/pkg": { "version": "5.12.0", "resolved": "https://registry.npmjs.org/@yao-pkg/pkg/-/pkg-5.12.0.tgz", @@ -16659,6 +14810,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -16674,19 +14826,6 @@ "acorn": "^8" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -16764,14 +14903,15 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -16794,18 +14934,21 @@ } } }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/ansi-regex": { "version": "6.0.1", @@ -17002,19 +15145,6 @@ "node": ">=0.8.0" } }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/array-back": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/array-back/-/array-back-6.2.2.tgz", @@ -17169,6 +15299,7 @@ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", "license": "MIT", + "peer": true, "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", @@ -17265,42 +15396,15 @@ "node": ">=8.0.0" } }, - "node_modules/autoprefixer": { - "version": "10.4.21", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", - "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "node_modules/atomically": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.0.tgz", + "integrity": "sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "browserslist": "^4.24.4", - "caniuse-lite": "^1.0.30001702", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" } }, "node_modules/available-typed-arrays": { @@ -17420,6 +15524,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -17723,13 +15828,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, "node_modules/botbuilder": { "version": "4.23.2", "resolved": "https://registry.npmjs.org/botbuilder/-/botbuilder-4.23.2.tgz", @@ -18028,6 +16126,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -18083,13 +16182,6 @@ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, "node_modules/buildcheck": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", @@ -18343,16 +16435,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001748", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz", @@ -18513,16 +16595,6 @@ "node": ">=10" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, "node_modules/cipher-base": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.5.tgz", @@ -18660,26 +16732,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -18825,6 +16877,61 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/conf": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/conf/-/conf-15.0.2.tgz", + "integrity": "sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^10.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.7.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/confbox": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", @@ -19018,49 +17125,6 @@ "resolved": "https://registry.npmjs.org/crypto-randomuuid/-/crypto-randomuuid-1.0.0.tgz", "integrity": "sha512-/RC5F4l1SCqD/jazwUF6+t34Cd8zTSAGZ7rvvZu1whZUhD2a5MOGKjSGowoGcpj/cbVZk1ZODIooJEQQq3nNAA==" }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/cssstyle": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", @@ -19237,6 +17301,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", @@ -19327,19 +17407,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -19477,26 +17544,12 @@ "node": ">=8" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "dev": true, - "license": "MIT" - }, "node_modules/dev-null": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -19518,13 +17571,6 @@ "node": ">=8" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -19600,10 +17646,43 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-10.1.0.tgz", + "integrity": "sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/type-fest": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.3.1.tgz", + "integrity": "sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/dotenv": { "version": "16.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.1.tgz", "integrity": "sha512-CjA3y+Dr3FyFDOAMnxZEGtnW9KBR2M0JvvUtXNW+dYJL5ROWxP9DUHCwgFqpMk0OXCc0ljhaNTr2w/kutYIcHQ==", + "peer": true, "engines": { "node": ">=12" }, @@ -19750,60 +17829,6 @@ "node": ">=10.2.0" } }, - "node_modules/engine.io-client": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", - "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1", - "xmlhttprequest-ssl": "~2.1.1" - } - }, - "node_modules/engine.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io-client/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/engine.io-parser": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", @@ -20043,6 +18068,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -20125,6 +18151,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20223,6 +18250,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -20311,6 +18339,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -20768,7 +18797,6 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", "license": "MIT", - "peer": true, "dependencies": { "cookie": "0.7.2", "cookie-signature": "1.0.7", @@ -20786,14 +18814,12 @@ "node_modules/express-session/node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "peer": true + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" }, "node_modules/express-session/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -20801,8 +18827,7 @@ "node_modules/express-session/node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "peer": true + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/express/node_modules/cookie": { "version": "0.7.1", @@ -21424,48 +19449,6 @@ "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==" }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/framer-motion": { - "version": "12.23.22", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.22.tgz", - "integrity": "sha512-ZgGvdxXCw55ZYvhoZChTlG6pUuehecgvEAJz0BHoC5pQKW1EC5xf1Mul1ej5+ai+pVY0pylyFfdl45qnM1/GsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "motion-dom": "^12.23.21", - "motion-utils": "^12.23.6", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -21820,16 +19803,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-nonce": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", - "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -21929,13 +19902,6 @@ "node": ">= 6" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, "node_modules/glob/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -22330,16 +20296,6 @@ "node": ">=0.10.0" } }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, "node_modules/helmet": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", @@ -23291,47 +21247,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", @@ -23541,13 +21456,6 @@ "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==" }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -23604,6 +21512,13 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -24105,16 +22020,6 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, - "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - } - }, "node_modules/localforage": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", @@ -24369,15 +22274,15 @@ } }, "node_modules/marked": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/marked/-/marked-7.0.4.tgz", - "integrity": "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==", + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "license": "MIT", "bin": { "marked": "bin/marked.js" }, "engines": { - "node": ">= 16" + "node": ">= 18" } }, "node_modules/math-intrinsics": { @@ -24388,18 +22293,6 @@ "node": ">= 0.4" } }, - "node_modules/md-to-react-email": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/md-to-react-email/-/md-to-react-email-5.0.5.tgz", - "integrity": "sha512-OvAXqwq57uOk+WZqFFNCMZz8yDp8BD3WazW1wAKHUrPbbdr89K9DWS6JXY09vd9xNdPNeurI8DU/X4flcfaD8A==", - "license": "MIT", - "dependencies": { - "marked": "7.0.4" - }, - "peerDependencies": { - "react": "^18.0 || ^19.0" - } - }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -24433,12 +22326,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -24763,17 +22650,6 @@ "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" }, - "node_modules/module-punycode": { - "name": "punycode", - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -24880,23 +22756,6 @@ "node": ">=16" } }, - "node_modules/motion-dom": { - "version": "12.23.21", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.21.tgz", - "integrity": "sha512-5xDXx/AbhrfgsQmSE7YESMn4Dpo6x5/DTZ4Iyy4xqDvVHWvFVoV+V2Ri2S/ksx+D40wrZ7gPYiMWshkdoqNgNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "motion-utils": "^12.23.6" - } - }, - "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", - "dev": true, - "license": "MIT" - }, "node_modules/mri": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/mri/-/mri-1.1.4.tgz", @@ -25103,13 +22962,13 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/next": { - "version": "15.5.2", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.2.tgz", - "integrity": "sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", + "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "dev": true, "license": "MIT", "dependencies": { - "@next/env": "15.5.2", + "@next/env": "16.0.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -25119,18 +22978,18 @@ "next": "dist/bin/next" }, "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.2", - "@next/swc-darwin-x64": "15.5.2", - "@next/swc-linux-arm64-gnu": "15.5.2", - "@next/swc-linux-arm64-musl": "15.5.2", - "@next/swc-linux-x64-gnu": "15.5.2", - "@next/swc-linux-x64-musl": "15.5.2", - "@next/swc-win32-arm64-msvc": "15.5.2", - "@next/swc-win32-x64-msvc": "15.5.2", - "sharp": "^0.34.3" + "@next/swc-darwin-arm64": "16.0.7", + "@next/swc-darwin-x64": "16.0.7", + "@next/swc-linux-arm64-gnu": "16.0.7", + "@next/swc-linux-arm64-musl": "16.0.7", + "@next/swc-linux-x64-gnu": "16.0.7", + "@next/swc-linux-x64-musl": "16.0.7", + "@next/swc-win32-arm64-msvc": "16.0.7", + "@next/swc-win32-x64-msvc": "16.0.7", + "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -25410,17 +23269,6 @@ "node": "^16.13.0 || >=18.0.0" } }, - "node_modules/node-html-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", - "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-select": "^5.1.0", - "he": "1.2.0" - } - }, "node_modules/node-releases": { "version": "2.0.23", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", @@ -25510,16 +23358,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -25531,19 +23369,6 @@ "set-blocking": "^2.0.0" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/nwsapi": { "version": "2.2.18", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.18.tgz", @@ -27649,7 +25474,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "peer": true, "engines": { "node": ">= 0.8" } @@ -28349,6 +26173,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz", "integrity": "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==", + "peer": true, "engines": { "node": ">=10" }, @@ -28682,6 +26507,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -28691,132 +26517,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -28923,6 +26623,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -28945,33 +26646,6 @@ "node": ">=6.0.0" } }, - "node_modules/pretty-bytes": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", - "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/prism-react-renderer": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", - "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/prismjs": "^1.26.0", - "clsx": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.0.0" - } - }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -29413,7 +27087,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", - "peer": true, "engines": { "node": ">= 0.8" } @@ -29509,6 +27182,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -29518,6 +27192,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -29526,9 +27201,9 @@ } }, "node_modules/react-email": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.3.0.tgz", - "integrity": "sha512-XFHCSfhdlO7k5q2TYGwC0HsVh5Yn13YaOdahuJEUEOfOJKHEpSP4PKg7R/RiKFoK9cDvzunhY+58pXxz0vE2zA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.6.tgz", + "integrity": "sha512-DEGzWpEiC3CquPEaaEJuipNT3WZ9mK58rbkpOe4Slbgyf60PLa1wONnt5a3afbBBRbNdW2aYhIvVI41yS6UIRA==", "dev": true, "license": "MIT", "dependencies": { @@ -29536,6 +27211,7 @@ "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", + "conf": "^15.0.2", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", @@ -29553,7 +27229,7 @@ "email": "dist/index.js" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/react-email/node_modules/chokidar": { @@ -29889,113 +27565,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-promise-suspense": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", - "integrity": "sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^2.0.1" - } - }, - "node_modules/react-promise-suspense/node_modules/fast-deep-equal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", - "integrity": "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==", - "license": "MIT" - }, - "node_modules/react-remove-scroll": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", - "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-remove-scroll-bar": "^2.3.7", - "react-style-singleton": "^2.2.3", - "tslib": "^2.1.0", - "use-callback-ref": "^1.3.3", - "use-sidecar": "^1.1.3" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-remove-scroll-bar": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", - "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-style-singleton": "^2.2.2", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-style-singleton": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", - "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-nonce": "^1.0.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/read-cache/node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/readable-stream": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", @@ -30583,26 +28152,6 @@ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/scim-patch": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/scim-patch/-/scim-patch-0.8.3.tgz", @@ -30649,9 +28198,9 @@ "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -30739,16 +28288,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -30831,16 +28370,17 @@ } }, "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "optional": true, "dependencies": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -30849,28 +28389,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -31323,40 +28865,6 @@ } } }, - "node_modules/socket.io-client": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", - "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.3.2", - "engine.io-client": "~6.6.1", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/socket.io-client/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/socket.io-parser": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", @@ -31412,6 +28920,7 @@ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -31469,17 +28978,6 @@ "atomic-sleep": "^1.0.0" } }, - "node_modules/sonner": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.3.tgz", - "integrity": "sha512-njQ4Hht92m0sMqqHVDL32V2Oun9W1+PHO9NDv9FHfJjT3JT22IG4Jpo3FPQy+mouRKCXFWO+r67v6MrHX2zeIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", - "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -31498,23 +28996,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/spamc": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/spamc/-/spamc-0.0.5.tgz", - "integrity": "sha512-jYXItuZuiWZyG9fIdvgTUbp2MNRuyhuSwvvhhpPJd4JK/9oSZxkD7zAj53GJtowSlXwCJzLg6sCKAoE9wXsKgg==", - "dev": true - }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -31635,29 +29116,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -31952,6 +29410,23 @@ "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==" }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "dev": true, + "license": "MIT" + }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", @@ -32143,105 +29618,25 @@ "node": ">=12.17" } }, - "node_modules/tailwind-merge": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.2.0.tgz", - "integrity": "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", "dev": true, "license": "MIT", + "engines": { + "node": ">=20" + }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/dcastil" + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tailwindcss": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.0.tgz", - "integrity": "sha512-VigzymniH77knD1dryXbyxR+ePHihHociZbXnLZHUyzf2MMs2ZVqlUrZ3FvpXP8pno9JzmILt1sZPD19M3IxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", + "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "license": "MIT" }, - "node_modules/tailwindcss/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/tailwindcss/node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/tailwindcss/node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/tailwindcss/node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -32440,78 +29835,6 @@ "node": ">= 6" } }, - "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.3.14", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz", - "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "serialize-javascript": "^6.0.2", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -33096,6 +30419,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -33273,6 +30597,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -33313,7 +30638,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", - "peer": true, "dependencies": { "random-bytes": "~1.0.0" }, @@ -33326,6 +30650,19 @@ "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -33525,64 +30862,6 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" }, - "node_modules/use-callback-ref": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", - "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/use-debounce": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.4.tgz", - "integrity": "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16.0.0" - }, - "peerDependencies": { - "react": "*" - } - }, - "node_modules/use-sidecar": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", - "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-node-es": "^1.1.0", - "tslib": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -33946,6 +31225,7 @@ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -34026,118 +31306,11 @@ "node": ">=18" } }, - "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, - "node_modules/webpack": { - "version": "5.102.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", - "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -34172,6 +31345,13 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -34579,15 +31759,6 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, - "node_modules/xmlhttprequest-ssl": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", - "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/xpath": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", @@ -34736,6 +31907,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/backend/package.json b/backend/package.json index 0e17bb2b73..433e7a9b0b 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", @@ -90,7 +91,7 @@ "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/preset-env": "^7.18.10", "@babel/preset-react": "^7.24.7", - "@react-email/preview-server": "^4.3.0", + "@react-email/preview-server": "^5.0.6", "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", @@ -128,7 +129,7 @@ "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", - "react-email": "^4.3.0", + "react-email": "^5.0.6", "rimraf": "^5.0.5", "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", @@ -183,7 +184,7 @@ "@opentelemetry/semantic-conventions": "^1.27.0", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/x509": "^1.12.1", - "@react-email/components": "0.0.36", + "@react-email/components": "^1.0.1", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", @@ -266,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/export-assets.sh b/backend/scripts/export-assets.sh new file mode 100644 index 0000000000..149700579e --- /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 6ef775f902..be6f459431 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -55,6 +55,7 @@ import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; import { TAdditionalPrivilegeServiceFactory } from "@app/services/additional-privilege/additional-privilege-service"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TApprovalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; @@ -65,6 +66,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 +290,7 @@ declare module "fastify" { auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; certificateV3: TCertificateV3ServiceFactory; + certificateRequest: TCertificateRequestServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; certificateTemplateV2: TCertificateTemplateV2ServiceFactory; certificateProfile: TCertificateProfileServiceFactory; @@ -359,6 +362,7 @@ declare module "fastify" { convertor: TConvertorServiceFactory; subOrganization: TSubOrgServiceFactory; pkiAlertV2: TPkiAlertV2ServiceFactory; + approvalPolicy: TApprovalPolicyServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 603df5f6ca..1301000d58 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -26,6 +26,30 @@ import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate, + TApprovalPolicies, + TApprovalPoliciesInsert, + TApprovalPoliciesUpdate, + TApprovalPolicyStepApprovers, + TApprovalPolicyStepApproversInsert, + TApprovalPolicyStepApproversUpdate, + TApprovalPolicySteps, + TApprovalPolicyStepsInsert, + TApprovalPolicyStepsUpdate, + TApprovalRequestApprovals, + TApprovalRequestApprovalsInsert, + TApprovalRequestApprovalsUpdate, + TApprovalRequestGrants, + TApprovalRequestGrantsInsert, + TApprovalRequestGrantsUpdate, + TApprovalRequests, + TApprovalRequestsInsert, + TApprovalRequestStepEligibleApprovers, + TApprovalRequestStepEligibleApproversInsert, + TApprovalRequestStepEligibleApproversUpdate, + TApprovalRequestSteps, + TApprovalRequestStepsInsert, + TApprovalRequestStepsUpdate, + TApprovalRequestsUpdate, TAuditLogs, TAuditLogsInsert, TAuditLogStreams, @@ -578,6 +602,11 @@ import { TAccessApprovalPoliciesEnvironmentsInsert, TAccessApprovalPoliciesEnvironmentsUpdate } from "@app/db/schemas/access-approval-policies-environments"; +import { + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate +} from "@app/db/schemas/certificate-requests"; import { TIdentityAuthTemplates, TIdentityAuthTemplatesInsert, @@ -714,6 +743,11 @@ declare module "knex/types/tables" { TExternalCertificateAuthoritiesUpdate >; [TableName.Certificate]: KnexOriginal.CompositeTableType; + [TableName.CertificateRequests]: KnexOriginal.CompositeTableType< + TCertificateRequests, + TCertificateRequestsInsert, + TCertificateRequestsUpdate + >; [TableName.CertificateTemplate]: KnexOriginal.CompositeTableType< TCertificateTemplates, TCertificateTemplatesInsert, @@ -1465,5 +1499,45 @@ declare module "knex/types/tables" { TVaultExternalMigrationConfigsInsert, TVaultExternalMigrationConfigsUpdate >; + [TableName.ApprovalPolicies]: KnexOriginal.CompositeTableType< + TApprovalPolicies, + TApprovalPoliciesInsert, + TApprovalPoliciesUpdate + >; + [TableName.ApprovalPolicyStepApprovers]: KnexOriginal.CompositeTableType< + TApprovalPolicyStepApprovers, + TApprovalPolicyStepApproversInsert, + TApprovalPolicyStepApproversUpdate + >; + [TableName.ApprovalPolicySteps]: KnexOriginal.CompositeTableType< + TApprovalPolicySteps, + TApprovalPolicyStepsInsert, + TApprovalPolicyStepsUpdate + >; + [TableName.ApprovalRequestApprovals]: KnexOriginal.CompositeTableType< + TApprovalRequestApprovals, + TApprovalRequestApprovalsInsert, + TApprovalRequestApprovalsUpdate + >; + [TableName.ApprovalRequestGrants]: KnexOriginal.CompositeTableType< + TApprovalRequestGrants, + TApprovalRequestGrantsInsert, + TApprovalRequestGrantsUpdate + >; + [TableName.ApprovalRequestStepEligibleApprovers]: KnexOriginal.CompositeTableType< + TApprovalRequestStepEligibleApprovers, + TApprovalRequestStepEligibleApproversInsert, + TApprovalRequestStepEligibleApproversUpdate + >; + [TableName.ApprovalRequestSteps]: KnexOriginal.CompositeTableType< + TApprovalRequestSteps, + TApprovalRequestStepsInsert, + TApprovalRequestStepsUpdate + >; + [TableName.ApprovalRequests]: KnexOriginal.CompositeTableType< + TApprovalRequests, + TApprovalRequestsInsert, + TApprovalRequestsUpdate + >; } } 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 0000000000..32944c37d7 --- /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/20251127192155_adds-sub-organization-id-to-identity-access-tokens.ts b/backend/src/db/migrations/20251127192155_adds-sub-organization-id-to-identity-access-tokens.ts new file mode 100644 index 0000000000..31de94dd39 --- /dev/null +++ b/backend/src/db/migrations/20251127192155_adds-sub-organization-id-to-identity-access-tokens.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasSubOrganizationIdColumn = await knex.schema.hasColumn(TableName.IdentityAccessToken, "subOrganizationId"); + if (!hasSubOrganizationIdColumn) { + await knex.schema.alterTable(TableName.IdentityAccessToken, (t) => { + t.uuid("subOrganizationId").nullable(); + t.foreign("subOrganizationId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasSubOrganizationIdColumn = await knex.schema.hasColumn(TableName.IdentityAccessToken, "subOrganizationId"); + if (hasSubOrganizationIdColumn) { + await knex.schema.alterTable(TableName.IdentityAccessToken, (t) => { + t.dropColumn("subOrganizationId"); + }); + } +} 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 0000000000..86ad4f5b4f --- /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/migrations/20251203002657_global-approvals.ts b/backend/src/db/migrations/20251203002657_global-approvals.ts new file mode 100644 index 0000000000..51610f8792 --- /dev/null +++ b/backend/src/db/migrations/20251203002657_global-approvals.ts @@ -0,0 +1,194 @@ +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.ApprovalPolicies))) { + await knex.schema.createTable(TableName.ApprovalPolicies, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable().index(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("organizationId").notNullable().index(); + t.foreign("organizationId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.string("type").notNullable().index(); + t.string("name").notNullable(); + + t.boolean("isActive").defaultTo(true); + + t.string("maxRequestTtl").nullable(); // 1hour, 30seconds, etc + + t.jsonb("conditions").notNullable(); + t.jsonb("constraints").notNullable(); + + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.ApprovalPolicies); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalPolicySteps))) { + await knex.schema.createTable(TableName.ApprovalPolicySteps, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("policyId").notNullable().index(); + t.foreign("policyId").references("id").inTable(TableName.ApprovalPolicies).onDelete("CASCADE"); + + t.string("name").nullable(); + t.integer("stepNumber").notNullable(); + + t.integer("requiredApprovals").notNullable(); + t.boolean("notifyApprovers").defaultTo(false); + }); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalPolicyStepApprovers))) { + await knex.schema.createTable(TableName.ApprovalPolicyStepApprovers, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("policyStepId").notNullable().index(); + t.foreign("policyStepId").references("id").inTable(TableName.ApprovalPolicySteps).onDelete("CASCADE"); + + t.uuid("userId").nullable().index(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("groupId").nullable().index(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.check('("userId" IS NOT NULL AND "groupId" IS NULL) OR ("userId" IS NULL AND "groupId" IS NOT NULL)'); + }); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalRequests))) { + await knex.schema.createTable(TableName.ApprovalRequests, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable().index(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("organizationId").notNullable().index(); + t.foreign("organizationId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.uuid("policyId").nullable().index(); + t.foreign("policyId").references("id").inTable(TableName.ApprovalPolicies).onDelete("SET NULL"); + + t.uuid("requesterId").nullable().index(); + t.foreign("requesterId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + + // To be used in the event of requester deletion + t.string("requesterName").notNullable(); + t.string("requesterEmail").notNullable(); + + t.string("type").notNullable().index(); + + t.string("status").notNullable().index(); + t.text("justification").nullable(); + t.integer("currentStep").notNullable(); + + t.jsonb("requestData").notNullable(); + + t.timestamp("expiresAt").nullable(); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.ApprovalRequests); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalRequestSteps))) { + await knex.schema.createTable(TableName.ApprovalRequestSteps, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("requestId").notNullable().index(); + t.foreign("requestId").references("id").inTable(TableName.ApprovalRequests).onDelete("CASCADE"); + + t.integer("stepNumber").notNullable(); + + t.string("name").nullable(); + t.string("status").notNullable().index(); + + t.integer("requiredApprovals").notNullable(); + t.boolean("notifyApprovers").defaultTo(false); + + t.timestamp("startedAt").nullable(); + t.timestamp("completedAt").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalRequestStepEligibleApprovers))) { + await knex.schema.createTable(TableName.ApprovalRequestStepEligibleApprovers, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("stepId").notNullable().index(); + t.foreign("stepId").references("id").inTable(TableName.ApprovalRequestSteps).onDelete("CASCADE"); + + t.uuid("userId").nullable().index(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("groupId").nullable().index(); + t.foreign("groupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.check('("userId" IS NOT NULL AND "groupId" IS NULL) OR ("userId" IS NULL AND "groupId" IS NOT NULL)'); + }); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalRequestApprovals))) { + await knex.schema.createTable(TableName.ApprovalRequestApprovals, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("stepId").notNullable().index(); + t.foreign("stepId").references("id").inTable(TableName.ApprovalRequestSteps).onDelete("CASCADE"); + + t.uuid("approverUserId").notNullable().index(); + t.foreign("approverUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.string("decision").notNullable(); + t.text("comment").nullable(); + + t.timestamp("createdAt").defaultTo(knex.fn.now()); + }); + } + + if (!(await knex.schema.hasTable(TableName.ApprovalRequestGrants))) { + await knex.schema.createTable(TableName.ApprovalRequestGrants, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.string("projectId").notNullable().index(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("requestId").nullable().index(); + t.foreign("requestId").references("id").inTable(TableName.ApprovalRequests).onDelete("SET NULL"); + + t.uuid("granteeUserId").nullable().index(); + t.foreign("granteeUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + + t.uuid("revokedByUserId").nullable().index(); + t.foreign("revokedByUserId").references("id").inTable(TableName.Users).onDelete("SET NULL"); + + t.text("revocationReason").nullable(); + + t.string("status").notNullable().index(); + t.string("type").notNullable().index(); + + t.jsonb("attributes").notNullable(); + + t.timestamp("createdAt").defaultTo(knex.fn.now()); + t.timestamp("expiresAt").nullable(); + t.timestamp("revokedAt").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.ApprovalRequestGrants); + await knex.schema.dropTableIfExists(TableName.ApprovalRequestApprovals); + await knex.schema.dropTableIfExists(TableName.ApprovalRequestStepEligibleApprovers); + await knex.schema.dropTableIfExists(TableName.ApprovalRequestSteps); + await knex.schema.dropTableIfExists(TableName.ApprovalRequests); + await knex.schema.dropTableIfExists(TableName.ApprovalPolicyStepApprovers); + await knex.schema.dropTableIfExists(TableName.ApprovalPolicySteps); + await knex.schema.dropTableIfExists(TableName.ApprovalPolicies); + + await dropOnUpdateTrigger(knex, TableName.ApprovalRequests); + await dropOnUpdateTrigger(knex, TableName.ApprovalPolicies); +} diff --git a/backend/src/db/migrations/20251203224427_pam-aws-console.ts b/backend/src/db/migrations/20251203224427_pam-aws-console.ts new file mode 100644 index 0000000000..adadb9e995 --- /dev/null +++ b/backend/src/db/migrations/20251203224427_pam-aws-console.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId"); + if (hasGatewayId) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.uuid("gatewayId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasGatewayId = await knex.schema.hasColumn(TableName.PamResource, "gatewayId"); + if (hasGatewayId) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.uuid("gatewayId").notNullable().alter(); + }); + } +} diff --git a/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts b/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts new file mode 100644 index 0000000000..3a87e75a40 --- /dev/null +++ b/backend/src/db/migrations/20251209191101_add-acme-order-id-for-cert-requests.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { dropConstraintIfExists } from "./utils/dropConstraintIfExists"; + +const FOREIGN_KEY_CONSTRAINT_NAME = "certificate_requests_acme_order_id_fkey"; +const INDEX_NAME = "certificate_requests_acme_order_id_idx"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateRequests)) { + const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId"); + + if (!hasAcmeOrderId) { + await knex.schema.alterTable(TableName.CertificateRequests, (t) => { + t.uuid("acmeOrderId").nullable(); + t.foreign("acmeOrderId", FOREIGN_KEY_CONSTRAINT_NAME) + .references("id") + .inTable(TableName.PkiAcmeOrder) + .onDelete("SET NULL"); + t.index("acmeOrderId", INDEX_NAME); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateRequests)) { + const hasAcmeOrderId = await knex.schema.hasColumn(TableName.CertificateRequests, "acmeOrderId"); + + if (hasAcmeOrderId) { + await dropConstraintIfExists(TableName.CertificateRequests, FOREIGN_KEY_CONSTRAINT_NAME, knex); + await knex.schema.alterTable(TableName.CertificateRequests, (t) => { + t.dropIndex("acmeOrderId", INDEX_NAME); + t.dropColumn("acmeOrderId"); + }); + } + } +} diff --git a/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts new file mode 100644 index 0000000000..6ae5b40397 --- /dev/null +++ b/backend/src/db/migrations/20251210113242_add-skip-dns-ownership-verification-to-acme-config.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (!(await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification"))) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.boolean("skipDnsOwnershipVerification").defaultTo(false).notNullable(); + }); + } + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + if (await knex.schema.hasColumn(TableName.PkiAcmeEnrollmentConfig, "skipDnsOwnershipVerification")) { + await knex.schema.alterTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.dropColumn("skipDnsOwnershipVerification"); + }); + } + } +} diff --git a/backend/src/db/schemas/approval-policies.ts b/backend/src/db/schemas/approval-policies.ts new file mode 100644 index 0000000000..d7f8fe8da8 --- /dev/null +++ b/backend/src/db/schemas/approval-policies.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ApprovalPoliciesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + organizationId: z.string().uuid(), + type: z.string(), + name: z.string(), + isActive: z.boolean().default(true).nullable().optional(), + maxRequestTtl: z.string().nullable().optional(), + conditions: z.unknown(), + constraints: z.unknown(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TApprovalPolicies = z.infer; +export type TApprovalPoliciesInsert = Omit, TImmutableDBKeys>; +export type TApprovalPoliciesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/approval-policy-step-approvers.ts b/backend/src/db/schemas/approval-policy-step-approvers.ts new file mode 100644 index 0000000000..909d99d15b --- /dev/null +++ b/backend/src/db/schemas/approval-policy-step-approvers.ts @@ -0,0 +1,24 @@ +// 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 ApprovalPolicyStepApproversSchema = z.object({ + id: z.string().uuid(), + policyStepId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + groupId: z.string().uuid().nullable().optional() +}); + +export type TApprovalPolicyStepApprovers = z.infer; +export type TApprovalPolicyStepApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TApprovalPolicyStepApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/approval-policy-steps.ts b/backend/src/db/schemas/approval-policy-steps.ts new file mode 100644 index 0000000000..d4831fa2db --- /dev/null +++ b/backend/src/db/schemas/approval-policy-steps.ts @@ -0,0 +1,21 @@ +// 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 ApprovalPolicyStepsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + name: z.string().nullable().optional(), + stepNumber: z.number(), + requiredApprovals: z.number(), + notifyApprovers: z.boolean().default(false).nullable().optional() +}); + +export type TApprovalPolicySteps = z.infer; +export type TApprovalPolicyStepsInsert = Omit, TImmutableDBKeys>; +export type TApprovalPolicyStepsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/approval-request-approvals.ts b/backend/src/db/schemas/approval-request-approvals.ts new file mode 100644 index 0000000000..25b305d856 --- /dev/null +++ b/backend/src/db/schemas/approval-request-approvals.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const ApprovalRequestApprovalsSchema = z.object({ + id: z.string().uuid(), + stepId: z.string().uuid(), + approverUserId: z.string().uuid(), + decision: z.string(), + comment: z.string().nullable().optional(), + createdAt: z.date().nullable().optional() +}); + +export type TApprovalRequestApprovals = z.infer; +export type TApprovalRequestApprovalsInsert = Omit, TImmutableDBKeys>; +export type TApprovalRequestApprovalsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/approval-request-grants.ts b/backend/src/db/schemas/approval-request-grants.ts new file mode 100644 index 0000000000..5056a5b1d3 --- /dev/null +++ b/backend/src/db/schemas/approval-request-grants.ts @@ -0,0 +1,27 @@ +// 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 ApprovalRequestGrantsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + requestId: z.string().uuid().nullable().optional(), + granteeUserId: z.string().uuid().nullable().optional(), + revokedByUserId: z.string().uuid().nullable().optional(), + revocationReason: z.string().nullable().optional(), + status: z.string(), + type: z.string(), + attributes: z.unknown(), + createdAt: z.date().nullable().optional(), + expiresAt: z.date().nullable().optional(), + revokedAt: z.date().nullable().optional() +}); + +export type TApprovalRequestGrants = z.infer; +export type TApprovalRequestGrantsInsert = Omit, TImmutableDBKeys>; +export type TApprovalRequestGrantsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/approval-request-step-eligible-approvers.ts b/backend/src/db/schemas/approval-request-step-eligible-approvers.ts new file mode 100644 index 0000000000..987861e6b1 --- /dev/null +++ b/backend/src/db/schemas/approval-request-step-eligible-approvers.ts @@ -0,0 +1,24 @@ +// 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 ApprovalRequestStepEligibleApproversSchema = z.object({ + id: z.string().uuid(), + stepId: z.string().uuid(), + userId: z.string().uuid().nullable().optional(), + groupId: z.string().uuid().nullable().optional() +}); + +export type TApprovalRequestStepEligibleApprovers = z.infer; +export type TApprovalRequestStepEligibleApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TApprovalRequestStepEligibleApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/approval-request-steps.ts b/backend/src/db/schemas/approval-request-steps.ts new file mode 100644 index 0000000000..7b5233601f --- /dev/null +++ b/backend/src/db/schemas/approval-request-steps.ts @@ -0,0 +1,24 @@ +// 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 ApprovalRequestStepsSchema = z.object({ + id: z.string().uuid(), + requestId: z.string().uuid(), + stepNumber: z.number(), + name: z.string().nullable().optional(), + status: z.string(), + requiredApprovals: z.number(), + notifyApprovers: z.boolean().default(false).nullable().optional(), + startedAt: z.date().nullable().optional(), + completedAt: z.date().nullable().optional() +}); + +export type TApprovalRequestSteps = z.infer; +export type TApprovalRequestStepsInsert = Omit, TImmutableDBKeys>; +export type TApprovalRequestStepsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/approval-requests.ts b/backend/src/db/schemas/approval-requests.ts new file mode 100644 index 0000000000..c5d53fdf80 --- /dev/null +++ b/backend/src/db/schemas/approval-requests.ts @@ -0,0 +1,30 @@ +// 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 ApprovalRequestsSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + organizationId: z.string().uuid(), + policyId: z.string().uuid().nullable().optional(), + requesterId: z.string().uuid().nullable().optional(), + requesterName: z.string(), + requesterEmail: z.string(), + type: z.string(), + status: z.string(), + justification: z.string().nullable().optional(), + currentStep: z.number(), + requestData: z.unknown(), + expiresAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TApprovalRequests = z.infer; +export type TApprovalRequestsInsert = Omit, TImmutableDBKeys>; +export type TApprovalRequestsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificate-requests.ts b/backend/src/db/schemas/certificate-requests.ts new file mode 100644 index 0000000000..4013ea5bbc --- /dev/null +++ b/backend/src/db/schemas/certificate-requests.ts @@ -0,0 +1,35 @@ +// 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(), + acmeOrderId: z.string().uuid().nullable().optional() +}); + +export type TCertificateRequests = z.infer; +export type TCertificateRequestsInsert = Omit, TImmutableDBKeys>; +export type TCertificateRequestsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/identity-access-tokens.ts b/backend/src/db/schemas/identity-access-tokens.ts index 8f2b8b73bf..fcda42d4b3 100644 --- a/backend/src/db/schemas/identity-access-tokens.ts +++ b/backend/src/db/schemas/identity-access-tokens.ts @@ -22,7 +22,8 @@ export const IdentityAccessTokensSchema = z.object({ updatedAt: z.date(), name: z.string().nullable().optional(), authMethod: z.string(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + subOrganizationId: z.string().uuid().nullable().optional() }); export type TIdentityAccessTokens = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 78dcb1980c..528582c592 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -6,6 +6,14 @@ export * from "./access-approval-requests-reviewers"; export * from "./additional-privileges"; export * from "./api-keys"; export * from "./app-connections"; +export * from "./approval-policies"; +export * from "./approval-policy-step-approvers"; +export * from "./approval-policy-steps"; +export * from "./approval-request-approvals"; +export * from "./approval-request-grants"; +export * from "./approval-request-step-eligible-approvers"; +export * from "./approval-request-steps"; +export * from "./approval-requests"; export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; @@ -16,6 +24,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 444a6bd97d..fe38a9c9ba 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", @@ -222,7 +223,17 @@ export enum TableName { PkiAcmeOrder = "pki_acme_orders", PkiAcmeOrderAuth = "pki_acme_order_auths", PkiAcmeAuth = "pki_acme_auths", - PkiAcmeChallenge = "pki_acme_challenges" + PkiAcmeChallenge = "pki_acme_challenges", + + // Approval Policies + ApprovalPolicies = "approval_policies", + ApprovalPolicySteps = "approval_policy_steps", + ApprovalPolicyStepApprovers = "approval_policy_step_approvers", + ApprovalRequests = "approval_requests", + ApprovalRequestSteps = "approval_request_steps", + ApprovalRequestStepEligibleApprovers = "approval_request_step_eligible_approvers", + ApprovalRequestApprovals = "approval_request_approvals", + ApprovalRequestGrants = "approval_request_grants" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts index 325f6eddc6..f59aae5d87 100644 --- a/backend/src/db/schemas/pam-resources.ts +++ b/backend/src/db/schemas/pam-resources.ts @@ -13,7 +13,7 @@ export const PamResourcesSchema = z.object({ id: z.string().uuid(), projectId: z.string(), name: z.string(), - gatewayId: z.string().uuid(), + gatewayId: z.string().uuid().nullable().optional(), resourceType: z.string(), encryptedConnectionDetails: zodBuffer, createdAt: z.date(), diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts index f0592319bf..4f4460986f 100644 --- a/backend/src/db/schemas/pki-acme-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -13,7 +13,8 @@ export const PkiAcmeEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), encryptedEabSecret: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + skipDnsOwnershipVerification: z.boolean().default(false) }); export type TPkiAcmeEnrollmentConfigs = z.infer; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index c0dd891f0b..0cf9cf160f 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), acmeConfigId: z.string().uuid().nullable().optional(), - issuerType: z.string().default("ca") + issuerType: z.string().default("ca"), + externalConfigs: z.string().nullable().optional() }); export type TPkiCertificateProfiles = 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 a48e28e3dd..b46b525fe7 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 0000000000..518b7947e3 --- /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 0000000000..2ae94155b9 --- /dev/null +++ b/backend/src/ee/routes/v1/external-kms-routers/external-kms-endpoints.ts @@ -0,0 +1,300 @@ +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 { deterministicStringify } from "@app/lib/fn/object"; +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 credentialsToHash = deterministicStringify(configuration.credential); + + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(Buffer.from(credentialsToHash)) + .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 credentialsToHash = deterministicStringify(externalKmsConfiguration.credential); + + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(Buffer.from(credentialsToHash)) + .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 credentialsToHash = deterministicStringify(externalKmsConfiguration.credential); + + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(Buffer.from(credentialsToHash)) + .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 credentialsToHash = deterministicStringify(configuration.credential); + + const credentialsHash = crypto.nativeCrypto + .createHash("sha256") + .update(Buffer.from(credentialsToHash)) + .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 0000000000..97b600c102 --- /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 0000000000..da70b0f59f --- /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/index.ts b/backend/src/ee/routes/v1/index.ts index 367c2833c9..0e34ebcd4f 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -12,6 +12,7 @@ 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"; @@ -162,9 +163,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/license-router.ts b/backend/src/ee/routes/v1/license-router.ts index 2ccdce93ae..c3c48bdc9f 100644 --- a/backend/src/ee/routes/v1/license-router.ts +++ b/backend/src/ee/routes/v1/license-router.ts @@ -58,7 +58,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { const plan = await server.services.license.getOrgPlan({ actorId: req.permission.id, actor: req.permission.type, - actorOrgId: req.permission.rootOrgId, + actorOrgId: req.permission.orgId, + rootOrgId: req.permission.rootOrgId, actorAuthMethod: req.permission.authMethod, orgId: req.params.organizationId, refreshCache: req.query.refreshCache @@ -87,7 +88,8 @@ export const registerLicenseRouter = async (server: FastifyZodProvider) => { actor: req.permission.type, actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, - orgId: req.params.organizationId + orgId: req.params.organizationId, + rootOrgId: req.permission.rootOrgId }); return data; } 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 d3aadd5a41..9c7cf161d6 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -1,3 +1,13 @@ +import { + CreateAwsIamAccountSchema, + SanitizedAwsIamAccountWithResourceSchema, + UpdateAwsIamAccountSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesAccountSchema, + SanitizedKubernetesAccountWithResourceSchema, + UpdateKubernetesAccountSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLAccountSchema, SanitizedMySQLAccountWithResourceSchema, @@ -44,5 +54,23 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + accountResponseSchema: SanitizedKubernetesAccountWithResourceSchema, + createAccountSchema: CreateKubernetesAccountSchema, + updateAccountSchema: UpdateKubernetesAccountSchema + }); + }, + [PamResource.AwsIam]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.AwsIam, + accountResponseSchema: SanitizedAwsIamAccountWithResourceSchema, + createAccountSchema: CreateAwsIamAccountSchema, + updateAccountSchema: UpdateAwsIamAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 44e2a5ea11..4043c3cc83 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -22,7 +22,7 @@ export const registerPamResourceEndpoints = ({ folderId?: C["folderId"]; name: C["name"]; description?: C["description"]; - rotationEnabled: C["rotationEnabled"]; + rotationEnabled?: C["rotationEnabled"]; rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ @@ -65,7 +65,7 @@ export const registerPamResourceEndpoints = ({ folderId: req.body.folderId, name: req.body.name, description: req.body.description, - rotationEnabled: req.body.rotationEnabled, + rotationEnabled: req.body.rotationEnabled ?? false, rotationIntervalSeconds: req.body.rotationIntervalSeconds } } 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 0b7f89b6ce..802d430935 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 @@ -3,8 +3,11 @@ 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 { SanitizedAwsIamAccountWithResourceSchema } from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { SanitizedKubernetesAccountWithResourceSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; +import { GatewayAccessResponseSchema } from "@app/ee/services/pam-resource/pam-resource-schemas"; 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"; @@ -18,9 +21,19 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAccountSchema = z.union([ SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, - SanitizedMySQLAccountWithResourceSchema + SanitizedMySQLAccountWithResourceSchema, + SanitizedKubernetesAccountWithResourceSchema, + SanitizedAwsIamAccountWithResourceSchema ]); +const ListPamAccountsResponseSchema = z.object({ + accounts: SanitizedAccountSchema.array(), + folders: PamFoldersSchema.array(), + totalCount: z.number().default(0), + folderId: z.string().optional(), + folderPaths: z.record(z.string(), z.string()) +}); + export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -50,13 +63,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { .optional() }), response: { - 200: z.object({ - accounts: SanitizedAccountSchema.array(), - folders: PamFoldersSchema.array(), - totalCount: z.number().default(0), - folderId: z.string().optional(), - folderPaths: z.record(z.string(), z.string()) - }) + 200: ListPamAccountsResponseSchema } }, onRequest: verifyAuth([AuthMode.JWT]), @@ -93,7 +100,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { } }); - return { accounts, folders, totalCount, folderId, folderPaths }; + return { accounts, folders, totalCount, folderId, folderPaths } as z.infer; } }); @@ -106,7 +113,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) @@ -124,18 +132,20 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { }) }), response: { - 200: z.object({ - sessionId: z.string(), - resourceType: z.nativeEnum(PamResource), - relayClientCertificate: z.string(), - relayClientPrivateKey: z.string(), - relayServerCertificateChain: z.string(), - gatewayClientCertificate: z.string(), - gatewayClientPrivateKey: z.string(), - gatewayServerCertificateChain: z.string(), - relayHost: z.string(), - metadata: z.record(z.string(), z.string().optional()).optional() - }) + 200: z.discriminatedUnion("resourceType", [ + // Gateway-based resources (Postgres, MySQL, SSH) + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Postgres) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.MySQL) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.SSH) }), + GatewayAccessResponseSchema.extend({ resourceType: z.literal(PamResource.Kubernetes) }), + // AWS IAM (no gateway, returns console URL) + z.object({ + sessionId: z.string(), + resourceType: z.literal(PamResource.AwsIam), + consoleUrl: z.string().url(), + metadata: z.record(z.string(), z.string().optional()).optional() + }) + ]) } }, onRequest: verifyAuth([AuthMode.JWT]), @@ -151,7 +161,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 ); @@ -159,11 +171,12 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, orgId: req.permission.orgId, - projectId: response.projectId, + projectId: req.body.projectId, 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 5dae317da2..e3c9cf60c5 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,3 +1,13 @@ +import { + CreateAwsIamResourceSchema, + SanitizedAwsIamResourceSchema, + UpdateAwsIamResourceSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + CreateKubernetesResourceSchema, + SanitizedKubernetesResourceSchema, + UpdateKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { CreateMySQLResourceSchema, MySQLResourceSchema, @@ -44,5 +54,23 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.Kubernetes, + resourceResponseSchema: SanitizedKubernetesResourceSchema, + createResourceSchema: CreateKubernetesResourceSchema, + updateResourceSchema: UpdateKubernetesResourceSchema + }); + }, + [PamResource.AwsIam]: async (server: FastifyZodProvider) => { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.AwsIam, + resourceResponseSchema: SanitizedAwsIamResourceSchema, + createResourceSchema: CreateAwsIamResourceSchema, + updateResourceSchema: UpdateAwsIamResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts index ffbeae5c0f..e3803316ef 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -19,7 +19,7 @@ export const registerPamResourceEndpoints = ({ createResourceSchema: z.ZodType<{ projectId: T["projectId"]; connectionDetails: T["connectionDetails"]; - gatewayId: T["gatewayId"]; + gatewayId?: T["gatewayId"]; name: T["name"]; rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; @@ -103,7 +103,7 @@ export const registerPamResourceEndpoints = ({ type: EventType.PAM_RESOURCE_CREATE, metadata: { resourceType, - gatewayId: req.body.gatewayId, + ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }), name: req.body.name } } @@ -150,8 +150,8 @@ export const registerPamResourceEndpoints = ({ metadata: { resourceId: req.params.resourceId, resourceType, - gatewayId: req.body.gatewayId, - name: req.body.name + ...(req.body.gatewayId && { gatewayId: req.body.gatewayId }), + ...(req.body.name && { name: req.body.name }) } } }); 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 3536e7a99d..8e4326f3f1 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 @@ -1,6 +1,14 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + AwsIamResourceListItemSchema, + SanitizedAwsIamResourceSchema +} from "@app/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas"; +import { + KubernetesResourceListItemSchema, + SanitizedKubernetesResourceSchema +} from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; import { MySQLResourceListItemSchema, SanitizedMySQLResourceSchema @@ -22,13 +30,17 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedResourceSchema = z.union([ SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema, - SanitizedSSHResourceSchema + SanitizedSSHResourceSchema, + SanitizedKubernetesResourceSchema, + SanitizedAwsIamResourceSchema ]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, MySQLResourceListItemSchema, - SSHResourceListItemSchema + SSHResourceListItemSchema, + KubernetesResourceListItemSchema, + AwsIamResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 3c39a9516e..574b8b7c3d 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -2,10 +2,12 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { KubernetesSessionCredentialsSchema } from "@app/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas"; 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 { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { + HttpEventSchema, PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema @@ -17,7 +19,8 @@ import { AuthMode } from "@app/services/auth/auth-type"; const SessionCredentialsSchema = z.union([ SSHSessionCredentialsSchema, PostgresSessionCredentialsSchema, - MySQLSessionCredentialsSchema + MySQLSessionCredentialsSchema, + KubernetesSessionCredentialsSchema ]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { @@ -89,7 +92,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema, HttpEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index acf34cb3bf..f1ee79481b 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -315,6 +315,8 @@ export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { memberships: z .object({ id: z.string(), + actorGroupId: z.string().nullish(), + actorUserId: z.string().nullish(), roles: z .object({ role: z.string() diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 52e4f8e1f8..756a980eb7 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/user-additional-privilege-router.ts b/backend/src/ee/routes/v1/user-additional-privilege-router.ts index 926b222319..debaa12cfe 100644 --- a/backend/src/ee/routes/v1/user-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/user-additional-privilege-router.ts @@ -142,6 +142,7 @@ export const registerUserAdditionalPrivilegeRouter = async (server: FastifyZodPr data: { ...req.body, ...req.body.type, + name: req.body.slug, permissions: req.body.permissions ? // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore-error this is valid ts 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 47b3f5258c..23ba27b8a5 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 } @@ -168,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 } @@ -222,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 } @@ -276,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 } @@ -339,7 +335,6 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F privilege: { ...privilege, identityId: req.query.identityId, - projectMembershipId: privilege.projectId as string, projectId, slug: privilege.name } @@ -391,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/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 8d17028505..d58f3c2f73 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -4,6 +4,7 @@ import { registerAuth0ClientSecretRotationRouter } from "./auth0-client-secret-r import { registerAwsIamUserSecretRotationRouter } from "./aws-iam-user-secret-rotation-router"; import { registerAzureClientSecretRotationRouter } from "./azure-client-secret-rotation-router"; import { registerLdapPasswordRotationRouter } from "./ldap-password-rotation-router"; +import { registerMongoDBCredentialsRotationRouter } from "./mongodb-credentials-rotation-router"; import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rotation-router"; import { registerOktaClientSecretRotationRouter } from "./okta-client-secret-rotation-router"; @@ -26,5 +27,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter, - [SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter + [SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter, + [SecretRotation.MongoDBCredentials]: registerMongoDBCredentialsRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mongodb-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mongodb-credentials-rotation-router.ts new file mode 100644 index 0000000000..0b41f24f9d --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mongodb-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateMongoDBCredentialsRotationSchema, + MongoDBCredentialsRotationGeneratedCredentialsSchema, + MongoDBCredentialsRotationSchema, + UpdateMongoDBCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/mongodb-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerMongoDBCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.MongoDBCredentials, + server, + responseSchema: MongoDBCredentialsRotationSchema, + createSchema: CreateMongoDBCredentialsRotationSchema, + updateSchema: UpdateMongoDBCredentialsRotationSchema, + generatedCredentialsSchema: MongoDBCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 6ea6497e46..53346657bd 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -5,6 +5,7 @@ import { Auth0ClientSecretRotationListItemSchema } from "@app/ee/services/secret import { AwsIamUserSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/aws-iam-user-secret"; import { AzureClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/azure-client-secret"; import { LdapPasswordRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/ldap-password"; +import { MongoDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mongodb-credentials"; import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mysql-credentials"; import { OktaClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret"; @@ -27,7 +28,8 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ AwsIamUserSecretRotationListItemSchema, LdapPasswordRotationListItemSchema, OktaClientSecretRotationListItemSchema, - RedisCredentialsRotationListItemSchema + RedisCredentialsRotationListItemSchema, + MongoDBCredentialsRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { 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 ac7aa64041..64cd7c9360 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -49,6 +49,7 @@ import { TWebhookPayloads } from "@app/services/webhook/webhook-types"; import { WorkflowIntegration } from "@app/services/workflow-integration/workflow-integration-types"; import { KmipPermission } from "../kmip/kmip-enum"; +import { AcmeChallengeType, AcmeIdentifierType } from "../pki-acme/pki-acme-schemas"; import { ApprovalStatus } from "../secret-approval-request/secret-approval-request-types"; export type TListProjectAuditLogDTO = { @@ -78,7 +79,9 @@ export type TCreateAuditLogDTO = { | ScimClientActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; orgId?: string; projectId?: string; } & BaseAuthData; @@ -368,6 +371,7 @@ export enum EventType { ORG_ADMIN_BYPASS_SSO = "org-admin-bypassed-sso", USER_LOGIN = "user-login", SELECT_ORGANIZATION = "select-organization", + SELECT_SUB_ORGANIZATION = "select-sub-organization", CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", @@ -388,6 +392,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", @@ -556,7 +563,32 @@ export enum EventType { PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_CREATE = "pam-resource-create", PAM_RESOURCE_UPDATE = "pam-resource-update", - PAM_RESOURCE_DELETE = "pam-resource-delete" + PAM_RESOURCE_DELETE = "pam-resource-delete", + APPROVAL_POLICY_CREATE = "approval-policy-create", + APPROVAL_POLICY_UPDATE = "approval-policy-update", + APPROVAL_POLICY_DELETE = "approval-policy-delete", + APPROVAL_POLICY_LIST = "approval-policy-list", + APPROVAL_POLICY_GET = "approval-policy-get", + APPROVAL_REQUEST_GET = "approval-request-get", + APPROVAL_REQUEST_LIST = "approval-request-list", + APPROVAL_REQUEST_CREATE = "approval-request-create", + APPROVAL_REQUEST_APPROVE = "approval-request-approve", + APPROVAL_REQUEST_REJECT = "approval-request-reject", + APPROVAL_REQUEST_CANCEL = "approval-request-cancel", + APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list", + APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get", + APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke", + + // PKI ACME + CREATE_ACME_ACCOUNT = "create-acme-account", + RETRIEVE_ACME_ACCOUNT = "retrieve-acme-account", + CREATE_ACME_ORDER = "create-acme-order", + FINALIZE_ACME_ORDER = "finalize-acme-order", + DOWNLOAD_ACME_CERTIFICATE = "download-acme-certificate", + RESPOND_TO_ACME_CHALLENGE = "respond-to-acme-challenge", + PASS_ACME_CHALLENGE = "pass-acme-challenge", + ATTEMPT_ACME_CHALLENGE = "attempt-acme-challenge", + FAIL_ACME_CHALLENGE = "fail-acme-challenge" } export const filterableSecretEvents: EventType[] = [ @@ -597,6 +629,15 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeProfileActorMetadata { + profileId: string; +} + +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} + interface UnknownUserActorMetadata {} export interface UserActor { @@ -634,7 +675,25 @@ export interface ScimClientActor { metadata: ScimClientActorMetadata; } -export type Actor = UserActor | ServiceActor | IdentityActor | ScimClientActor | PlatformActor | KmipClientActor; +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + +export type Actor = + | UserActor + | ServiceActor + | IdentityActor + | ScimClientActor + | PlatformActor + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; @@ -2687,6 +2746,15 @@ interface SelectOrganizationEvent { }; } +interface SelectSubOrganizationEvent { + type: EventType.SELECT_SUB_ORGANIZATION; + metadata: { + organizationId: string; + organizationName: string; + rootOrganizationId: string; + }; +} + interface CreateCertificateTemplateEstConfig { type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG; metadata: { @@ -2846,7 +2914,6 @@ interface OrderCertificateFromProfile { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; metadata: { certificateProfileId: string; - orderId: string; profileName: string; }; } @@ -4074,6 +4141,7 @@ interface PamAccountAccessEvent { type: EventType.PAM_ACCOUNT_ACCESS; metadata: { accountId: string; + accountPath: string; accountName: string; duration?: string; }; @@ -4156,7 +4224,7 @@ interface PamResourceCreateEvent { type: EventType.PAM_RESOURCE_CREATE; metadata: { resourceType: string; - gatewayId: string; + gatewayId?: string; name: string; }; } @@ -4196,6 +4264,229 @@ 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; + }; +} + +interface ApprovalPolicyCreateEvent { + type: EventType.APPROVAL_POLICY_CREATE; + metadata: { + policyType: string; + name: string; + }; +} + +interface ApprovalPolicyUpdateEvent { + type: EventType.APPROVAL_POLICY_UPDATE; + metadata: { + policyType: string; + policyId: string; + name: string; + }; +} + +interface ApprovalPolicyDeleteEvent { + type: EventType.APPROVAL_POLICY_DELETE; + metadata: { + policyType: string; + policyId: string; + }; +} + +interface ApprovalPolicyListEvent { + type: EventType.APPROVAL_POLICY_LIST; + metadata: { + policyType: string; + count: number; + }; +} + +interface ApprovalPolicyGetEvent { + type: EventType.APPROVAL_POLICY_GET; + metadata: { + policyType: string; + policyId: string; + name: string; + }; +} + +interface ApprovalRequestGetEvent { + type: EventType.APPROVAL_REQUEST_GET; + metadata: { + policyType: string; + requestId: string; + status: string; + }; +} + +interface ApprovalRequestListEvent { + type: EventType.APPROVAL_REQUEST_LIST; + metadata: { + policyType: string; + count: number; + }; +} + +interface ApprovalRequestCreateEvent { + type: EventType.APPROVAL_REQUEST_CREATE; + metadata: { + policyType: string; + justification?: string; + requestDuration: string; + }; +} + +interface ApprovalRequestApproveEvent { + type: EventType.APPROVAL_REQUEST_APPROVE; + metadata: { + policyType: string; + requestId: string; + comment?: string; + }; +} + +interface ApprovalRequestRejectEvent { + type: EventType.APPROVAL_REQUEST_REJECT; + metadata: { + policyType: string; + requestId: string; + comment?: string; + }; +} + +interface ApprovalRequestCancelEvent { + type: EventType.APPROVAL_REQUEST_CANCEL; + metadata: { + policyType: string; + requestId: string; + }; +} + +interface ApprovalRequestGrantListEvent { + type: EventType.APPROVAL_REQUEST_GRANT_LIST; + metadata: { + policyType: string; + count: number; + }; +} + +interface ApprovalRequestGrantGetEvent { + type: EventType.APPROVAL_REQUEST_GRANT_GET; + metadata: { + policyType: string; + grantId: string; + status: string; + }; +} + +interface ApprovalRequestGrantRevokeEvent { + type: EventType.APPROVAL_REQUEST_GRANT_REVOKE; + metadata: { + policyType: string; + grantId: string; + revocationReason?: string; + }; +} + +interface CreateAcmeAccountEvent { + type: EventType.CREATE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + emails?: string[]; + }; +} + +interface RetrieveAcmeAccountEvent { + type: EventType.RETRIEVE_ACME_ACCOUNT; + metadata: { + accountId: string; + publicKeyThumbprint: string; + }; +} + +interface CreateAcmeOrderEvent { + type: EventType.CREATE_ACME_ORDER; + metadata: { + orderId: string; + identifiers: Array<{ + type: AcmeIdentifierType; + value: string; + }>; + }; +} + +interface FinalizeAcmeOrderEvent { + type: EventType.FINALIZE_ACME_ORDER; + metadata: { + orderId: string; + csr: string; + }; +} + +interface DownloadAcmeCertificateEvent { + type: EventType.DOWNLOAD_ACME_CERTIFICATE; + metadata: { + orderId: string; + }; +} + +interface RespondToAcmeChallengeEvent { + type: EventType.RESPOND_TO_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} +interface PassedAcmeChallengeEvent { + type: EventType.PASS_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + }; +} + +interface AttemptAcmeChallengeEvent { + type: EventType.ATTEMPT_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + +interface FailAcmeChallengeEvent { + type: EventType.FAIL_ACME_CHALLENGE; + metadata: { + challengeId: string; + type: AcmeChallengeType; + retryCount: number; + errorMessage: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4575,7 +4866,34 @@ export type Event = | PamResourceDeleteEvent | UpdateCertificateRenewalConfigEvent | DisableCertificateRenewalConfigEvent + | CreateCertificateRequestEvent + | GetCertificateRequestEvent + | GetCertificateFromRequestEvent | AutomatedRenewCertificate | AutomatedRenewCertificateFailed | UserLoginEvent - | SelectOrganizationEvent; + | SelectOrganizationEvent + | SelectSubOrganizationEvent + | ApprovalPolicyCreateEvent + | ApprovalPolicyUpdateEvent + | ApprovalPolicyDeleteEvent + | ApprovalPolicyListEvent + | ApprovalPolicyGetEvent + | ApprovalRequestGetEvent + | ApprovalRequestListEvent + | ApprovalRequestCreateEvent + | ApprovalRequestApproveEvent + | ApprovalRequestRejectEvent + | ApprovalRequestCancelEvent + | ApprovalRequestGrantListEvent + | ApprovalRequestGrantGetEvent + | ApprovalRequestGrantRevokeEvent + | CreateAcmeAccountEvent + | RetrieveAcmeAccountEvent + | CreateAcmeOrderEvent + | FinalizeAcmeOrderEvent + | DownloadAcmeCertificateEvent + | RespondToAcmeChallengeEvent + | PassedAcmeChallengeEvent + | AttemptAcmeChallengeEvent + | FailAcmeChallengeEvent; 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 5ead798faa..81657e15d3 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/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index 9614f32980..af246eacdc 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; @@ -299,6 +380,7 @@ export const externalKmsServiceFactory = ({ const findById = async ({ actor, actorId, actorOrgId, actorAuthMethod, id: kmsId }: TGetExternalKmsByIdDTO) => { const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new NotFoundError({ message: `Could not find KMS with ID '${kmsId}'` }); const { permission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, @@ -393,6 +475,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 2c248992fa..82e95f360c 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 6cb78a34e0..08a9a3fc72 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/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e34f9273f5..c8e311621f 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -350,6 +350,7 @@ export const licenseServiceFactory = ({ actor, actorId, actorOrgId, + rootOrgId, actorAuthMethod, projectId, refreshCache @@ -360,12 +361,12 @@ export const licenseServiceFactory = ({ orgId, actorOrgId, actorAuthMethod, - scope: OrganizationActionScope.ParentOrganization + scope: OrganizationActionScope.Any }); if (refreshCache) { - await refreshPlan(orgId); + await refreshPlan(rootOrgId); } - const plan = await getPlan(orgId, projectId); + const plan = await getPlan(rootOrgId, projectId); return plan; }; diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 8897eaabcf..75bf4b0674 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -102,6 +102,7 @@ export type TOrgPlansTableDTO = { export type TOrgPlanDTO = { projectId?: string; refreshCache?: boolean; + rootOrgId: string; } & TOrgPermission; export type TStartOrgTrialDTO = { diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts index aae703eeb0..71ef0fd7bd 100644 --- a/backend/src/ee/services/pam-account/pam-account-fns.ts +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -72,17 +72,24 @@ export const decryptAccount = async < account: T, projectId: string, kmsService: Pick -): Promise => { +): Promise< + Omit & { + credentials: TPamAccountCredentials; + lastRotationMessage: string | null; + } +> => { + const { encryptedCredentials, encryptedLastRotationMessage, ...rest } = account; + return { - ...account, + ...rest, credentials: await decryptAccountCredentials({ - encryptedCredentials: account.encryptedCredentials, + encryptedCredentials, projectId, kmsService }), - lastRotationMessage: account.encryptedLastRotationMessage + lastRotationMessage: encryptedLastRotationMessage ? await decryptAccountMessage({ - encryptedMessage: account.encryptedLastRotationMessage, + encryptedMessage: encryptedLastRotationMessage, projectId, kmsService }) 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 1eae8df15c..ec8b37c365 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,13 @@ +import path from "node:path"; + import { ForbiddenError, subject } from "@casl/ability"; import { ActionProjectType, OrganizationActionScope, TPamAccounts, TPamFolders, TPamResources } from "@app/db/schemas"; +import { + extractAwsAccountIdFromArn, + generateConsoleFederationUrl, + TAwsIamAccountCredentials +} from "@app/ee/services/pam-resource/aws-iam"; 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"; @@ -10,12 +17,23 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { DatabaseErrorCode } from "@app/lib/error-codes"; -import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { + BadRequestError, + DatabaseError, + ForbiddenRequestError, + NotFoundError, + PolicyViolationError +} from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; +import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approval-policy-dal"; +import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums"; +import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory"; +import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TPamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; @@ -27,7 +45,8 @@ 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 { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types"; +import { TSSHAccountCredentials } from "../pam-resource/ssh/ssh-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"; @@ -51,6 +70,9 @@ type TPamAccountServiceFactoryDep = { >; userDAL: TUserDALFactory; auditLogService: Pick; + approvalPolicyDAL: TApprovalPolicyDALFactory; + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; + pamSessionExpirationService: Pick; }; export type TPamAccountServiceFactory = ReturnType; @@ -67,7 +89,10 @@ export const pamAccountServiceFactory = ({ licenseService, kmsService, gatewayV2Service, - auditLogService + auditLogService, + approvalPolicyDAL, + approvalRequestGrantsDAL, + pamSessionExpirationService }: TPamAccountServiceFactoryDep) => { const create = async ( { @@ -135,7 +160,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); const validatedCredentials = await factory.validateAccountCredentials(credentials); @@ -250,7 +276,8 @@ export const pamAccountServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + account.projectId ); const decryptedCredentials = await decryptAccountCredentials({ @@ -279,17 +306,27 @@ export const pamAccountServiceFactory = ({ return decryptAccount(account, account.projectId, kmsService); } - const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); + try { + const updatedAccount = await pamAccountDAL.updateById(accountId, updateDoc); - return { - ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), - resource: { - id: resource.id, - name: resource.name, - resourceType: resource.resourceType, - rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + return { + ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } + }; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Account with name '${name}' already exists for this path` + }); } - }; + + throw err; + } }; const deleteById = async (id: string, actor: OrgServiceActor) => { @@ -428,7 +465,7 @@ export const pamAccountServiceFactory = ({ const totalCount = totalFolderCount + totalAccountCount; const decryptedAndPermittedAccounts: Array< - TPamAccounts & { + Omit & { resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; lastRotationMessage: string | null; @@ -487,7 +524,7 @@ export const pamAccountServiceFactory = ({ }; 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); @@ -497,50 +534,83 @@ 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` }); - const { permission } = await permissionService.getProjectPermission({ - actor: actor.type, - actorAuthMethod: actor.authMethod, - actorId: actor.id, - actorOrgId: actor.orgId, - projectId: account.projectId, - actionProjectType: ActionProjectType.PAM - }); + const fac = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.PamAccess](ApprovalPolicyType.PamAccess); - const accountPath = await getFullPamFolderPath({ - pamFolderDAL, - folderId: account.folderId, - projectId: account.projectId - }); + const inputs = { + resourceId: resource.id, + accountPath: path.join(folderPath, account.name) + }; - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPamAccountActions.Access, - subject(ProjectPermissionSub.PamAccounts, { - resourceName: resource.name, - accountName: account.name, - accountPath - }) - ); + const canAccess = await fac.canAccess(approvalRequestGrantsDAL, resource.projectId, actor.id, inputs); - const session = await pamSessionDAL.create({ - accountName: account.name, - actorEmail, - actorIp, - actorName, - actorUserAgent, - projectId: account.projectId, - resourceName: resource.name, - resourceType: resource.resourceType, - status: PamSessionStatus.Starting, - accountId: account.id, - userId: actor.id, - expiresAt: new Date(Date.now() + duration) - }); + // Grant does not exist, check policy and fallback to permission check + if (!canAccess) { + const policy = await fac.matchPolicy(approvalPolicyDAL, resource.projectId, inputs); + + if (policy) { + throw new PolicyViolationError({ + message: "A policy is in place for this resource", + details: { + policyId: policy.id, + policyName: policy.name, + policyType: policy.type + } + }); + } + + // If there isn't a policy in place, continue with checking permission + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: account.projectId, + actionProjectType: ActionProjectType.PAM + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPamAccountActions.Access, + subject(ProjectPermissionSub.PamAccounts, { + resourceName: resource.name, + accountName: account.name, + accountPath: folderPath + }) + ); + } const { connectionDetails, gatewayId, resourceType } = await decryptResource( resource, @@ -551,13 +621,98 @@ export const pamAccountServiceFactory = ({ const user = await userDAL.findById(actor.id); if (!user) throw new NotFoundError({ message: `User with ID '${actor.id}' not found` }); + if (resourceType === PamResource.AwsIam) { + const awsCredentials = (await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + kmsService, + projectId: account.projectId + })) as TAwsIamAccountCredentials; + + const { consoleUrl, expiresAt } = await generateConsoleFederationUrl({ + connectionDetails, + targetRoleArn: awsCredentials.targetRoleArn, + roleSessionName: actorEmail, + projectId: account.projectId, // Use project ID as External ID for security + sessionDuration: awsCredentials.defaultSessionDuration + }); + + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId: account.projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Active, // AWS IAM sessions are immediately active + accountId: account.id, + userId: actor.id, + expiresAt, + startedAt: new Date() + }); + + // Schedule session expiration job to run at expiresAt + await pamSessionExpirationService.scheduleSessionExpiration(session.id, expiresAt); + + return { + sessionId: session.id, + resourceType, + account, + consoleUrl, + metadata: { + awsAccountId: extractAwsAccountIdFromArn(connectionDetails.roleArn), + targetRoleArn: awsCredentials.targetRoleArn, + federatedUsername: actorEmail, + expiresAt: expiresAt.toISOString() + } + }; + } + + // For gateway-based resources (Postgres, MySQL, SSH), create session first + const session = await pamSessionDAL.create({ + accountName: account.name, + actorEmail, + actorIp, + actorName, + actorUserAgent, + projectId, + resourceName: resource.name, + resourceType: resource.resourceType, + status: PamSessionStatus.Starting, + accountId: account.id, + userId: actor.id, + expiresAt: new Date(Date.now() + duration) + }); + + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required for this resource type" }); + } + + const { host, port } = + resourceType !== PamResource.Kubernetes + ? connectionDetails + : (() => { + const url = new URL(connectionDetails.url); + let portNumber: number | undefined; + if (url.port) { + portNumber = Number(url.port); + } else { + portNumber = url.protocol === "https:" ? 443 : 80; + } + return { + host: url.hostname, + port: portNumber + }; + })(); + const gatewayConnectionDetails = await gatewayV2Service.getPAMConnectionDetails({ gatewayId, duration, sessionId: session.id, resourceType: resource.resourceType as PamResource, - host: connectionDetails.host, - port: connectionDetails.port, + host, + port, actorMetadata: { id: actor.id, type: actor.type, @@ -578,36 +733,43 @@ export const pamAccountServiceFactory = ({ const connectionCredentials = (await decryptResourceConnectionDetails({ encryptedConnectionDetails: resource.encryptedConnectionDetails, kmsService, - projectId: account.projectId + projectId })) as TSqlResourceConnectionDetails; - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, - projectId: account.projectId - }); + projectId + })) as TSqlAccountCredentials; metadata = { username: credentials.username, database: connectionCredentials.database, accountName: account.name, - accountPath + accountPath: folderPath }; } break; case PamResource.SSH: { - const credentials = await decryptAccountCredentials({ + const credentials = (await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, kmsService, - projectId: account.projectId - }); + projectId + })) as TSSHAccountCredentials; metadata = { username: credentials.username }; } break; + case PamResource.Kubernetes: + metadata = { + resourceName: resource.name, + accountName: account.name, + accountPath + }; + break; default: break; } @@ -622,7 +784,7 @@ export const pamAccountServiceFactory = ({ gatewayClientPrivateKey: gatewayConnectionDetails.gateway.clientPrivateKey, gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, relayHost: gatewayConnectionDetails.relayHost, - projectId: account.projectId, + projectId, account, metadata }; @@ -674,7 +836,7 @@ export const pamAccountServiceFactory = ({ const resource = await pamResourceDAL.findById(account.resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` }); - if (resource.gatewayIdentityId !== actor.id) { + if (resource.gatewayId && resource.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to fetch the PAM session credentials" }); @@ -738,7 +900,8 @@ export const pamAccountServiceFactory = ({ resourceType as PamResource, connectionDetails, gatewayId, - gatewayV2Service + gatewayV2Service, + account.projectId ); const newCredentials = await factory.rotateAccountCredentials( 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 b8498036e1..a20d2f7375 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -6,15 +6,18 @@ import { PamAccountOrderBy, PamAccountView } from "./pam-account-enums"; // DTOs export type TCreateAccountDTO = Pick< TPamAccount, - "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" ->; + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationIntervalSeconds" +> & { + rotationEnabled?: boolean; +}; export type TUpdateAccountDTO = Partial> & { accountId: string; }; export type TAccessAccountDTO = { - accountId: string; + accountPath: string; + projectId: string; actorEmail: string; actorIp: string; actorName: string; diff --git a/backend/src/ee/services/pam-folder/pam-folder-dal.ts b/backend/src/ee/services/pam-folder/pam-folder-dal.ts index 0b8aa8f60c..a21c401ffb 100644 --- a/backend/src/ee/services/pam-folder/pam-folder-dal.ts +++ b/backend/src/ee/services/pam-folder/pam-folder-dal.ts @@ -71,23 +71,29 @@ export const pamFolderDALFactory = (db: TDbClient) => { const findByPath = async (projectId: string, path: string, tx?: Knex) => { try { const dbInstance = tx || db.replicaNode(); + + const folders = await dbInstance(TableName.PamFolder) + .where(`${TableName.PamFolder}.projectId`, projectId) + .select(selectAllTableCols(TableName.PamFolder)); + const pathSegments = path.split("/").filter(Boolean); + if (pathSegments.length === 0) { + return undefined; + } + + const foldersByParentId = new Map(); + for (const folder of folders) { + const children = foldersByParentId.get(folder.parentId ?? null) ?? []; + children.push(folder); + foldersByParentId.set(folder.parentId ?? null, children); + } let parentId: string | null = null; - let currentFolder: Awaited> | undefined; + let currentFolder: (typeof folders)[0] | 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(); + for (const segment of pathSegments) { + const childFolders: typeof folders = foldersByParentId.get(parentId) || []; + currentFolder = childFolders.find((folder) => folder.name === segment); if (!currentFolder) { return undefined; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts new file mode 100644 index 0000000000..97415a0888 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-federation.ts @@ -0,0 +1,245 @@ +import { AssumeRoleCommand, Credentials, STSClient, STSClientConfig } from "@aws-sdk/client-sts"; + +import { CustomAWSHasher } from "@app/lib/aws/hashing"; +import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; + +import { TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; + +const AWS_STS_MIN_DURATION_SECONDS = 900; + +// We hardcode us-east-1 because: +// 1. IAM is global - roles can be assumed from any STS regional endpoint +// 2. The temporary credentials returned work globally across all AWS regions +// 3. The target account's resources can be in any region - it doesn't affect STS calls +const AWS_STS_DEFAULT_REGION = "us-east-1"; + +const createStsClient = (credentials?: Credentials): STSClient => { + const appCfg = getConfig(); + + const config: STSClientConfig = { + region: AWS_STS_DEFAULT_REGION, + useFipsEndpoint: crypto.isFipsModeEnabled(), + sha256: CustomAWSHasher + }; + + if (credentials) { + // Use provided credentials (for role chaining) + config.credentials = { + accessKeyId: credentials.AccessKeyId!, + secretAccessKey: credentials.SecretAccessKey!, + sessionToken: credentials.SessionToken + }; + } else if (appCfg.PAM_AWS_ACCESS_KEY_ID && appCfg.PAM_AWS_SECRET_ACCESS_KEY) { + // Use configured static credentials + config.credentials = { + accessKeyId: appCfg.PAM_AWS_ACCESS_KEY_ID, + secretAccessKey: appCfg.PAM_AWS_SECRET_ACCESS_KEY + }; + } + // Otherwise uses instance profile if hosting on AWS + + return new STSClient(config); +}; + +/** + * Assumes the PAM role and returns the credentials. + * Returns null if assumption fails (for validation) or throws if throwOnError is true. + */ +const assumePamRole = async ({ + connectionDetails, + projectId, + sessionDuration = AWS_STS_MIN_DURATION_SECONDS, + sessionNameSuffix = "validation", + throwOnError = false +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + projectId: string; + sessionDuration?: number; + sessionNameSuffix?: string; + throwOnError?: boolean; +}): Promise => { + const stsClient = createStsClient(); + + try { + const result = await stsClient.send( + new AssumeRoleCommand({ + RoleArn: connectionDetails.roleArn, + RoleSessionName: `infisical-pam-${sessionNameSuffix}-${Date.now()}`, + DurationSeconds: sessionDuration, + ExternalId: projectId + }) + ); + + if (!result.Credentials) { + if (throwOnError) { + throw new InternalServerError({ + message: "Failed to assume PAM role - AWS STS did not return credentials" + }); + } + return null; + } + + return result.Credentials; + } catch (error) { + if (throwOnError) { + throw new InternalServerError({ + message: `Failed to assume PAM role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + return null; + } +}; + +/** + * Assumes a target role using PAM role credentials (role chaining). + * Returns null if assumption fails (for validation) or throws if throwOnError is true. + */ +const assumeTargetRole = async ({ + pamCredentials, + targetRoleArn, + projectId, + roleSessionName, + sessionDuration = AWS_STS_MIN_DURATION_SECONDS, + throwOnError = false +}: { + pamCredentials: Credentials; + targetRoleArn: string; + projectId: string; + roleSessionName: string; + sessionDuration?: number; + throwOnError?: boolean; +}): Promise => { + const chainedStsClient = createStsClient(pamCredentials); + + try { + const result = await chainedStsClient.send( + new AssumeRoleCommand({ + RoleArn: targetRoleArn, + RoleSessionName: roleSessionName, + DurationSeconds: sessionDuration, + ExternalId: projectId + }) + ); + + if (!result.Credentials) { + if (throwOnError) { + throw new BadRequestError({ + message: "Failed to assume target role - verify the target role trust policy allows the PAM role to assume it" + }); + } + return null; + } + + return result.Credentials; + } catch (error) { + if (throwOnError) { + throw new InternalServerError({ + message: `Failed to assume target role - AWS STS did not return credentials: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + return null; + } +}; + +export const validatePamRoleConnection = async ( + connectionDetails: TAwsIamResourceConnectionDetails, + projectId: string +): Promise => { + try { + const credentials = await assumePamRole({ connectionDetails, projectId }); + return credentials !== null; + } catch { + return false; + } +}; + +export const validateTargetRoleAssumption = async ({ + connectionDetails, + targetRoleArn, + projectId +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + targetRoleArn: string; + projectId: string; +}): Promise => { + try { + const pamCredentials = await assumePamRole({ connectionDetails, projectId }); + if (!pamCredentials) return false; + + const targetCredentials = await assumeTargetRole({ + pamCredentials, + targetRoleArn, + projectId, + roleSessionName: `infisical-pam-target-validation-${Date.now()}` + }); + return targetCredentials !== null; + } catch { + return false; + } +}; + +/** + * Assumes the target role and generates a federated console sign-in URL. + */ +export const generateConsoleFederationUrl = async ({ + connectionDetails, + targetRoleArn, + roleSessionName, + projectId, + sessionDuration +}: { + connectionDetails: TAwsIamResourceConnectionDetails; + targetRoleArn: string; + roleSessionName: string; + projectId: string; + sessionDuration: number; +}): Promise<{ consoleUrl: string; expiresAt: Date }> => { + const pamCredentials = await assumePamRole({ + connectionDetails, + projectId, + sessionDuration, + sessionNameSuffix: "session", + throwOnError: true + }); + + const targetCredentials = await assumeTargetRole({ + pamCredentials: pamCredentials!, + targetRoleArn, + projectId, + roleSessionName, + sessionDuration, + throwOnError: true + }); + + const { AccessKeyId, SecretAccessKey, SessionToken, Expiration } = targetCredentials!; + + // Generate federation URL + const sessionJson = JSON.stringify({ + sessionId: AccessKeyId, + sessionKey: SecretAccessKey, + sessionToken: SessionToken + }); + + const federationEndpoint = "https://signin.aws.amazon.com/federation"; + + const signinTokenUrl = `${federationEndpoint}?Action=getSigninToken&Session=${encodeURIComponent(sessionJson)}`; + + const tokenResponse = await request.get<{ SigninToken?: string }>(signinTokenUrl); + + if (!tokenResponse.data.SigninToken) { + throw new InternalServerError({ + message: `AWS federation endpoint did not return a SigninToken: ${JSON.stringify(tokenResponse.data).substring(0, 200)}` + }); + } + + const consoleDestination = `https://console.aws.amazon.com/`; + const consoleUrl = `${federationEndpoint}?Action=login&SigninToken=${encodeURIComponent(tokenResponse.data.SigninToken)}&Destination=${encodeURIComponent(consoleDestination)}`; + + return { + consoleUrl, + expiresAt: Expiration ?? new Date(Date.now() + sessionDuration * 1000) + }; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts new file mode 100644 index 0000000000..8449086711 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-factory.ts @@ -0,0 +1,110 @@ +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { PamResource } from "../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { validatePamRoleConnection, validateTargetRoleAssumption } from "./aws-iam-federation"; +import { TAwsIamAccountCredentials, TAwsIamResourceConnectionDetails } from "./aws-iam-resource-types"; + +export const awsIamResourceFactory: TPamResourceFactory = ( + resourceType: PamResource, + connectionDetails: TAwsIamResourceConnectionDetails, + // AWS IAM doesn't use gateway + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gatewayId, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _gatewayV2Service, + projectId +) => { + const validateConnection = async () => { + try { + const isValid = await validatePamRoleConnection(connectionDetails, projectId ?? ""); + + if (!isValid) { + throw new BadRequestError({ + message: + "Unable to assume the PAM role. Verify the role ARN and ensure the trust policy allows Infisical to assume the role." + }); + } + + logger.info( + { roleArn: connectionDetails.roleArn }, + "[AWS IAM Resource Factory] PAM role connection validated successfully" + ); + + return connectionDetails; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + logger.error(error, "[AWS IAM Resource Factory] Failed to validate PAM role connection"); + + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + const isValid = await validateTargetRoleAssumption({ + connectionDetails, + targetRoleArn: credentials.targetRoleArn, + projectId: projectId ?? "" + }); + + if (!isValid) { + throw new BadRequestError({ + message: `Unable to assume the target role. Verify the target role ARN and ensure the PAM role (ARN: ${connectionDetails.roleArn}) has permission to assume it.` + }); + } + + logger.info( + { targetRoleArn: credentials.targetRoleArn }, + "[AWS IAM Resource Factory] Target role credentials validated successfully" + ); + + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + logger.error(error, "[AWS IAM Resource Factory] Failed to validate target role credentials"); + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + _rotationAccountCredentials, + currentCredentials + ) => { + return currentCredentials; + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TAwsIamAccountCredentials, + // AWS IAM has no censored credential values - role ARNs are not secrets + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _currentCredentials: TAwsIamAccountCredentials + ) => { + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts new file mode 100644 index 0000000000..d04018d492 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-fns.ts @@ -0,0 +1,24 @@ +import RE2 from "re2"; + +import { BadRequestError } from "@app/lib/errors"; + +import { AwsIamResourceListItemSchema } from "./aws-iam-resource-schemas"; + +export const getAwsIamResourceListItem = () => { + return { + name: AwsIamResourceListItemSchema.shape.name.value, + resource: AwsIamResourceListItemSchema.shape.resource.value + }; +}; + +/** + * Extract the AWS Account ID from an IAM Role ARN + * ARN format: arn:aws:iam::123456789012:role/RoleName + */ +export const extractAwsAccountIdFromArn = (roleArn: string): string => { + const match = roleArn.match(new RE2("^arn:aws:iam::(\\d{12}):role/")); + if (!match) { + throw new BadRequestError({ message: "Invalid IAM Role ARN format" }); + } + return match[1]; +}; diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts new file mode 100644 index 0000000000..2762977eba --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-schemas.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; + +// AWS STS session duration limits (in seconds) +// Role chaining (Infisical → PAM role → target role) limits max session to 1 hour +// @see https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html +const AWS_STS_MIN_SESSION_DURATION = 900; // 15 minutes +const AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING = 3600; // 1 hour + +export const AwsIamResourceConnectionDetailsSchema = z.object({ + roleArn: z.string().trim().min(1) +}); + +export const AwsIamAccountCredentialsSchema = z.object({ + targetRoleArn: z.string().trim().min(1).max(2048), + defaultSessionDuration: z.coerce + .number() + .min(AWS_STS_MIN_SESSION_DURATION) + .max(AWS_STS_MAX_SESSION_DURATION_ROLE_CHAINING) +}); + +const BaseAwsIamResourceSchema = BasePamResourceSchema.extend({ + resourceType: z.literal(PamResource.AwsIam), + gatewayId: z.string().uuid().nullable().optional() +}); + +export const AwsIamResourceSchema = BaseAwsIamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedAwsIamResourceSchema = BaseAwsIamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const AwsIamResourceListItemSchema = z.object({ + name: z.literal("AWS IAM"), + resource: z.literal(PamResource.AwsIam) +}); + +export const CreateAwsIamResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema, + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateAwsIamResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: AwsIamResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: AwsIamAccountCredentialsSchema.nullable().optional() +}); + +export const AwsIamAccountSchema = BasePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema +}); + +export const CreateAwsIamAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema, + // AWS IAM accounts don't support credential rotation - they use role assumption + rotationEnabled: z.boolean().default(false) +}); + +export const UpdateAwsIamAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: AwsIamAccountCredentialsSchema.optional() +}); + +export const SanitizedAwsIamAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: AwsIamAccountCredentialsSchema.pick({ + targetRoleArn: true, + defaultSessionDuration: true + }) +}); diff --git a/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts new file mode 100644 index 0000000000..732355371a --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/aws-iam-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + AwsIamAccountCredentialsSchema, + AwsIamAccountSchema, + AwsIamResourceConnectionDetailsSchema, + AwsIamResourceSchema +} from "./aws-iam-resource-schemas"; + +// Resources +export type TAwsIamResource = z.infer; +export type TAwsIamResourceConnectionDetails = z.infer; + +// Accounts +export type TAwsIamAccount = z.infer; +export type TAwsIamAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/aws-iam/index.ts b/backend/src/ee/services/pam-resource/aws-iam/index.ts new file mode 100644 index 0000000000..8e41fa48a7 --- /dev/null +++ b/backend/src/ee/services/pam-resource/aws-iam/index.ts @@ -0,0 +1,5 @@ +export * from "./aws-iam-federation"; +export * from "./aws-iam-resource-factory"; +export * from "./aws-iam-resource-fns"; +export * from "./aws-iam-resource-schemas"; +export * from "./aws-iam-resource-types"; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts new file mode 100644 index 0000000000..21d7da806c --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-enums.ts @@ -0,0 +1,3 @@ +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts new file mode 100644 index 0000000000..dddeb37baf --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-factory.ts @@ -0,0 +1,225 @@ +import axios, { AxiosError } from "axios"; +import https from "https"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway/types"; +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 { KubernetesAuthMethod } from "./kubernetes-resource-enums"; +import { TKubernetesAccountCredentials, TKubernetesResourceConnectionDetails } from "./kubernetes-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TKubernetesResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (baseUrl: string, httpsAgent: https.Agent) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const url = new URL(connectionDetails.url); + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + + let targetPort: number; + if (url.port) { + targetPort = Number(url.port); + } else if (url.protocol === "https:") { + targetPort = 443; + } else { + targetPort = 80; + } + + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort + }); + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + const httpsAgent = new https.Agent({ + ca: connectionDetails.sslCertificate, + rejectUnauthorized: connectionDetails.sslRejectUnauthorized, + servername: targetHost + }); + return withGatewayV2Proxy( + async (proxyPort) => { + const protocol = url.protocol === "https:" ? "https" : "http"; + const baseUrl = `${protocol}://localhost:${proxyPort}`; + return operation(baseUrl, httpsAgent); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay, + httpsAgent + } + ); +}; + +export const kubernetesResourceFactory: TPamResourceFactory< + TKubernetesResourceConnectionDetails, + TKubernetesAccountCredentials +> = (resourceType, connectionDetails, gatewayId, gatewayV2Service) => { + const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + // Validate connection by checking API server version + try { + await axios.get(`${baseUrl}/version`, { + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + }); + } catch (error) { + if (error instanceof AxiosError) { + // If we get a 401/403, it means we reached the API server but need auth - that's fine for connection validation + if (error.response?.status === 401 || error.response?.status === 403) { + logger.info( + { status: error.response.status }, + "[Kubernetes Resource Factory] Kubernetes connection validation succeeded (auth required)" + ); + return connectionDetails; + } + throw new BadRequestError({ + message: `Unable to connect to Kubernetes API server: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + + logger.info("[Kubernetes Resource Factory] Kubernetes connection validation succeeded"); + return connectionDetails; + } + ); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials< + TKubernetesAccountCredentials + > = async (credentials) => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { + await executeWithGateway( + { connectionDetails, gatewayId, resourceType }, + gatewayV2Service, + async (baseUrl, httpsAgent) => { + const { authMethod } = credentials; + if (authMethod === KubernetesAuthMethod.ServiceAccountToken) { + // Validate service account token using SelfSubjectReview API (whoami) + // This endpoint doesn't require any special permissions from the service account + try { + await axios.post( + `${baseUrl}/apis/authentication.k8s.io/v1/selfsubjectreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "SelfSubjectReview" + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${credentials.serviceAccountToken}` + }, + ...(httpsAgent ? { httpsAgent } : {}), + signal: AbortSignal.timeout(EXTERNAL_REQUEST_TIMEOUT), + timeout: EXTERNAL_REQUEST_TIMEOUT + } + ); + + logger.info("[Kubernetes Resource Factory] Kubernetes service account token authentication successful"); + } catch (error) { + if (error instanceof AxiosError) { + if (error.response?.status === 401 || error.response?.status === 403) { + throw new BadRequestError({ + message: + "Account credentials invalid. Service account token is not valid or does not have required permissions." + }); + } + throw new BadRequestError({ + message: `Unable to validate account credentials: ${error.response?.statusText || error.message}` + }); + } + throw error; + } + } else { + throw new BadRequestError({ + message: `Unsupported Kubernetes auth method: ${authMethod as string}` + }); + } + } + ); + return credentials; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials< + TKubernetesAccountCredentials + > = async () => { + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: not implemented` + }); + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TKubernetesAccountCredentials, + currentCredentials: TKubernetesAccountCredentials + ) => { + if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) { + return updatedAccountCredentials; + } + + if ( + updatedAccountCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken && + currentCredentials.authMethod === KubernetesAuthMethod.ServiceAccountToken + ) { + if (updatedAccountCredentials.serviceAccountToken === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + serviceAccountToken: currentCredentials.serviceAccountToken + }; + } + } + + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts new file mode 100644 index 0000000000..b7d3546c5c --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-fns.ts @@ -0,0 +1,8 @@ +import { KubernetesResourceListItemSchema } from "./kubernetes-resource-schemas"; + +export const getKubernetesResourceListItem = () => { + return { + name: KubernetesResourceListItemSchema.shape.name.value, + resource: KubernetesResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts new file mode 100644 index 0000000000..2a0f06d0e5 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-schemas.ts @@ -0,0 +1,94 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreateGatewayPamResourceSchema, + BaseCreatePamAccountSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema +} from "../pam-resource-schemas"; +import { KubernetesAuthMethod } from "./kubernetes-resource-enums"; + +export const BaseKubernetesResourceSchema = BasePamResourceSchema.extend({ + resourceType: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceListItemSchema = z.object({ + name: z.literal("Kubernetes"), + resource: z.literal(PamResource.Kubernetes) +}); + +export const KubernetesResourceConnectionDetailsSchema = z.object({ + url: z.string().url().trim().max(500), + sslRejectUnauthorized: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const KubernetesServiceAccountTokenCredentialsSchema = z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken), + serviceAccountToken: z.string().trim().max(10000) +}); + +export const KubernetesAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + KubernetesServiceAccountTokenCredentialsSchema +]); + +export const KubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedKubernetesResourceSchema = BaseKubernetesResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: z + .discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) + .nullable() + .optional() +}); + +export const CreateKubernetesResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema, + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateKubernetesResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ + connectionDetails: KubernetesResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: KubernetesAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const KubernetesAccountSchema = BasePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const CreateKubernetesAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema +}); + +export const UpdateKubernetesAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: KubernetesAccountCredentialsSchema.optional() +}); + +export const SanitizedKubernetesAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KubernetesAuthMethod.ServiceAccountToken) + }) + ]) +}); + +// Sessions +export const KubernetesSessionCredentialsSchema = KubernetesResourceConnectionDetailsSchema.and( + KubernetesAccountCredentialsSchema +); diff --git a/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts new file mode 100644 index 0000000000..d23163d267 --- /dev/null +++ b/backend/src/ee/services/pam-resource/kubernetes/kubernetes-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + KubernetesAccountCredentialsSchema, + KubernetesAccountSchema, + KubernetesResourceConnectionDetailsSchema, + KubernetesResourceSchema +} from "./kubernetes-resource-schemas"; + +// Resources +export type TKubernetesResource = z.infer; +export type TKubernetesResourceConnectionDetails = z.infer; + +// Accounts +export type TKubernetesAccount = z.infer; +export type TKubernetesAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts index 8d3589a8ab..cb12a4c8cc 100644 --- a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { BaseSqlAccountCredentialsSchema, @@ -43,12 +43,12 @@ export const MySQLResourceListItemSchema = z.object({ resource: z.literal(PamResource.MySQL) }); -export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreateMySQLResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: MySQLResourceConnectionDetailsSchema, rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() }); -export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdateMySQLResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() }); 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 9e5cbc9857..e5b76a882c 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-dal.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-dal.ts @@ -14,7 +14,7 @@ export const pamResourceDALFactory = (db: TDbClient) => { const findById = async (id: string, tx?: Knex) => { const doc = await (tx || db.replicaNode())(TableName.PamResource) - .join(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) + .leftJoin(TableName.GatewayV2, `${TableName.PamResource}.gatewayId`, `${TableName.GatewayV2}.id`) .select(selectAllTableCols(TableName.PamResource)) .select(db.ref("name").withSchema(TableName.GatewayV2).as("gatewayName")) .select(db.ref("identityId").withSchema(TableName.GatewayV2).as("gatewayIdentityId")) 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 e4ec043e14..c8c57b03b2 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,7 +1,9 @@ export enum PamResource { Postgres = "postgres", MySQL = "mysql", - SSH = "ssh" + SSH = "ssh", + Kubernetes = "kubernetes", + AwsIam = "aws-iam" } export enum PamResourceOrderBy { 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 e2d0a50f81..bf8d13d664 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,3 +1,5 @@ +import { awsIamResourceFactory } from "./aws-iam/aws-iam-resource-factory"; +import { kubernetesResourceFactory } from "./kubernetes/kubernetes-resource-factory"; import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; @@ -8,5 +10,7 @@ type TPamResourceFactoryImplementation = TPamResourceFactory = { [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation, - [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation + [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation, + [PamResource.Kubernetes]: kubernetesResourceFactory as TPamResourceFactoryImplementation, + [PamResource.AwsIam]: awsIamResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index cad087d2fd..581f3425ac 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -3,12 +3,19 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getAwsIamResourceListItem } from "./aws-iam/aws-iam-resource-fns"; +import { getKubernetesResourceListItem } from "./kubernetes/kubernetes-resource-fns"; import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { - return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [ + getPostgresResourceListItem(), + getMySQLResourceListItem(), + getAwsIamResourceListItem(), + getKubernetesResourceListItem() + ].sort((a, b) => a.name.localeCompare(b.name)); }; // Resource diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 17ed1ccd19..a3db6b446c 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -3,6 +3,18 @@ import { z } from "zod"; import { PamAccountsSchema, PamResourcesSchema } from "@app/db/schemas"; import { slugSchema } from "@app/server/lib/schemas"; +export const GatewayAccessResponseSchema = z.object({ + sessionId: z.string(), + relayClientCertificate: z.string(), + relayClientPrivateKey: z.string(), + relayServerCertificateChain: z.string(), + gatewayClientCertificate: z.string(), + gatewayClientPrivateKey: z.string(), + gatewayServerCertificateChain: z.string(), + relayHost: z.string(), + metadata: z.record(z.string(), z.string().optional()).optional() +}); + // Resources export const BasePamResourceSchema = PamResourcesSchema.omit({ encryptedConnectionDetails: true, @@ -10,17 +22,27 @@ export const BasePamResourceSchema = PamResourcesSchema.omit({ resourceType: true }); -export const BaseCreatePamResourceSchema = z.object({ +const CoreCreatePamResourceSchema = z.object({ projectId: z.string().uuid(), - gatewayId: z.string().uuid(), name: slugSchema({ field: "name" }) }); -export const BaseUpdatePamResourceSchema = z.object({ - gatewayId: z.string().uuid().optional(), +export const BaseCreateGatewayPamResourceSchema = CoreCreatePamResourceSchema.extend({ + gatewayId: z.string().uuid() +}); + +export const BaseCreatePamResourceSchema = CoreCreatePamResourceSchema; + +const CoreUpdatePamResourceSchema = z.object({ name: slugSchema({ field: "name" }).optional() }); +export const BaseUpdateGatewayPamResourceSchema = CoreUpdatePamResourceSchema.extend({ + gatewayId: z.string().uuid().optional() +}); + +export const BaseUpdatePamResourceSchema = CoreUpdatePamResourceSchema; + // Accounts export const BasePamAccountSchema = PamAccountsSchema.omit({ encryptedCredentials: true 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 0ebca02b57..abbbf651b7 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -92,7 +92,8 @@ export const pamResourceServiceFactory = ({ resourceType, connectionDetails, gatewayId, - gatewayV2Service + gatewayV2Service, + projectId ); const validatedConnectionDetails = await factory.validateConnection(); @@ -162,7 +163,8 @@ export const pamResourceServiceFactory = ({ resource.resourceType as PamResource, connectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ @@ -189,7 +191,8 @@ export const pamResourceServiceFactory = ({ resource.resourceType as PamResource, decryptedConnectionDetails, resource.gatewayId, - gatewayV2Service + gatewayV2Service, + resource.projectId ); let finalCredentials = { ...rotationAccountCredentials }; 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 9da0948018..5291e044ac 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,6 +1,18 @@ import { OrderByDirection, TProjectPermission } from "@app/lib/types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TAwsIamAccount, + TAwsIamAccountCredentials, + TAwsIamResource, + TAwsIamResourceConnectionDetails +} from "./aws-iam/aws-iam-resource-types"; +import { + TKubernetesAccount, + TKubernetesAccountCredentials, + TKubernetesResource, + TKubernetesResourceConnectionDetails +} from "./kubernetes/kubernetes-resource-types"; import { TMySQLAccount, TMySQLAccountCredentials, @@ -22,22 +34,30 @@ import { } from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource | TAwsIamResource | TKubernetesResource; export type TPamResourceConnectionDetails = | TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails - | TSSHResourceConnectionDetails; + | TSSHResourceConnectionDetails + | TKubernetesResourceConnectionDetails + | TAwsIamResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; -// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount | TAwsIamAccount | TKubernetesAccount; + +export type TPamAccountCredentials = + | TPostgresAccountCredentials + // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents + | TMySQLAccountCredentials + | TSSHAccountCredentials + | TKubernetesAccountCredentials + | TAwsIamAccountCredentials; // Resource DTOs -export type TCreateResourceDTO = Pick< - TPamResource, - "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" ->; +export type TCreateResourceDTO = Pick & { + gatewayId?: string | null; + rotationAccountCredentials?: TPamAccountCredentials | null; +}; export type TUpdateResourceDTO = Partial> & { resourceId: string; @@ -65,8 +85,9 @@ export type TPamResourceFactoryRotateAccountCredentials = ( resourceType: PamResource, connectionDetails: T, - gatewayId: string, - gatewayV2Service: Pick + gatewayId: string | null | undefined, + gatewayV2Service: Pick, + projectId: string | null | undefined ) => { validateConnection: TPamResourceFactoryValidateConnection; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index bbe83a3a4b..fd58484f71 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { BaseSqlAccountCredentialsSchema, @@ -40,12 +40,12 @@ export const PostgresResourceListItemSchema = z.object({ resource: z.literal(PamResource.Postgres) }); -export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreatePostgresResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: PostgresResourceConnectionDetailsSchema, rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); -export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdatePostgresResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); 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 b3128c4228..26fa7ff39d 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 @@ -233,6 +233,10 @@ export const sqlResourceFactory: TPamResourceFactory { const validateConnection = async () => { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { await client.validate(true); @@ -255,6 +259,10 @@ export const sqlResourceFactory: TPamResourceFactory { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway( { connectionDetails, @@ -296,6 +304,10 @@ export const sqlResourceFactory: TPamResourceFactory { const newPassword = alphaNumericNanoId(32); + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + try { return await executeWithGateway( { 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 index b90aa00c6c..dfbb071e24 100644 --- a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -60,6 +60,10 @@ export const sshResourceFactory: TPamResourceFactory { const validateConnection = async () => { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { return new Promise((resolve, reject) => { const client = new Client(); @@ -131,6 +135,10 @@ export const sshResourceFactory: TPamResourceFactory { try { + if (!gatewayId) { + throw new BadRequestError({ message: "Gateway ID is required" }); + } + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { return new Promise((resolve, reject) => { const client = new Client(); 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 index 97d462369a..01b8ef2c06 100644 --- a/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts @@ -2,13 +2,13 @@ import { z } from "zod"; import { PamResource } from "../pam-resource-enums"; import { + BaseCreateGatewayPamResourceSchema, BaseCreatePamAccountSchema, - BaseCreatePamResourceSchema, BasePamAccountSchema, BasePamAccountSchemaWithResource, BasePamResourceSchema, - BaseUpdatePamAccountSchema, - BaseUpdatePamResourceSchema + BaseUpdateGatewayPamResourceSchema, + BaseUpdatePamAccountSchema } from "../pam-resource-schemas"; import { SSHAuthMethod } from "./ssh-resource-enums"; @@ -73,12 +73,12 @@ export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({ .optional() }); -export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({ +export const CreateSSHResourceSchema = BaseCreateGatewayPamResourceSchema.extend({ connectionDetails: SSHResourceConnectionDetailsSchema, rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() }); -export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({ +export const UpdateSSHResourceSchema = BaseUpdateGatewayPamResourceSchema.extend({ connectionDetails: SSHResourceConnectionDetailsSchema.optional(), rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() }); diff --git a/backend/src/ee/services/pam-session/pam-session-dal.ts b/backend/src/ee/services/pam-session/pam-session-dal.ts index f8b3a33939..094614859c 100644 --- a/backend/src/ee/services/pam-session/pam-session-dal.ts +++ b/backend/src/ee/services/pam-session/pam-session-dal.ts @@ -4,6 +4,8 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { ormify, selectAllTableCols } from "@app/lib/knex"; +import { PamSessionStatus } from "./pam-session-enums"; + export type TPamSessionDALFactory = ReturnType; export const pamSessionDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.PamSession); @@ -22,5 +24,19 @@ export const pamSessionDALFactory = (db: TDbClient) => { return session; }; - return { ...orm, findById }; + const expireSessionById = async (sessionId: string, tx?: Knex) => { + const now = new Date(); + + const updatedCount = await (tx || db)(TableName.PamSession) + .where("id", sessionId) + .whereIn("status", [PamSessionStatus.Active, PamSessionStatus.Starting]) + .update({ + status: PamSessionStatus.Ended, + endedAt: now + }); + + return updatedCount; + }; + + return { ...orm, findById, expireSessionById }; }; diff --git a/backend/src/ee/services/pam-session/pam-session-enums.ts b/backend/src/ee/services/pam-session/pam-session-enums.ts index 87731f5778..33afe95e4a 100644 --- a/backend/src/ee/services/pam-session/pam-session-enums.ts +++ b/backend/src/ee/services/pam-session/pam-session-enums.ts @@ -1,6 +1,6 @@ export enum PamSessionStatus { Starting = "starting", // Starting, user connecting to resource Active = "active", // Active, user is connected to resource - Ended = "ended", // Ended by user + Ended = "ended", // Ended by user or automatically expired after expiresAt timestamp Terminated = "terminated" // Terminated by an admin } 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 db24931966..b336d15c80 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -11,6 +11,8 @@ export const PamSessionCommandLogSchema = z.object({ // SSH Terminal Event schemas export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); +export const HttpEventTypeSchema = z.enum(["request", "response"]); + export const TerminalEventSchema = z.object({ timestamp: z.coerce.date(), eventType: TerminalEventTypeSchema, @@ -18,8 +20,29 @@ export const TerminalEventSchema = z.object({ elapsedTime: z.number() // Seconds since session start (for replay) }); +export const HttpBaseEventSchema = z.object({ + timestamp: z.coerce.date(), + requestId: z.string(), + eventType: TerminalEventTypeSchema, + headers: z.record(z.string(), z.array(z.string())), + body: z.string().optional() +}); + +export const HttpRequestEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.request), + method: z.string(), + url: z.string() +}); + +export const HttpResponseEventSchema = HttpBaseEventSchema.extend({ + eventType: z.literal(HttpEventTypeSchema.Values.response), + status: z.string() +}); + +export const HttpEventSchema = z.discriminatedUnion("eventType", [HttpRequestEventSchema, HttpResponseEventSchema]); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) + logs: z.array(z.union([PamSessionCommandLogSchema, HttpEventSchema, 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 18c185cacf..bdb82e6502 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -34,9 +34,40 @@ export const pamSessionServiceFactory = ({ licenseService, kmsService }: TPamSessionServiceFactoryDep) => { + // Helper to check and update expired sessions when viewing session details (redundancy for scheduled job) + // Only applies to non-gateway sessions (e.g., AWS IAM) - gateway sessions are managed by the gateway + // This is intentionally only called in getById (session details view), not in list + const checkAndExpireSessionIfNeeded = async < + T extends { id: string; status: string; expiresAt: Date | null; gatewayIdentityId?: string | null } + >( + session: T + ): Promise => { + // Skip gateway-based sessions - they have their own lifecycle managed by the gateway + if (session.gatewayIdentityId) { + return session; + } + + const isActive = session.status === PamSessionStatus.Active || session.status === PamSessionStatus.Starting; + const isExpired = session.expiresAt && new Date(session.expiresAt) <= new Date(); + + if (isActive && isExpired) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const updatedSession = await pamSessionDAL.updateById(session.id, { + status: PamSessionStatus.Ended, + endedAt: new Date() + }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return { ...session, ...updatedSession }; + } + + return session; + }; + const getById = async (sessionId: string, actor: OrgServiceActor) => { - const session = await pamSessionDAL.findById(sessionId); - if (!session) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + const sessionFromDb = await pamSessionDAL.findById(sessionId); + if (!sessionFromDb) throw new NotFoundError({ message: `Session with ID '${sessionId}' not found` }); + + const session = await checkAndExpireSessionIfNeeded(sessionFromDb); const { permission } = await permissionService.getProjectPermission({ actor: actor.type, @@ -116,7 +147,7 @@ export const pamSessionServiceFactory = ({ OrgPermissionSubjects.Gateway ); - if (session.gatewayIdentityId !== actor.id) { + if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to update logs for this session" }); } @@ -158,7 +189,7 @@ export const pamSessionServiceFactory = ({ OrgPermissionSubjects.Gateway ); - if (session.gatewayIdentityId !== actor.id) { + if (session.gatewayIdentityId && session.gatewayIdentityId !== actor.id) { throw new ForbiddenRequestError({ message: "Identity does not have access to end this session" }); } } else if (actor.type === ActorType.USER) { diff --git a/backend/src/ee/services/pam-session/pam-session-types.ts b/backend/src/ee/services/pam-session/pam-session-types.ts index 893f930e51..8f202b6723 100644 --- a/backend/src/ee/services/pam-session/pam-session-types.ts +++ b/backend/src/ee/services/pam-session/pam-session-types.ts @@ -1,13 +1,19 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; +import { + HttpEventSchema, + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; export type TTerminalEvent = z.infer; +export type THttpEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: (TPamSessionCommandLog | TTerminalEvent)[]; + logs: (TPamSessionCommandLog | TTerminalEvent | THttpEvent)[]; }; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 81814a67c6..561cb3bb55 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -3,8 +3,11 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, ProjectPermissionAppConnectionActions, + ProjectPermissionApprovalRequestActions, + ProjectPermissionApprovalRequestGrantActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -44,9 +47,7 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Settings, ProjectPermissionSub.Environments, ProjectPermissionSub.Tags, - ProjectPermissionSub.AuditLogs, ProjectPermissionSub.IpAllowList, - ProjectPermissionSub.CertificateAuthorities, ProjectPermissionSub.PkiAlerts, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.SshCertificateAuthorities, @@ -67,6 +68,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 +110,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionCertificateActions.Edit, ProjectPermissionCertificateActions.Create, ProjectPermissionCertificateActions.Delete, - ProjectPermissionCertificateActions.ReadPrivateKey + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionCertificateActions.Import ], ProjectPermissionSub.Certificates ); @@ -325,6 +341,16 @@ const buildAdminPermissionRules = () => { can([ProjectPermissionPamSessionActions.Read], ProjectPermissionSub.PamSessions); + can( + [ProjectPermissionApprovalRequestActions.Read, ProjectPermissionApprovalRequestActions.Create], + ProjectPermissionSub.ApprovalRequests + ); + + can( + [ProjectPermissionApprovalRequestGrantActions.Read, ProjectPermissionApprovalRequestGrantActions.Revoke], + ProjectPermissionSub.ApprovalRequestGrants + ); + return rules; }; @@ -460,7 +486,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 +494,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionCertificateActions.Read, ProjectPermissionCertificateActions.Edit, ProjectPermissionCertificateActions.Create, - ProjectPermissionCertificateActions.Delete + ProjectPermissionCertificateActions.Delete, + ProjectPermissionCertificateActions.Import ], ProjectPermissionSub.Certificates ); @@ -571,6 +598,8 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.PamAccounts ); + can([ProjectPermissionApprovalRequestActions.Create], ProjectPermissionSub.ApprovalRequests); + return rules; }; @@ -599,7 +628,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 19340644a9..85efd19aaa 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 { @@ -214,6 +224,16 @@ export enum ProjectPermissionPamSessionActions { // Terminate = "terminate" } +export enum ProjectPermissionApprovalRequestActions { + Read = "read", + Create = "create" +} + +export enum ProjectPermissionApprovalRequestGrantActions { + Read = "read", + Revoke = "revoke" +} + export const isCustomProjectRole = (slug: string) => !Object.values(ProjectMembershipRole).includes(slug as ProjectMembershipRole); @@ -264,7 +284,9 @@ export enum ProjectPermissionSub { PamResources = "pam-resources", PamAccounts = "pam-accounts", PamSessions = "pam-sessions", - CertificateProfiles = "certificate-profiles" + CertificateProfiles = "certificate-profiles", + ApprovalRequests = "approval-requests", + ApprovalRequestGrants = "approval-request-grants" } export type SecretSubjectFields = { @@ -292,7 +314,8 @@ export type SecretSyncSubjectFields = { }; export type PkiSyncSubjectFields = { - subscriberName: string; + subscriberName?: string; + name: string; }; export type DynamicSecretSubjectFields = { @@ -332,6 +355,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 +442,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 +506,15 @@ export type ProjectPermissionSet = ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) ] | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] - | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; + | [ + ProjectPermissionCertificateProfileActions, + ( + | ProjectPermissionSub.CertificateProfiles + | (ForcedSubject & CertificateProfileSubjectFields) + ) + ] + | [ProjectPermissionApprovalRequestActions, ProjectPermissionSub.ApprovalRequests] + | [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants]; 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 +632,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 +769,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 +821,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 +992,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) @@ -967,6 +1119,18 @@ const GeneralPermissionSchema = [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPamSessionActions).describe( "Describe what action an entity can take." ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.ApprovalRequests).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalRequestActions).describe( + "Describe what action an entity can take." + ) + }), + z.object({ + subject: z.literal(ProjectPermissionSub.ApprovalRequestGrants).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalRequestGrantActions).describe( + "Describe what action an entity can take." + ) }) ]; @@ -1130,7 +1294,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-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts index 74cbd14660..abc3a654b5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -122,6 +122,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { const result = await (tx || db)(TableName.PkiAcmeChallenge) .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) .join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`) + .join( + TableName.PkiCertificateProfile, + `${TableName.PkiAcmeAccount}.profileId`, + `${TableName.PkiCertificateProfile}.id` + ) .select( selectAllTableCols(TableName.PkiAcmeChallenge), db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), @@ -131,7 +136,9 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"), db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"), db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"), - db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint") + db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint"), + db.ref("profileId").withSchema(TableName.PkiAcmeAccount).as("profileId"), + db.ref("projectId").withSchema(TableName.PkiCertificateProfile).as("projectId") ) // For all challenges, acquire update lock on the auth to avoid race conditions .forUpdate(TableName.PkiAcmeAuth) @@ -149,6 +156,8 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { authExpiresAt, accountId, accountPublicKeyThumbprint, + profileId, + projectId, ...challenge } = result; return { @@ -161,7 +170,11 @@ export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { expiresAt: authExpiresAt, account: { id: accountId, - publicKeyThumbprint: accountPublicKeyThumbprint + publicKeyThumbprint: accountPublicKeyThumbprint, + project: { + id: projectId + }, + profileId } } }; 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 e084c8f0a9..06ad692365 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,10 +1,13 @@ 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"; import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; import { AcmeConnectionError, @@ -18,17 +21,22 @@ import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; type TPkiAcmeChallengeServiceFactoryDep = { acmeChallengeDAL: Pick< TPkiAcmeChallengeDALFactory, - "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById" + | "transaction" + | "findByIdForChallengeValidation" + | "markAsValidCascadeById" + | "markAsInvalidCascadeById" + | "updateById" >; + auditLogService: Pick; }; export const pkiAcmeChallengeServiceFactory = ({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }: 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) { @@ -52,49 +60,92 @@ 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 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 + return acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Processing }, tx); + }); + }; + + 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}` }); - 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); + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: EventType.PASS_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } } - 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" }); - } - 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); + }); + } catch (exp) { + let finalAttempt = false; + 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); + finalAttempt = true; + } + try { // Properly type and inspect the error if (axios.isAxiosError(exp)) { const axiosError = exp as AxiosError; @@ -102,31 +153,51 @@ export const pkiAcmeChallengeServiceFactory = ({ const errorMessage = axiosError.message; if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) { - return new AcmeConnectionError({ message: "Connection refused" }); + throw new AcmeConnectionError({ message: "Connection refused" }); } if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) { - return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + 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"); - return new AcmeConnectionError({ message: "Connection timed out" }); + throw 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" }); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } 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" }); + throw exp; } - return exp; + logger.error(exp, "Unknown error validating ACME challenge response"); + throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } catch (outterExp) { + await auditLogService.createAuditLog({ + projectId: challenge.auth.account.project.id, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: challenge.auth.account.profileId, + accountId: challenge.auth.account.id + } + }, + event: { + type: finalAttempt ? EventType.FAIL_ACME_CHALLENGE : EventType.ATTEMPT_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType, + retryCount, + errorMessage: exp instanceof Error ? exp.message : "Unknown error" + } + } + }); + throw outterExp; } - }); - if (error) { - throw error; } }; - return { validateChallengeResponse }; + return { markChallengeAsReady, validateChallengeResponse }; }; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts index 5aab0be631..cf7d96e876 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types"; export type TPkiAcmeOrderDALFactory = ReturnType; @@ -19,6 +20,43 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { } }; + const findWithCertificateRequestForSync = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder) + .leftJoin( + TableName.CertificateRequests, + `${TableName.PkiAcmeOrder}.id`, + `${TableName.CertificateRequests}.acmeOrderId` + ) + .select( + selectAllTableCols(TableName.PkiAcmeOrder), + db.ref("id").withSchema(TableName.CertificateRequests).as("certificateRequestId"), + db.ref("status").withSchema(TableName.CertificateRequests).as("certificateRequestStatus"), + db.ref("certificateId").withSchema(TableName.CertificateRequests).as("certificateId") + ) + .forUpdate(TableName.PkiAcmeOrder) + .where(`${TableName.PkiAcmeOrder}.id`, id) + .first(); + if (!order) { + return null; + } + const { certificateRequestId, certificateRequestStatus, certificateId, ...details } = order; + return { + ...details, + certificateRequest: + certificateRequestId && certificateRequestStatus + ? { + id: certificateRequestId, + status: certificateRequestStatus as CertificateRequestStatus, + certificateId + } + : undefined + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id with certificate request" }); + } + }; + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { try { const rows = await (tx || db)(TableName.PkiAcmeOrder) @@ -72,6 +110,7 @@ export const pkiAcmeOrderDALFactory = (db: TDbClient) => { return { ...pkiAcmeOrderOrm, findByIdForFinalization, + findWithCertificateRequestForSync, findByAccountAndOrderIdWithAuthorizations, listByAccountId }; 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 0000000000..8511599812 --- /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 23b86d172c..ebd17c26a8 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -6,8 +6,8 @@ export enum AcmeIdentifierType { export enum AcmeOrderStatus { Pending = "pending", - Processing = "processing", Ready = "ready", + Processing = "processing", Valid = "valid", Invalid = "invalid" } 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 89ba17dd5a..705cd6c392 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -7,8 +7,10 @@ import { importJWK, JWSHeaderParameters } from "jose"; +import { Knex } from "knex"; import { z, ZodError } from "zod"; +import { TPkiAcmeOrders } from "@app/db/schemas"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; @@ -17,31 +19,34 @@ import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { isPrivateIp } from "@app/lib/ip/ipRange"; import { logger } from "@app/lib/logger"; -import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { 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, - CertKeyUsage, - CertSubjectAlternativeNameType -} from "@app/services/certificate/certificate-types"; -import { orderCertificate } from "@app/services/certificate-authority/acme/acme-certificate-authority-fns"; +import { CertSubjectAlternativeNameType } from "@app/services/certificate/certificate-types"; 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 { + TCertificateIssuanceQueueFactory, + TIssueCertificateFromProfileJobData +} from "@app/services/certificate-authority/certificate-issuance-queue"; +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 { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service"; +import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-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"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-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, @@ -93,24 +99,23 @@ import { type TPkiAcmeServiceFactoryDep = { projectDAL: Pick; - appConnectionDAL: Pick; - certificateDAL: Pick; certificateAuthorityDAL: Pick; - externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateBodyDAL: Pick; - certificateSecretDAL: Pick; + certificateTemplateV2DAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" >; acmeOrderDAL: Pick< TPkiAcmeOrderDALFactory, + | "findById" | "create" | "transaction" | "updateById" | "findByAccountAndOrderIdWithAuthorizations" | "findByIdForFinalization" + | "findWithCertificateRequestForSync" | "listByAccountId" >; acmeAuthDAL: Pick; @@ -126,18 +131,20 @@ type TPkiAcmeServiceFactoryDep = { >; licenseService: Pick; certificateV3Service: Pick; - acmeChallengeService: TPkiAcmeChallengeServiceFactory; + certificateTemplateV2Service: Pick; + certificateRequestService: Pick; + certificateIssuanceQueue: Pick; + acmeChallengeService: Pick; + pkiAcmeQueueService: Pick; + auditLogService: Pick; }; export const pkiAcmeServiceFactory = ({ projectDAL, - appConnectionDAL, - certificateDAL, certificateAuthorityDAL, - externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, - certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -147,7 +154,12 @@ export const pkiAcmeServiceFactory = ({ kmsService, licenseService, certificateV3Service, - acmeChallengeService + certificateTemplateV2Service, + certificateRequestService, + certificateIssuanceQueue, + acmeChallengeService, + pkiAcmeQueueService, + auditLogService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { const validateAcmeProfile = async (profileId: string): Promise => { const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); @@ -352,6 +364,52 @@ export const pkiAcmeServiceFactory = ({ }; }; + const checkAndSyncAcmeOrderStatus = async ({ orderId }: { orderId: string }): Promise => { + const order = await acmeOrderDAL.findById(orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + if (order.status !== AcmeOrderStatus.Processing) { + // We only care about processing orders, as they are the ones that have async certificate requests + return order; + } + return acmeOrderDAL.transaction(async (tx) => { + // Lock the order for syncing with async cert request + const orderWithCertificateRequest = await acmeOrderDAL.findWithCertificateRequestForSync(orderId, tx); + if (!orderWithCertificateRequest) { + throw new NotFoundError({ message: "ACME order not found" }); + } + // Check the status again after we have acquired the lock, as things may have changed since we last checked + if ( + orderWithCertificateRequest.status !== AcmeOrderStatus.Processing || + !orderWithCertificateRequest.certificateRequest + ) { + return orderWithCertificateRequest; + } + let newStatus: AcmeOrderStatus | undefined; + let newCertificateId: string | undefined; + switch (orderWithCertificateRequest.certificateRequest.status) { + case CertificateRequestStatus.PENDING: + break; + case CertificateRequestStatus.ISSUED: + newStatus = AcmeOrderStatus.Valid; + newCertificateId = orderWithCertificateRequest.certificateRequest.certificateId ?? undefined; + break; + case CertificateRequestStatus.FAILED: + newStatus = AcmeOrderStatus.Invalid; + break; + default: + throw new AcmeServerInternalError({ + message: `Invalid certificate request status: ${orderWithCertificateRequest.certificateRequest.status as string}` + }); + } + if (newStatus) { + return acmeOrderDAL.updateById(orderId, { status: newStatus, certificateId: newCertificateId }, tx); + } + return orderWithCertificateRequest; + }); + }; + const getAcmeDirectory = async (profileId: string): Promise => { const profile = await validateAcmeProfile(profileId); return { @@ -434,6 +492,23 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } if (existingAccount) { + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.RETRIEVE_ACME_ACCOUNT, + metadata: { + accountId: existingAccount.id, + publicKeyThumbprint + } + } + }); + return { status: 200, body: { @@ -506,7 +581,25 @@ export const pkiAcmeServiceFactory = ({ publicKeyThumbprint, emails: contact ?? [] }); - // TODO: create audit log here + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_PROFILE, + metadata: { + profileId: profile.id + } + }, + event: { + type: EventType.CREATE_ACME_ACCOUNT, + metadata: { + accountId: newAccount.id, + publicKeyThumbprint: newAccount.publicKeyThumbprint, + emails: newAccount.emails + } + } + }); + return { status: 201, body: { @@ -555,6 +648,8 @@ export const pkiAcmeServiceFactory = ({ accountId: string; payload: TCreateAcmeOrderPayload; }): Promise> => { + const profile = await validateAcmeProfile(profileId); + const skipDnsOwnershipVerification = profile.acmeConfig?.skipDnsOwnershipVerification ?? false; // TODO: check and see if we have existing orders for this account that meet the criteria // if we do, return the existing order // TODO: check the identifiers and see if are they even allowed for this profile. @@ -580,7 +675,7 @@ export const pkiAcmeServiceFactory = ({ const createdOrder = await acmeOrderDAL.create( { accountId: account.id, - status: AcmeOrderStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeOrderStatus.Ready : AcmeOrderStatus.Pending, notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, // TODO: read config from the profile to get the expiration time instead @@ -599,7 +694,7 @@ export const pkiAcmeServiceFactory = ({ const auth = await acmeAuthDAL.create( { accountId: account.id, - status: AcmeAuthStatus.Pending, + status: skipDnsOwnershipVerification ? AcmeAuthStatus.Valid : AcmeAuthStatus.Pending, identifierType: identifier.type, identifierValue: identifier.value, // RFC 8555 suggests a token with at least 128 bits of entropy @@ -611,15 +706,17 @@ export const pkiAcmeServiceFactory = ({ }, tx ); - // TODO: support other challenge types here. Currently only HTTP-01 is supported. - await acmeChallengeDAL.create( - { - authId: auth.id, - status: AcmeChallengeStatus.Pending, - type: AcmeChallengeType.HTTP_01 - }, - tx - ); + if (!skipDnsOwnershipVerification) { + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + status: AcmeChallengeStatus.Pending, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + } return auth; }) ); @@ -631,7 +728,26 @@ export const pkiAcmeServiceFactory = ({ })), tx ); - // TODO: create audit log here + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId: account.profileId, + accountId: account.id + } + }, + event: { + type: EventType.CREATE_ACME_ORDER, + metadata: { + orderId: createdOrder.id, + identifiers: authorizations.map((auth) => ({ + type: auth.identifierType as AcmeIdentifierType, + value: auth.identifierValue + })) + } + } + }); return { ...createdOrder, authorizations, account }; }); @@ -661,9 +777,12 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } + // Sync order first in case if there is a certificate request that needs to be processed + await checkAndSyncAcmeOrderStatus({ orderId }); + const updatedOrder = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId))!; return { status: 200, - body: buildAcmeOrderResource({ profileId, order }), + body: buildAcmeOrderResource({ profileId, order: updatedOrder }), headers: { Location: buildUrl(profileId, `/orders/${orderId}`), Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` @@ -671,6 +790,129 @@ export const pkiAcmeServiceFactory = ({ }; }; + const processCertificateIssuanceForOrder = async ({ + caType, + accountId, + actorOrgId, + profileId, + orderId, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, + tx + }: { + caType: CaType; + accountId: string; + actorOrgId: string; + profileId: string; + orderId: string; + csr: string; + finalizingOrder: { + notBefore?: Date | null; + notAfter?: Date | null; + }; + certificateRequest: ReturnType; + profile: TCertificateProfileWithConfigs; + ca: Awaited>; + tx?: Knex; + }): Promise<{ certificateId?: string; certIssuanceJobData?: TIssueCertificateFromProfileJobData }> => { + if (caType === CaType.INTERNAL) { + const result = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, + validity: !finalizingOrder.notAfter + ? { + // 47 days, the default TTL comes with Let's Encrypt + // TODO: read config from the profile to get the expiration time instead + ttl: `${47}d` + } + : // ttl is not used if notAfter is provided + ({ ttl: "0d" } as const), + enrollmentType: EnrollmentType.ACME + }); + return { + certificateId: result.certificateId + }; + } + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + const updatedCertificateRequest = { + ...certificateRequest, + keyAlgorithm: extractedKeyAlgorithm, + signatureAlgorithm: extractedSignatureAlgorithm, + validity: 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)); + return { ttl: `${diffDays}d` }; + })() + : certificateRequest.validity + }; + + const template = await certificateTemplateV2DAL.findById(profile.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + template.id, + updatedCertificateRequest + ); + if (!validationResult.isValid) { + throw new AcmeBadCSRError({ message: `Invalid CSR: ${validationResult.errors.join(", ")}` }); + } + + const certRequest = await certificateRequestService.createCertificateRequest({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + projectId: profile.projectId, + caId: ca.id, + profileId: profile.id, + commonName: updatedCertificateRequest.commonName ?? "", + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","), + notBefore: updatedCertificateRequest.notBefore, + notAfter: updatedCertificateRequest.notAfter, + status: CertificateRequestStatus.PENDING, + acmeOrderId: orderId, + csr, + tx + }); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const csrPem = csrObj.toString("pem"); + return { + certIssuanceJobData: { + certificateId: orderId, + profileId: profile.id, + caId: profile.caId || "", + ttl: updatedCertificateRequest.validity?.ttl || "1y", + signatureAlgorithm: updatedCertificateRequest.signatureAlgorithm || "", + keyAlgorithm: updatedCertificateRequest.keyAlgorithm || "", + commonName: updatedCertificateRequest.commonName || "", + altNames: updatedCertificateRequest.subjectAlternativeNames?.map((san) => san.value) || [], + keyUsages: updatedCertificateRequest.keyUsages?.map((usage) => usage.toString()) ?? [], + extendedKeyUsages: updatedCertificateRequest.extendedKeyUsages?.map((usage) => usage.toString()) ?? [], + certificateRequestId: certRequest.id, + csr: csrPem + } + }; + }; + const finalizeAcmeOrder = async ({ profileId, accountId, @@ -695,7 +937,11 @@ export const pkiAcmeServiceFactory = ({ throw new NotFoundError({ message: "ACME order not found" }); } if (order.status === AcmeOrderStatus.Ready) { - const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { + const { + order: updatedOrder, + error, + certIssuanceJobData + } = await acmeOrderDAL.transaction(async (tx) => { const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; @@ -742,71 +988,31 @@ export const pkiAcmeServiceFactory = ({ } const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; let errorToReturn: Error | undefined; + let certIssuanceJobDataToReturn: TIssueCertificateFromProfileJobData | undefined; try { - const { certificateId } = await (async () => { - if (caType === CaType.INTERNAL) { - const result = await certificateV3Service.signCertificateFromProfile({ - actor: ActorType.ACME_ACCOUNT, - actorId: accountId, - actorAuthMethod: null, - actorOrgId, - profileId, - csr, - notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, - notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, - validity: !finalizingOrder.notAfter - ? { - // 47 days, the default TTL comes with Let's Encrypt - // TODO: read config from the profile to get the expiration time instead - ttl: `${47}d` - } - : // ttl is not used if notAfter is provided - ({ ttl: "0d" } as const), - enrollmentType: EnrollmentType.ACME - }); - return { certificateId: result.certificateId }; - } - const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; - const csrObj = new x509.Pkcs10CertificateRequest(csr); - const csrPem = csrObj.toString("pem"); - // TODO: for internal CA, we rely on the internal certificate authority service to check CSR against the template - // we should check the CSR against the template here - // TODO: this is pretty slow, and we are holding the transaction open for a long time, - // we should queue the certificate issuance to a background job instead - const cert = await orderCertificate( - { - caId: certificateAuthority!.id, - // 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 - keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT], - extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH] - }, - { - appConnectionDAL, - certificateAuthorityDAL, - externalCertificateAuthorityDAL, - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - kmsService, - projectDAL - } - ); - return { certificateId: cert.id }; - })(); + const result = await processCertificateIssuanceForOrder({ + caType, + accountId, + actorOrgId, + profileId, + orderId, + csr, + finalizingOrder, + certificateRequest, + profile, + ca, + tx + }); await acmeOrderDAL.updateById( orderId, { - status: AcmeOrderStatus.Valid, + status: result.certificateId ? AcmeOrderStatus.Valid : AcmeOrderStatus.Processing, csr, - certificateId + certificateId: result.certificateId }, tx ); + certIssuanceJobDataToReturn = result.certIssuanceJobData; } catch (exp) { await acmeOrderDAL.updateById( orderId, @@ -821,19 +1027,46 @@ 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" }); + errorToReturn = new AcmeServerInternalError({ + message: "Failed to sign certificate with internal error" + }); } } return { order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, - error: errorToReturn + error: errorToReturn, + certIssuanceJobData: certIssuanceJobDataToReturn }; }); if (error) { throw error; } + if (certIssuanceJobData) { + // TODO: ideally, this should be done inside the transaction, but the pg-boss queue doesn't support external transactions + // as it seems to be. we need to commit the transaction before queuing the job, otherwise the job will fail (not found error). + await certificateIssuanceQueue.queueCertificateIssuance(certIssuanceJobData); + } order = updatedOrder; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.FINALIZE_ACME_ORDER, + metadata: { + orderId: updatedOrder.id, + csr: updatedOrder.csr! + } + } + }); } else if (order.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); } @@ -861,14 +1094,16 @@ export const pkiAcmeServiceFactory = ({ if (!order) { throw new NotFoundError({ message: "ACME order not found" }); } - if (order.status !== AcmeOrderStatus.Valid) { + // Sync order first in case if there is a certificate request that needs to be processed + const syncedOrder = await checkAndSyncAcmeOrderStatus({ orderId }); + if (syncedOrder.status !== AcmeOrderStatus.Valid) { throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); } - if (!order.certificateId) { + if (!syncedOrder.certificateId) { throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" }); } - const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId }); + const certBody = await certificateBodyDAL.findOne({ certId: syncedOrder.certificateId }); const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: profile.projectId, projectDAL, @@ -889,6 +1124,24 @@ export const pkiAcmeServiceFactory = ({ const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n"); const certChain = certificateChain.trim().replace("\n", "\r\n"); + + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.DOWNLOAD_ACME_CERTIFICATE, + metadata: { + orderId + } + } + }); + return { status: 200, body: @@ -971,12 +1224,31 @@ export const pkiAcmeServiceFactory = ({ authzId: string; challengeId: string; }): Promise> => { + const profile = await validateAcmeProfile(profileId); const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId); 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))!; + await auditLogService.createAuditLog({ + projectId: profile.projectId, + actor: { + type: ActorType.ACME_ACCOUNT, + metadata: { + profileId, + accountId + } + }, + event: { + type: EventType.RESPOND_TO_ACME_CHALLENGE, + metadata: { + challengeId, + type: challenge.type as AcmeChallengeType + } + } + }); return { status: 200, body: { 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 3ddb424f1b..6607ce711b 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/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 7206bd2931..38411627a5 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-service.ts b/backend/src/ee/services/scim/scim-service.ts index 0542bc911d..465ed3ee58 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -390,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( @@ -426,7 +424,8 @@ export const scimServiceFactory = ({ aliasType, externalId, emails: email ? [email.toLowerCase()] : [], - orgId + orgId, + isEmailVerified: trustScimEmails }, tx ); diff --git a/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/index.ts new file mode 100644 index 0000000000..876ab836d3 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./mongodb-credentials-rotation-constants"; +export * from "./mongodb-credentials-rotation-fns"; +export * from "./mongodb-credentials-rotation-schemas"; +export * from "./mongodb-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-constants.ts new file mode 100644 index 0000000000..82b43f45a4 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-constants.ts @@ -0,0 +1,27 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MONGODB_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "MongoDB Credentials", + type: SecretRotation.MongoDBCredentials, + connection: AppConnection.MongoDB, + template: { + createUserStatement: `use [DATABASE_NAME] +db.createUser({ + user: "infisical_user_1", + pwd: "temporary_password", + roles: [{ role: "readWrite", db: "[DATABASE_NAME]" }] +}) + +db.createUser({ + user: "infisical_user_2", + pwd: "temporary_password", + roles: [{ role: "readWrite", db: "[DATABASE_NAME]" }] +})`, + secretsMapping: { + username: "MONGODB_DB_USERNAME", + password: "MONGODB_DB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-fns.ts new file mode 100644 index 0000000000..aad286ecc5 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-fns.ts @@ -0,0 +1,191 @@ +/* eslint-disable no-await-in-loop */ +import { MongoClient } from "mongodb"; + +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { createMongoClient } from "@app/services/app-connection/mongodb/mongodb-connection-fns"; + +import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils"; +import { + TMongoDBCredentialsRotationGeneratedCredentials, + TMongoDBCredentialsRotationWithConnection +} from "./mongodb-credentials-rotation-types"; + +const redactPasswords = (e: unknown, credentials: TMongoDBCredentialsRotationGeneratedCredentials) => { + const error = e as Error; + + if (!error?.message) return "Unknown error"; + + let redactedMessage = error.message; + + credentials.forEach(({ password }) => { + redactedMessage = redactedMessage.replaceAll(password, "*******************"); + }); + + return redactedMessage; +}; + +export const mongodbCredentialsRotationFactory: TRotationFactory< + TMongoDBCredentialsRotationWithConnection, + TMongoDBCredentialsRotationGeneratedCredentials +> = (secretRotation) => { + const { + connection, + parameters: { username1, username2 }, + activeIndex, + secretsMapping + } = secretRotation; + + const passwordRequirement = DEFAULT_PASSWORD_REQUIREMENTS; + + const $getClient = async () => { + let client: MongoClient | null = null; + try { + client = await createMongoClient(connection.credentials, { validateConnection: true }); + return client; + } catch (err) { + if (client) await client.close(); + throw err; + } + }; + + const $validateCredentials = async (credentials: TMongoDBCredentialsRotationGeneratedCredentials[number]) => { + let client: MongoClient | null = null; + try { + client = await createMongoClient(connection.credentials, { + authCredentials: { + username: credentials.username, + password: credentials.password + }, + validateConnection: true + }); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + if (client) await client.close(); + } + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + // For MongoDB, since we get existing users, we change both their passwords + // on issue to invalidate their existing passwords + const credentialsSet = [ + { username: username1, password: generatePassword(passwordRequirement) }, + { username: username2, password: generatePassword(passwordRequirement) } + ]; + + let client: MongoClient | null = null; + try { + client = await $getClient(); + const db = client.db(connection.credentials.database); + + for (const credentials of credentialsSet) { + await db.command({ + updateUser: credentials.username, + pwd: credentials.password + }); + } + } catch (error) { + throw new Error(redactPasswords(error, credentialsSet)); + } finally { + if (client) await client.close(); + } + + for (const credentials of credentialsSet) { + await $validateCredentials(credentials); + } + + return callback(credentialsSet[0]); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentialsToRevoke, + callback + ) => { + const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ + username, + password: generatePassword(passwordRequirement) + })); + + let client: MongoClient | null = null; + try { + client = await $getClient(); + const db = client.db(connection.credentials.database); + + for (const credentials of revokedCredentials) { + await db.command({ + updateUser: credentials.username, + pwd: credentials.password + }); + } + } catch (error) { + throw new Error(redactPasswords(error, revokedCredentials)); + } finally { + if (client) await client.close(); + } + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + _, + callback + ) => { + const credentials = { + username: activeIndex === 0 ? username2 : username1, + password: generatePassword(passwordRequirement) + }; + + let client: MongoClient | null = null; + try { + client = await $getClient(); + const db = client.db(connection.credentials.database); + + await db.command({ + updateUser: credentials.username, + pwd: credentials.password + }); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + if (client) await client.close(); + } + + await $validateCredentials(credentials); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ( + generatedCredentials + ) => { + const { username, password } = secretsMapping; + + const secrets = [ + { + key: username, + value: generatedCredentials.username + }, + { + key: password, + value: generatedCredentials.password + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-schemas.ts new file mode 100644 index 0000000000..9a5335f5f6 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationGeneratedCredentialsSchema, + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MongoDBCredentialsRotationGeneratedCredentialsSchema = SqlCredentialsRotationGeneratedCredentialsSchema; +export const MongoDBCredentialsRotationParametersSchema = SqlCredentialsRotationParametersSchema; +export const MongoDBCredentialsRotationTemplateSchema = SqlCredentialsRotationTemplateSchema; + +const MongoDBCredentialsRotationSecretsMappingSchema = z.object({ + username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.MONGODB_CREDENTIALS.username), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.MONGODB_CREDENTIALS.password) +}); + +export const MongoDBCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MongoDBCredentials).extend({ + type: z.literal(SecretRotation.MongoDBCredentials), + parameters: MongoDBCredentialsRotationParametersSchema, + secretsMapping: MongoDBCredentialsRotationSecretsMappingSchema +}); + +export const CreateMongoDBCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.MongoDBCredentials +).extend({ + parameters: MongoDBCredentialsRotationParametersSchema, + secretsMapping: MongoDBCredentialsRotationSecretsMappingSchema +}); + +export const UpdateMongoDBCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.MongoDBCredentials +).extend({ + parameters: MongoDBCredentialsRotationParametersSchema.optional(), + secretsMapping: MongoDBCredentialsRotationSecretsMappingSchema.optional() +}); + +export const MongoDBCredentialsRotationListItemSchema = z.object({ + name: z.literal("MongoDB Credentials"), + connection: z.literal(AppConnection.MongoDB), + type: z.literal(SecretRotation.MongoDBCredentials), + template: MongoDBCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-types.ts new file mode 100644 index 0000000000..3a53a8cc51 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mongodb-credentials/mongodb-credentials-rotation-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TMongoDBConnection } from "@app/services/app-connection/mongodb"; + +import { + CreateMongoDBCredentialsRotationSchema, + MongoDBCredentialsRotationGeneratedCredentialsSchema, + MongoDBCredentialsRotationListItemSchema, + MongoDBCredentialsRotationSchema +} from "./mongodb-credentials-rotation-schemas"; + +export type TMongoDBCredentialsRotation = z.infer; + +export type TMongoDBCredentialsRotationInput = z.infer; + +export type TMongoDBCredentialsRotationListItem = z.infer; + +export type TMongoDBCredentialsRotationWithConnection = TMongoDBCredentialsRotation & { + connection: TMongoDBConnection; +}; + +export type TMongoDBCredentialsRotationGeneratedCredentials = z.infer< + typeof MongoDBCredentialsRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts index cf236b56f5..1718e09fd0 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -214,7 +214,10 @@ export const secretRotationV2DALFactory = ( tx?: Knex ) => { try { - const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + const { limit, offset = 0, sort, ...queryOptions } = options || {}; + const baseOptions = { ...queryOptions }; + + const subquery = baseSecretRotationV2Query({ filter, db, tx, options: baseOptions }) .join( TableName.SecretRotationV2SecretMapping, `${TableName.SecretRotationV2SecretMapping}.rotationId`, @@ -233,6 +236,7 @@ export const secretRotationV2DALFactory = ( ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .select( + selectAllTableCols(TableName.SecretRotationV2), db.ref("id").withSchema(TableName.SecretV2).as("secretId"), db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), @@ -252,18 +256,31 @@ export const secretRotationV2DALFactory = ( db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), - db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue"), + db.raw(`DENSE_RANK() OVER (ORDER BY ${TableName.SecretRotationV2}."createdAt" DESC) as rank`) ); if (search) { - void extendedQuery.where((query) => { - void query + void subquery.where((qb) => { + void qb .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); }); } - const secretRotations = await extendedQuery; + let secretRotations: Awaited; + if (limit !== undefined) { + const rankOffset = offset + 1; + const queryWithLimit = (tx || db) + .with("inner", subquery) + .select("*") + .from("inner") + .where("inner.rank", ">=", rankOffset) + .andWhere("inner.rank", "<", rankOffset + limit); + secretRotations = (await queryWithLimit) as unknown as Awaited; + } else { + secretRotations = await subquery; + } if (!secretRotations.length) return []; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index 661a2399ae..470a638498 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -8,7 +8,8 @@ export enum SecretRotation { AwsIamUserSecret = "aws-iam-user-secret", LdapPassword = "ldap-password", OktaClientSecret = "okta-client-secret", - RedisCredentials = "redis-credentials" + RedisCredentials = "redis-credentials", + MongoDBCredentials = "mongodb-credentials" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index e4e6a85312..bb774c4bec 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -9,6 +9,7 @@ import { AUTH0_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./auth0-client-secret" import { AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION } from "./aws-iam-user-secret"; import { AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./azure-client-secret"; import { LDAP_PASSWORD_ROTATION_LIST_OPTION, TLdapPasswordRotation } from "./ldap-password"; +import { MONGODB_CREDENTIALS_ROTATION_LIST_OPTION } from "./mongodb-credentials"; import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials"; import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret"; @@ -37,7 +38,8 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index 2087fa1957..c2b0714ab0 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -11,7 +11,8 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", [SecretRotation.LdapPassword]: "LDAP Password", [SecretRotation.OktaClientSecret]: "Okta Client Secret", - [SecretRotation.RedisCredentials]: "Redis Credentials" + [SecretRotation.RedisCredentials]: "Redis Credentials", + [SecretRotation.MongoDBCredentials]: "MongoDB Credentials" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { @@ -24,5 +25,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = async ( callback ) => { - // For SQL, since we get existing users, we change both their passwords - // on issue to invalidate their existing passwords // For SQL, since we get existing users, we change both their passwords // on issue to invalidate their existing passwords const credentialsSet = [ diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 3c89722484..81b0c0de2a 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -170,10 +170,13 @@ export const IDENTITIES = { } } as const; +const IDENTITY_AUTH_SUB_ORGANIZATION_NAME = "sub-organization name to scope the token to"; + export const UNIVERSAL_AUTH = { LOGIN: { clientId: "Your Machine Identity Client ID.", - clientSecret: "Your Machine Identity Client Secret." + clientSecret: "Your Machine Identity Client Secret.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -247,7 +250,8 @@ export const LDAP_AUTH = { LOGIN: { identityId: "The ID of the machine identity to login.", username: "The username of the LDAP user to login.", - password: "The password of the LDAP user to login." + password: "The password of the LDAP user to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { templateId: "The ID of the identity auth template to attach the configuration onto.", @@ -312,7 +316,8 @@ export const ALICLOUD_AUTH = { Timestamp: "The timestamp of the request in UTC, formatted as 'YYYY-MM-DDTHH:mm:ssZ'.", SignatureVersion: "The signature version. For STS GetCallerIdentity, this should be '1.0'.", SignatureNonce: "A unique random string to prevent replay attacks.", - Signature: "The signature string calculated based on the request parameters and AccessKey Secret." + Signature: "The signature string calculated based on the request parameters and AccessKey Secret.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -340,7 +345,8 @@ export const ALICLOUD_AUTH = { export const TLS_CERT_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -378,7 +384,8 @@ export const AWS_AUTH = { "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/.", iamRequestBody: "The base64-encoded body of the signed request. Most likely, the base64-encoding of Action=GetCallerIdentity&Version=2011-06-15.", - iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." + iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -416,7 +423,8 @@ export const OCI_AUTH = { LOGIN: { identityId: "The ID of the machine identity to login.", userOcid: "The OCID of the user attempting login.", - headers: "The headers of the signed request." + headers: "The headers of the signed request.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -448,7 +456,8 @@ export const OCI_AUTH = { export const AZURE_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -482,7 +491,8 @@ export const AZURE_AUTH = { export const GCP_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -520,7 +530,8 @@ export const GCP_AUTH = { export const KUBERNETES_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -600,7 +611,8 @@ export const TOKEN_AUTH = { }, CREATE_TOKEN: { identityId: "The ID of the machine identity to create the token for.", - name: "The name of the token to create." + name: "The name of the token to create.", + subOrganizationName: "The sub organization name to scope the token to." }, UPDATE_TOKEN: { tokenId: "The ID of the token to update metadata for.", @@ -613,7 +625,8 @@ export const TOKEN_AUTH = { export const OIDC_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -653,7 +666,8 @@ export const OIDC_AUTH = { export const JWT_AUTH = { LOGIN: { - identityId: "The ID of the machine identity to login." + identityId: "The ID of the machine identity to login.", + subOrganizationName: IDENTITY_AUTH_SUB_ORGANIZATION_NAME }, ATTACH: { identityId: "The ID of the machine identity to attach the configuration onto.", @@ -2860,6 +2874,12 @@ export const SecretRotations = { }, REDIS_CREDENTIALS: { permissionScope: "The ACL permission scope to assign to the issued Redis users." + }, + MONGODB_CREDENTIALS: { + username1: + "The username of the first MongoDB user to rotate passwords for. This user must already exist in your database.", + username2: + "The username of the second MongoDB user to rotate passwords for. This user must already exist in your database." } }, SECRETS_MAPPING: { @@ -2890,6 +2910,10 @@ export const SecretRotations = { OKTA_CLIENT_SECRET: { clientId: "The name of the secret that the client ID will be mapped to.", clientSecret: "The name of the secret that the rotated client secret will be mapped to." + }, + MONGODB_CREDENTIALS: { + username: "The name of the secret that the active username will be mapped to.", + password: "The name of the secret that the generated password will be mapped to." } } }; 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 0000000000..35f519c33a --- /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 21e83c2b79..14eb601925 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -286,6 +286,10 @@ const envSchema = z DYNAMIC_SECRET_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()).default( process.env.INF_APP_CONNECTION_AWS_SECRET_ACCESS_KEY ), + + // PAM AWS credentials (for AWS IAM PAM resource type) + PAM_AWS_ACCESS_KEY_ID: zpStr(z.string().optional()), + PAM_AWS_SECRET_ACCESS_KEY: zpStr(z.string().optional()), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index dab9d32780..b5497ca211 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -183,3 +183,23 @@ export class CryptographyError extends Error { this.error = error; } } + +export class PolicyViolationError extends Error { + name: string; + + error: unknown; + + details?: unknown; + + constructor({ + name, + error, + message, + details + }: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) { + super(message || "A policy is in place for this resource"); + this.name = name || "PolicyViolationError"; + this.error = error; + this.details = details; + } +} diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 6ff7278aae..c437e484aa 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -103,3 +103,34 @@ export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: st return deepEqual(filtered1, filtered2); }; + +export const deterministicStringify = (value: unknown): string => { + if (value === null || value === undefined) { + return JSON.stringify(value); + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + const items = value.map((item) => deterministicStringify(item)); + return `[${items.join(",")}]`; + } + + if (typeof value === "object") { + const sortedKeys = Object.keys(value).sort(); + const sortedObj: Record = {}; + for (const key of sortedKeys) { + const val = (value as Record)[key]; + if (typeof val === "object" && val !== null) { + sortedObj[key] = JSON.parse(deterministicStringify(val)); + } else { + sortedObj[key] = val; + } + } + return JSON.stringify(sortedObj); + } + + return JSON.stringify(value); +}; 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 0000000000..d6e0f92a47 --- /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 57409d173b..0243c81f21 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -64,6 +64,7 @@ export enum QueueName { 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", @@ -81,7 +82,9 @@ export enum QueueName { UserNotification = "user-notification", HealthAlert = "health-alert", CertificateV3AutoRenewal = "certificate-v3-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + PamSessionExpiration = "pam-session-expiration", + PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } export enum QueueJobs { @@ -127,6 +130,7 @@ export enum QueueJobs { 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", @@ -134,7 +138,9 @@ export enum QueueJobs { UserNotification = "user-notification-job", HealthAlert = "health-alert", CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", - PamAccountRotation = "pam-account-rotation" + PamAccountRotation = "pam-account-rotation", + PamSessionExpiration = "pam-session-expiration", + PkiAcmeChallengeValidation = "pki-acme-challenge-validation" } export type TQueueJobTypes = { @@ -353,6 +359,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; @@ -385,6 +406,14 @@ export type TQueueJobTypes = { name: QueueJobs.PamAccountRotation; payload: undefined; }; + [QueueName.PamSessionExpiration]: { + name: QueueJobs.PamSessionExpiration; + payload: { sessionId: string }; + }; + [QueueName.PkiAcmeChallengeValidation]: { + name: QueueJobs.PkiAcmeChallengeValidation; + payload: { challengeId: string }; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index e6ba2eec74..16c7b1e292 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -8,7 +8,6 @@ import { TScimTokenJwtPayload } from "@app/ee/services/scim/scim-types"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError } from "@app/lib/errors"; -import { slugSchema } from "@app/server/lib/schemas"; import { ActorType, AuthMethod, AuthMode, AuthModeJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; import { TIdentityAccessTokenJwtPayload } from "@app/services/identity-access-token/identity-access-token-types"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -152,15 +151,10 @@ export const injectIdentity = fp( if (!authMode) return; - const subOrganizationSelector = req.headers?.["x-infisical-org"] as string | undefined; - if (subOrganizationSelector) { - await slugSchema().parseAsync(subOrganizationSelector); - } - switch (authMode) { case AuthMode.JWT: { const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } = - await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); + await server.services.authToken.fnValidateJwtIdentity(token); requestContext.set("orgId", orgId); requestContext.set("orgName", orgName); requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" }); @@ -180,11 +174,7 @@ export const injectIdentity = fp( break; } case AuthMode.IDENTITY_ACCESS_TOKEN: { - const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( - token, - req.realIp, - subOrganizationSelector - ); + const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken(token, req.realIp); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); requestContext.set("orgName", identity.orgName); @@ -195,7 +185,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 @@ -223,9 +213,6 @@ export const injectIdentity = fp( const serviceToken = await server.services.serviceToken.fnValidateServiceToken(token); requestContext.set("orgId", serviceToken.orgId); - if (subOrganizationSelector) - throw new BadRequestError({ message: `Service token doesn't support sub organization selector` }); - req.auth = { orgId: serviceToken.orgId, rootOrgId: serviceToken.rootOrgId, @@ -248,9 +235,6 @@ export const injectIdentity = fp( const { orgId, scimTokenId } = await server.services.scim.fnValidateScimToken(token); requestContext.set("orgId", orgId); - if (subOrganizationSelector) - throw new BadRequestError({ message: `SCIM token doesn't support sub organization selector` }); - req.auth = { authMode: AuthMode.SCIM_TOKEN, actor, diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index e703df5ef5..df1988ce3b 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -17,6 +17,7 @@ import { NotFoundError, OidcAuthError, PermissionBoundaryError, + PolicyViolationError, RateLimitError, ScimRequestError, UnauthorizedError @@ -255,6 +256,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider detail: error.message // TODO: add subproblems if they exist }); + } else if (error instanceof PolicyViolationError) { + void res.status(HttpStatusCodes.Forbidden).send({ + reqId: req.id, + statusCode: HttpStatusCodes.Forbidden, + error: "PolicyViolationError", + message: error.message, + details: error.details + }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index ec9e56880a..cd4f75e7cf 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -81,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"; @@ -158,6 +159,19 @@ import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { + approvalPolicyDALFactory, + approvalPolicyStepApproversDALFactory, + approvalPolicyStepsDALFactory +} from "@app/services/approval-policy/approval-policy-dal"; +import { approvalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service"; +import { + approvalRequestApprovalsDALFactory, + approvalRequestDALFactory, + approvalRequestGrantsDALFactory, + approvalRequestStepEligibleApproversDALFactory, + approvalRequestStepsDALFactory +} from "@app/services/approval-policy/approval-request-dal"; import { authDALFactory } from "@app/services/auth/auth-dal"; import { authLoginServiceFactory } from "@app/services/auth/auth-login-service"; import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service"; @@ -173,6 +187,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"; @@ -180,6 +195,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"; @@ -275,6 +292,7 @@ import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; +import { pamSessionExpirationServiceFactory } from "@app/services/pam-session-expiration/pam-session-expiration-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -1092,6 +1110,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); @@ -1187,7 +1206,7 @@ export const registerRoutes = async ( certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -1911,6 +1930,9 @@ export const registerRoutes = async ( identityDAL }); + const approvalRequestDAL = approvalRequestDALFactory(db); + const approvalRequestGrantsDAL = approvalRequestGrantsDALFactory(db); + // DAILY const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ scimService, @@ -1926,7 +1948,9 @@ export const registerRoutes = async ( serviceTokenService, orgService, userNotificationDAL, - keyValueStoreDAL + keyValueStoreDAL, + approvalRequestDAL, + approvalRequestGrantsDAL }); const healthAlert = healthAlertServiceFactory({ @@ -2215,6 +2239,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, @@ -2229,7 +2278,9 @@ export const registerRoutes = async ( pkiSyncQueue, kmsService, projectDAL, - certificateBodyDAL + certificateBodyDAL, + certificateIssuanceQueue, + certificateRequestService }); const certificateV3Queue = certificateV3QueueServiceFactory({ @@ -2252,17 +2303,21 @@ export const registerRoutes = async ( }); const acmeChallengeService = pkiAcmeChallengeServiceFactory({ - acmeChallengeDAL + acmeChallengeDAL, + auditLogService }); + + const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({ + queueService, + acmeChallengeService + }); + const pkiAcmeService = pkiAcmeServiceFactory({ projectDAL, - appConnectionDAL, - certificateDAL, certificateAuthorityDAL, - externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, - certificateSecretDAL, + certificateTemplateV2DAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -2272,7 +2327,12 @@ export const registerRoutes = async ( kmsService, licenseService, certificateV3Service, - acmeChallengeService + certificateTemplateV2Service, + certificateRequestService, + certificateIssuanceQueue, + acmeChallengeService, + pkiAcmeQueueService, + auditLogService }); const pkiSubscriberService = pkiSubscriberServiceFactory({ @@ -2371,6 +2431,12 @@ export const registerRoutes = async ( gatewayV2Service }); + const approvalPolicyDAL = approvalPolicyDALFactory(db); + const pamSessionExpirationService = pamSessionExpirationServiceFactory({ + queueService, + pamSessionDAL + }); + const pamAccountService = pamAccountServiceFactory({ pamAccountDAL, gatewayV2Service, @@ -2382,7 +2448,10 @@ export const registerRoutes = async ( permissionService, projectDAL, userDAL, - auditLogService + auditLogService, + approvalRequestGrantsDAL, + approvalPolicyDAL, + pamSessionExpirationService }); const pamAccountRotation = pamAccountRotationServiceFactory({ @@ -2410,6 +2479,27 @@ export const registerRoutes = async ( auditLogService }); + const approvalPolicyStepsDAL = approvalPolicyStepsDALFactory(db); + const approvalPolicyStepApproversDAL = approvalPolicyStepApproversDALFactory(db); + const approvalRequestStepsDAL = approvalRequestStepsDALFactory(db); + const approvalRequestStepEligibleApproversDAL = approvalRequestStepEligibleApproversDALFactory(db); + const approvalRequestApprovalsDAL = approvalRequestApprovalsDALFactory(db); + + const approvalPolicyService = approvalPolicyServiceFactory({ + approvalPolicyDAL, + approvalPolicyStepsDAL, + approvalPolicyStepApproversDAL, + permissionService, + projectMembershipDAL, + approvalRequestDAL, + approvalRequestStepsDAL, + approvalRequestStepEligibleApproversDAL, + approvalRequestApprovalsDAL, + userGroupMembershipDAL, + notificationService, + approvalRequestGrantsDAL + }); + // setup the communication with license key server await licenseService.init(); @@ -2449,12 +2539,14 @@ export const registerRoutes = async ( await healthAlert.init(); await pkiSyncCleanup.init(); await pamAccountRotation.init(); + await pamSessionExpirationService.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiAlertV2Queue.init(); await certificateV3Queue.init(); + await certificateIssuanceQueue.initializeCertificateIssuanceQueue(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); @@ -2520,6 +2612,7 @@ export const registerRoutes = async ( auditLogStream: auditLogStreamService, certificate: certificateService, certificateV3: certificateV3Service, + certificateRequest: certificateRequestService, certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, @@ -2587,7 +2680,8 @@ export const registerRoutes = async ( additionalPrivilege: additionalPrivilegeService, identityProject: identityProjectService, convertor: convertorService, - pkiAlertV2: pkiAlertV2Service + pkiAlertV2: pkiAlertV2Service, + approvalPolicy: approvalPolicyService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts b/backend/src/server/routes/sanitizedSchema/identitiy-additional-privilege.ts index e44b9af4ec..0feb1ba55c 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/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 48fdc7c381..072abbadb7 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 @@ -87,6 +87,10 @@ import { SanitizedLaravelForgeConnectionSchema } from "@app/services/app-connection/laravel-forge"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; +import { + MongoDBConnectionListItemSchema, + SanitizedMongoDBConnectionSchema +} from "@app/services/app-connection/mongodb"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; import { @@ -173,6 +177,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, + ...SanitizedMongoDBConnectionSchema.options, ...SanitizedLaravelForgeConnectionSchema.options, ...SanitizedChefConnectionSchema.options, ...SanitizedDNSMadeEasyConnectionSchema.options @@ -219,6 +224,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, + MongoDBConnectionListItemSchema, LaravelForgeConnectionListItemSchema, ChefConnectionListItemSchema, DNSMadeEasyConnectionListItemSchema 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 d7a4065fd5..0738f0407e 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -16,8 +16,8 @@ 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 { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router"; import { registerFlyioConnectionRouter } from "./flyio-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; @@ -28,6 +28,7 @@ import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerLaravelForgeConnectionRouter } from "./laravel-forge-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; +import { registerMongoDBConnectionRouter } from "./mongodb-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; @@ -90,5 +91,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.MongoDB, + server, + sanitizedResponseSchema: SanitizedMongoDBConnectionSchema, + createSchema: CreateMongoDBConnectionSchema, + updateSchema: UpdateMongoDBConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts b/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts new file mode 100644 index 0000000000..91e0425422 --- /dev/null +++ b/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts @@ -0,0 +1,625 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { BadRequestError } from "@app/lib/errors"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums"; +import { + TApprovalPolicy, + TCreatePolicyDTO, + TCreateRequestDTO, + TUpdatePolicyDTO +} from "@app/services/approval-policy/approval-policy-types"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerApprovalPolicyEndpoints =

({ + server, + policyType, + createPolicySchema, + updatePolicySchema, + policyResponseSchema, + createRequestSchema, + requestResponseSchema, + grantResponseSchema +}: { + server: FastifyZodProvider; + policyType: ApprovalPolicyType; + createPolicySchema: z.ZodType< + TCreatePolicyDTO & { + conditions: P["conditions"]["conditions"]; + constraints: P["constraints"]["constraints"]; + } + >; + updatePolicySchema: z.ZodType< + TUpdatePolicyDTO & { + conditions?: P["conditions"]["conditions"]; + constraints?: P["constraints"]["constraints"]; + } + >; + policyResponseSchema: z.ZodTypeAny; + createRequestSchema: z.ZodType; + requestResponseSchema: z.ZodTypeAny; + grantResponseSchema: z.ZodTypeAny; +}) => { + // Policies + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create approval policy", + body: createPolicySchema, + response: { + 200: z.object({ + policy: policyResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policy } = await server.services.approvalPolicy.create(policyType, req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.body.projectId, + event: { + type: EventType.APPROVAL_POLICY_CREATE, + metadata: { + policyType, + name: req.body.name + } + } + }); + + return { policy }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List approval policies", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + policies: z.array(policyResponseSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policies } = await server.services.approvalPolicy.list(policyType, req.query.projectId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.APPROVAL_POLICY_LIST, + metadata: { + policyType, + count: policies.length + } + } + }); + + return { policies }; + } + }); + + server.route({ + method: "GET", + url: "/:policyId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get approval policy", + params: z.object({ + policyId: z.string().uuid() + }), + response: { + 200: z.object({ + policy: policyResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policy } = await server.services.approvalPolicy.getById(req.params.policyId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: policy.projectId, + event: { + type: EventType.APPROVAL_POLICY_GET, + metadata: { + policyType, + policyId: policy.id, + name: policy.name + } + } + }); + + return { policy }; + } + }); + + server.route({ + method: "PATCH", + url: "/:policyId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Update approval policy", + params: z.object({ + policyId: z.string().uuid() + }), + body: updatePolicySchema, + response: { + 200: z.object({ + policy: policyResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policy } = await server.services.approvalPolicy.updateById(req.params.policyId, req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: policy.projectId, + event: { + type: EventType.APPROVAL_POLICY_UPDATE, + metadata: { + policyType, + policyId: policy.id, + name: policy.name + } + } + }); + + return { policy }; + } + }); + + server.route({ + method: "DELETE", + url: "/:policyId", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Delete approval policy", + params: z.object({ + policyId: z.string().uuid() + }), + response: { + 200: z.object({ + policyId: z.string().uuid() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { policyId, projectId } = await server.services.approvalPolicy.deleteById( + req.params.policyId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId, + event: { + type: EventType.APPROVAL_POLICY_DELETE, + metadata: { + policyType, + policyId + } + } + }); + + return { policyId }; + } + }); + + // Requests + server.route({ + method: "GET", + url: "/requests", + config: { + rateLimit: readLimit + }, + schema: { + description: "List approval requests", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + requests: z.array(requestResponseSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { requests } = await server.services.approvalPolicy.listRequests( + policyType, + req.query.projectId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.APPROVAL_REQUEST_LIST, + metadata: { + policyType, + count: requests.length + } + } + }); + + return { requests }; + } + }); + + server.route({ + method: "POST", + url: "/requests", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Create approval request", + body: createRequestSchema, + response: { + 200: z.object({ + request: requestResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + // To prevent type errors when accessing req.auth.user + if (req.auth.authMode !== AuthMode.JWT) { + throw new BadRequestError({ message: "You can only request access using JWT auth tokens." }); + } + + const { request } = await server.services.approvalPolicy.createRequest( + policyType, + { + requesterName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(), + requesterEmail: req.auth.user.email ?? "", + ...req.body + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: request.projectId, + event: { + type: EventType.APPROVAL_REQUEST_CREATE, + metadata: { + policyType, + justification: req.body.justification || undefined, + requestDuration: req.body.requestDuration || "infinite" + } + } + }); + + return { request }; + } + }); + + server.route({ + method: "GET", + url: "/requests/:requestId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get approval request", + params: z.object({ + requestId: z.string().uuid() + }), + response: { + 200: z.object({ + request: requestResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.approvalPolicy.getRequestById(req.params.requestId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: request.projectId, + event: { + type: EventType.APPROVAL_REQUEST_GET, + metadata: { + policyType, + requestId: request.id, + status: request.status + } + } + }); + + return { request }; + } + }); + + server.route({ + method: "POST", + url: "/requests/:requestId/approve", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Approve approval request", + params: z.object({ + requestId: z.string().uuid() + }), + body: z.object({ + comment: z.string().optional() + }), + response: { + 200: z.object({ + request: requestResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.approvalPolicy.approveRequest( + req.params.requestId, + req.body, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: request.projectId, + event: { + type: EventType.APPROVAL_REQUEST_APPROVE, + metadata: { + policyType, + requestId: req.params.requestId, + comment: req.body.comment + } + } + }); + + return { request }; + } + }); + + server.route({ + method: "POST", + url: "/requests/:requestId/reject", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Reject approval request", + params: z.object({ + requestId: z.string().uuid() + }), + body: z.object({ + comment: z.string().optional() + }), + response: { + 200: z.object({ + request: requestResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.approvalPolicy.rejectRequest( + req.params.requestId, + req.body, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: request.projectId, + event: { + type: EventType.APPROVAL_REQUEST_REJECT, + metadata: { + policyType, + requestId: req.params.requestId, + comment: req.body.comment + } + } + }); + + return { request }; + } + }); + + server.route({ + method: "POST", + url: "/requests/:requestId/cancel", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Cancel approval request", + params: z.object({ + requestId: z.string().uuid() + }), + response: { + 200: z.object({ + request: requestResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { request } = await server.services.approvalPolicy.cancelRequest(req.params.requestId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: request.projectId, + event: { + type: EventType.APPROVAL_REQUEST_CANCEL, + metadata: { + policyType, + requestId: req.params.requestId + } + } + }); + + return { request }; + } + }); + + // Grants + server.route({ + method: "GET", + url: "/grants", + config: { + rateLimit: readLimit + }, + schema: { + description: "List approval grants", + querystring: z.object({ + projectId: z.string().uuid() + }), + response: { + 200: z.object({ + grants: z.array(grantResponseSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { grants } = await server.services.approvalPolicy.listGrants( + policyType, + req.query.projectId, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.query.projectId, + event: { + type: EventType.APPROVAL_REQUEST_GRANT_LIST, + metadata: { + policyType, + count: grants.length + } + } + }); + + return { grants }; + } + }); + + server.route({ + method: "GET", + url: "/grants/:grantId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get approval grant", + params: z.object({ + grantId: z.string().uuid() + }), + response: { + 200: z.object({ + grant: grantResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { grant } = await server.services.approvalPolicy.getGrantById(req.params.grantId, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: grant.projectId, + event: { + type: EventType.APPROVAL_REQUEST_GRANT_GET, + metadata: { + policyType, + grantId: grant.id, + status: grant.status + } + } + }); + + return { grant }; + } + }); + + server.route({ + method: "POST", + url: "/grants/:grantId/revoke", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Revoke approval grant", + params: z.object({ + grantId: z.string().uuid() + }), + body: z.object({ + revocationReason: z.string().optional() + }), + response: { + 200: z.object({ + grant: grantResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { grant } = await server.services.approvalPolicy.revokeGrant(req.params.grantId, req.body, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: grant.projectId, + event: { + type: EventType.APPROVAL_REQUEST_GRANT_REVOKE, + metadata: { + policyType, + grantId: grant.id, + revocationReason: req.body.revocationReason + } + } + }); + + return { grant }; + } + }); +}; diff --git a/backend/src/server/routes/v1/approval-policy-routers/index.ts b/backend/src/server/routes/v1/approval-policy-routers/index.ts new file mode 100644 index 0000000000..c848e2e47b --- /dev/null +++ b/backend/src/server/routes/v1/approval-policy-routers/index.ts @@ -0,0 +1,29 @@ +import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums"; +import { + CreatePamAccessPolicySchema, + CreatePamAccessRequestSchema, + PamAccessPolicySchema, + PamAccessRequestGrantSchema, + PamAccessRequestSchema, + UpdatePamAccessPolicySchema +} from "@app/services/approval-policy/pam-access/pam-access-policy-schemas"; + +import { registerApprovalPolicyEndpoints } from "./approval-policy-endpoints"; + +export const APPROVAL_POLICY_REGISTER_ROUTER_MAP: Record< + ApprovalPolicyType, + (server: FastifyZodProvider) => Promise +> = { + [ApprovalPolicyType.PamAccess]: async (server: FastifyZodProvider) => { + registerApprovalPolicyEndpoints({ + server, + policyType: ApprovalPolicyType.PamAccess, + createPolicySchema: CreatePamAccessPolicySchema, + updatePolicySchema: UpdatePamAccessPolicySchema, + policyResponseSchema: PamAccessPolicySchema, + createRequestSchema: CreatePamAccessRequestSchema, + requestResponseSchema: PamAccessRequestSchema, + grantResponseSchema: PamAccessRequestGrantSchema + }); + } +}; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index 48939844e5..bedc519da6 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -81,7 +81,8 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { response: { 200: z.object({ token: z.string(), - organizationId: z.string().optional() + organizationId: z.string().optional(), + subOrganizationId: z.string().optional() }) } }, @@ -89,14 +90,15 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { const { decodedToken, tokenVersion } = await server.services.authToken.validateRefreshToken(req.cookies.jid); const appCfg = getConfig(); let expiresIn: string | number = appCfg.JWT_AUTH_LIFETIME; + if (decodedToken.organizationId) { - const org = await server.services.org.findOrganizationById( - decodedToken.userId, - decodedToken.organizationId, - decodedToken.authMethod, - decodedToken.organizationId, - decodedToken.organizationId - ); + const org = await server.services.org.findOrganizationById({ + userId: decodedToken.userId, + orgId: decodedToken.subOrganizationId ? decodedToken.subOrganizationId : decodedToken.organizationId, + actorAuthMethod: decodedToken.authMethod, + actorOrgId: decodedToken.subOrganizationId ? decodedToken.subOrganizationId : decodedToken.organizationId, + rootOrgId: decodedToken.organizationId + }); if (org && org.userTokenExpiration) { expiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); } @@ -110,14 +112,14 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { tokenVersionId: tokenVersion.id, accessVersion: tokenVersion.accessVersion, organizationId: decodedToken.organizationId, + ...(decodedToken.subOrganizationId && { subOrganizationId: decodedToken.subOrganizationId }), isMfaVerified: decodedToken.isMfaVerified, mfaMethod: decodedToken.mfaMethod }, appCfg.AUTH_SECRET, { expiresIn } ); - - return { token, organizationId: decodedToken.organizationId }; + return { token, organizationId: decodedToken.organizationId, subOrganizationId: decodedToken.subOrganizationId }; } }); }; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index b23dd1fee2..a3402bd858 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -8,6 +8,7 @@ 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 { 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) => { @@ -46,7 +47,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { @@ -149,7 +155,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -204,6 +212,15 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid 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(), @@ -232,9 +249,11 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid acmeConfig: z .object({ id: z.string(), - directoryUrl: z.string() + directoryUrl: z.string(), + skipDnsOwnershipVerification: z.boolean().optional() }) - .optional() + .optional(), + externalConfigs: ExternalConfigUnionSchema }).array(), totalCount: z.number() }) @@ -280,12 +299,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 @@ -310,7 +333,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), renewBeforeDays: z.number().optional() }) - .optional() + .optional(), + externalConfigs: ExternalConfigUnionSchema }) }) } @@ -358,7 +382,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid }), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -412,7 +438,13 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), + externalConfigs: ExternalConfigUnionSchema }) .refine( (data) => { @@ -434,7 +466,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid ), response: { 200: z.object({ - certificateProfile: PkiCertificateProfilesSchema + certificateProfile: PkiCertificateProfilesSchema.extend({ + externalConfigs: ExternalConfigUnionSchema + }) }) } }, @@ -479,7 +513,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 a4af7fd924..25beb710cd 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -5,18 +5,14 @@ import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; 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 { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { - ACMESANType, - CertificateOrderStatus, - CertKeyAlgorithm, - CertSignatureAlgorithm, - 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 { CertExtendedKeyUsageType, @@ -26,10 +22,23 @@ 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 { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types"; 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[]; @@ -47,13 +56,32 @@ interface CertificateRequestForService { keyAlgorithm?: string; } -const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: 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: { notBefore?: string; notAfter?: string }) => { +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); @@ -65,13 +93,277 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { export const registerCertificateRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/issue-certificate", + 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: "/certificate-requests/:requestId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + requestId: z.string().uuid() + }), + response: { + 200: z.object({ + status: z.nativeEnum(CertificateRequestStatus), + certificate: z.string().nullable(), + certificateId: 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 { certificateRequest, projectId } = await server.services.certificateRequest.getCertificateFromRequest({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateRequestId: req.params.requestId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_CERTIFICATE_REQUEST, + metadata: { + certificateRequestId: req.params.requestId + } + } + }); + return certificateRequest; + } + }); + + 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(), @@ -111,7 +403,8 @@ export const registerCertificateRouter = 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() }) } }, @@ -168,8 +461,10 @@ export const registerCertificateRouter = 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(), @@ -196,14 +491,13 @@ export const registerCertificateRouter = 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, @@ -220,6 +514,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { removeRootsFromChain: req.body.removeRootsFromChain }); + const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: data.projectId, @@ -229,7 +525,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: certificateRequest.commonName || "" + commonName: certificateRequestData.commonName || "" } } }); @@ -245,23 +541,23 @@ export const registerCertificateRouter = 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() @@ -285,59 +581,34 @@ export const registerCertificateRouter = 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 }); @@ -348,7 +619,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => { type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, metadata: { certificateProfileId: req.body.profileId, - orderId: data.orderId, profileName: data.profileName } } @@ -382,12 +652,24 @@ export const registerCertificateRouter = 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.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, diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 8cf9604a43..7dc763730f 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -624,7 +624,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array() + }).nullable() }) .nullable() .array() @@ -743,6 +746,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ReturnType >[number]["secrets"][number] & { isEmpty: boolean; + reminder: Awaited>[string] | null; } > | null)[]; })[] @@ -847,27 +851,38 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ); if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { - secretRotations = ( - await server.services.secretRotationV2.getDashboardSecretRotations( - { - projectId, - search, - orderBy, - orderDirection, - environments: [environment], - secretPath, - limit: remainingLimit, - offset: adjustedOffset - }, - req.permission - ) - ).map((rotation) => ({ + const rawSecretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + const allRotationSecretIds = rawSecretRotations + .flatMap((rotation) => rotation.secrets) + .filter((secret) => Boolean(secret)) + .map((secret) => secret.id); + + const rotationReminders = + allRotationSecretIds.length > 0 + ? await server.services.reminder.getRemindersForDashboard(allRotationSecretIds) + : {}; + + secretRotations = rawSecretRotations.map((rotation) => ({ ...rotation, secrets: rotation.secrets.map((secret) => secret ? { ...secret, - isEmpty: !secret.secretValue + isEmpty: !secret.secretValue, + reminder: rotationReminders[secret.id] ?? null } : secret ) @@ -948,7 +963,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { search, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { @@ -970,7 +986,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, tagSlugs: tags, includeTagsInSearch: true, - includeMetadataInSearch: true + includeMetadataInSearch: true, + excludeRotatedSecrets: includeSecretRotations }) ).secrets; diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index 8f64d3b231..817cde6673 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -5,6 +5,7 @@ import { IdentityAlicloudAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ALICLOUD_AUTH, 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -38,6 +39,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi message: "AccessKeyId must be alphanumeric" }) .describe(ALICLOUD_AUTH.LOGIN.AccessKeyId), + subOrganizationName: slugSchema().optional().describe(ALICLOUD_AUTH.LOGIN.subOrganizationName), SignatureMethod: z.enum(["HMAC-SHA1"]).describe(ALICLOUD_AUTH.LOGIN.SignatureMethod), Timestamp: z .string() diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index 3cfb19895f..d9d9cab40e 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityAwsAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, AWS_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -28,7 +29,8 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) identityId: z.string().trim().describe(AWS_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), iamRequestBody: z.string().describe(AWS_AUTH.LOGIN.iamRequestBody), - iamRequestHeaders: z.string().describe(AWS_AUTH.LOGIN.iamRequestHeaders) + iamRequestHeaders: z.string().describe(AWS_AUTH.LOGIN.iamRequestHeaders), + subOrganizationName: slugSchema().optional().describe(AWS_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index cdab7af027..e8dd4341d9 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityAzureAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, AZURE_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -23,7 +24,8 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider description: "Login with Azure Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AZURE_AUTH.LOGIN.identityId), - jwt: z.string() + jwt: z.string(), + subOrganizationName: slugSchema().optional().describe(AZURE_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 474999b2b1..9f6d284bd9 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityGcpAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, GCP_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -23,7 +24,8 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) description: "Login with GCP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(GCP_AUTH.LOGIN.identityId), - jwt: z.string() + jwt: z.string(), + subOrganizationName: slugSchema().optional().describe(GCP_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index 5d71b37814..a9d0b90c6e 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityJwtAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, JWT_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -99,7 +100,8 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) description: "Login with JWT Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), - jwt: z.string().trim() + jwt: z.string().trim(), + subOrganizationName: slugSchema().optional().describe(JWT_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -112,10 +114,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) }, handler: async (req) => { const { identityJwtAuth, accessToken, identityAccessToken, identity } = - await server.services.identityJwtAuth.login({ - identityId: req.body.identityId, - jwt: req.body.jwt - }); + await server.services.identityJwtAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 28f611abae..29edf363ad 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, KUBERNETES_AUTH } from "@app/lib/api-docs"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -44,7 +45,8 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide description: "Login with Kubernetes Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(KUBERNETES_AUTH.LOGIN.identityId), - jwt: z.string().trim() + jwt: z.string().trim(), + subOrganizationName: slugSchema().optional().describe(KUBERNETES_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -57,10 +59,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }, handler: async (req) => { const { identityKubernetesAuth, accessToken, identityAccessToken, identity } = - await server.services.identityKubernetesAuth.login({ - identityId: req.body.identityId, - jwt: req.body.jwt - }); + await server.services.identityKubernetesAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index dade20ea36..ec5360345f 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -21,6 +21,7 @@ import { getConfig } from "@app/lib/config/env"; import { UnauthorizedError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -124,7 +125,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) body: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), username: z.string().describe(LDAP_AUTH.LOGIN.username), - password: z.string().describe(LDAP_AUTH.LOGIN.password) + password: z.string().describe(LDAP_AUTH.LOGIN.password), + subOrganizationName: slugSchema().optional().describe(LDAP_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -163,7 +165,8 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) const { identityId, user } = req.passportMachineIdentity; const { accessToken, identityLdapAuth, identity } = await server.services.identityLdapAuth.login({ - identityId + identityId, + subOrganizationName: req.body.subOrganizationName }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index 003d9810ba..cffba6f413 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityOciAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, OCI_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -40,7 +41,8 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) }); } }) - .describe(OCI_AUTH.LOGIN.headers) + .describe(OCI_AUTH.LOGIN.headers), + subOrganizationName: slugSchema().optional().describe(OCI_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 6fad1f400c..9b49a65f04 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityOidcAuthsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, OIDC_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -47,7 +48,8 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) description: "Login with OIDC Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OIDC_AUTH.LOGIN.identityId), - jwt: z.string().trim() + jwt: z.string().trim(), + subOrganizationName: slugSchema().optional().describe(OIDC_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -60,10 +62,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) }, handler: async (req) => { const { identityOidcAuth, accessToken, identityAccessToken, identity, oidcTokenData } = - await server.services.identityOidcAuth.login({ - identityId: req.body.identityId, - jwt: req.body.jwt - }); + await server.services.identityOidcAuth.login(req.body); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index b7a44c62cd..2a6faf896f 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -7,6 +7,7 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError } from "@app/lib/errors"; 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -46,7 +47,8 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid tags: [ApiDocsTags.TlsCertAuth], description: "Login with TLS Certificate Auth for machine identity", body: z.object({ - identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) + identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId), + subOrganizationName: slugSchema().optional().describe(TLS_CERT_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -66,7 +68,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid const { identityTlsCertAuth, accessToken, identityAccessToken, identity } = await server.services.identityTlsCertAuth.login({ - identityId: req.body.identityId, + ...req.body, clientCertificate: clientCertificate as string }); 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 d7cd86330b..e6ad10acc9 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityAccessTokensSchema, IdentityTokenAuthsSchema } from "@app/db/sc import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, TOKEN_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -307,7 +308,8 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider identityId: z.string().describe(TOKEN_AUTH.CREATE_TOKEN.identityId) }), body: z.object({ - name: z.string().optional().describe(TOKEN_AUTH.CREATE_TOKEN.name) + name: z.string().optional().describe(TOKEN_AUTH.CREATE_TOKEN.name), + subOrganizationName: slugSchema().optional().describe(TOKEN_AUTH.CREATE_TOKEN.subOrganizationName) }), response: { 200: z.object({ @@ -408,6 +410,7 @@ 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", @@ -416,7 +419,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - hide: false, + hide: true, tags: [ApiDocsTags.TokenAuth], description: "Get token for machine identity with Token Auth", security: [ @@ -436,13 +439,11 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider }, handler: async (req) => { const { token, identityMembershipOrg } = await server.services.identityTokenAuth.getTokenAuthTokenById({ - identityId: req.params.identityId, tokenId: req.params.tokenId, actor: req.permission.type, actorId: req.permission.id, actorOrgId: req.permission.orgId, - actorAuthMethod: req.permission.authMethod, - isActorSuperAdmin: isSuperAdmin(req.auth) + actorAuthMethod: req.permission.authMethod }); await server.services.auditLog.createAuditLog({ @@ -462,6 +463,57 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider } }); + 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/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 88a4cb7752..64a6cfebeb 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -4,6 +4,7 @@ import { IdentityUaClientSecretsSchema, IdentityUniversalAuthsSchema } from "@ap import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, UNIVERSAL_AUTH } 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 { TIdentityTrustedIp } from "@app/services/identity/identity-types"; @@ -35,7 +36,8 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { description: "Login with Universal Auth for machine identity", body: z.object({ clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), - clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) + clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret), + subOrganizationName: slugSchema().optional().describe(UNIVERSAL_AUTH.LOGIN.subOrganizationName) }), response: { 200: z.object({ @@ -55,7 +57,10 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { identity, accessTokenTTL, accessTokenMaxTTL - } = await server.services.identityUa.login(req.body.clientId, req.body.clientSecret, req.realIp); + } = await server.services.identityUa.login({ + ...req.body, + ip: req.realIp + }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c273994537..3b4b10b88c 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -7,6 +7,7 @@ import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router" import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers"; import { registerAdminRouter } from "./admin-router"; +import { APPROVAL_POLICY_REGISTER_ROUTER_MAP } from "./approval-policy-routers"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; @@ -275,4 +276,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerEventRouter, { prefix: "/events" }); await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); + + await server.register( + async (approvalPolicyRouter) => { + // Register policy type-specific endpoints + for await (const [type, router] of Object.entries(APPROVAL_POLICY_REGISTER_ROUTER_MAP)) { + await approvalPolicyRouter.register(router, { prefix: `/${type}` }); + } + }, + { prefix: "/approval-policies" } + ); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index d4b0058f97..a56fdcfed4 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -60,26 +60,19 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - organization: sanitizedOrganizationSchema.extend({ - subOrganization: z - .object({ - id: z.string(), - name: z.string() - }) - .optional() - }) + organization: sanitizedOrganizationSchema }) } }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { - const organization = await server.services.org.findOrganizationById( - req.permission.id, - req.params.organizationId, - req.permission.authMethod, - req.permission.rootOrgId, - req.permission.orgId - ); + const organization = await server.services.org.findOrganizationById({ + userId: req.permission.id, + orgId: req.params.organizationId, + actorAuthMethod: req.permission.authMethod, + rootOrgId: req.permission.rootOrgId, + actorOrgId: req.permission.orgId + }); return { organization }; } }); diff --git a/backend/src/server/routes/v3/deprecated-certificates-router.ts b/backend/src/server/routes/v3/deprecated-certificates-router.ts index f13f77c346..ab8ae176c2 100644 --- a/backend/src/server/routes/v3/deprecated-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,6 +17,7 @@ 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"; @@ -65,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(), @@ -106,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() }) } }, @@ -138,6 +138,29 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => 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({ ...req.auditLogInfo, projectId: data.projectId, @@ -152,7 +175,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => } }); - return data; + return { + ...data, + certificateRequestId: certificateRequest.id + }; } }); @@ -163,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(), @@ -191,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, @@ -215,6 +242,32 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => 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({ ...req.auditLogInfo, projectId: data.projectId, @@ -224,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 + }; } }); @@ -240,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() @@ -280,62 +336,55 @@ 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({ ...req.auditLogInfo, projectId: data.projectId, @@ -343,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 + }; } }); @@ -377,12 +428,24 @@ 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, diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 07923fd6c4..0f77c02f6a 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -57,6 +57,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { }, handler: async (req, res) => { const cfg = getConfig(); + const tokens = await server.services.login.selectOrganization({ userAgent: req.body.userAgent ?? req.headers["user-agent"], authJwtToken: req.headers.authorization, diff --git a/backend/src/services/additional-privilege/additional-privilege-service.ts b/backend/src/services/additional-privilege/additional-privilege-service.ts index 2af9e6419a..69f103c853 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 8e0260c01d..e7e2bca760 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -39,6 +39,7 @@ export enum AppConnection { Netlify = "netlify", Okta = "okta", Redis = "redis", + MongoDB = "mongodb", LaravelForge = "laravel-forge", Chef = "chef", Northflank = "northflank" diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index d8af3773b2..f28508efba 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -119,6 +119,7 @@ import { validateLaravelForgeConnectionCredentials } from "./laravel-forge"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; +import { getMongoDBConnectionListItem, MongoDBConnectionMethod, validateMongoDBConnectionCredentials } from "./mongodb"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; @@ -224,6 +225,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem(), + getMongoDBConnectionListItem(), getChefConnectionListItem() ] .filter((option) => { @@ -357,7 +359,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.MongoDB]: validateMongoDBConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); @@ -411,6 +414,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case OracleDBConnectionMethod.UsernameAndPassword: case AzureADCSConnectionMethod.UsernamePassword: case RedisConnectionMethod.UsernameAndPassword: + case MongoDBConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -504,6 +508,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, + [AppConnection.MongoDB]: platformManagedCredentialsNotSupported, [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported, [AppConnection.Chef]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 27d6a27a8e..a41589d123 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -42,6 +42,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", [AppConnection.Redis]: "Redis", + [AppConnection.MongoDB]: "MongoDB", [AppConnection.Chef]: "Chef", [AppConnection.Northflank]: "Northflank" }; @@ -88,6 +89,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { + return { + name: "MongoDB" as const, + app: AppConnection.MongoDB as const, + methods: Object.values(MongoDBConnectionMethod) as [MongoDBConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: false as const + }; +}; + +export type TMongoDBConnectionCredentials = { + host: string; + port?: number; + database: string; + username: string; + password: string; + tlsEnabled?: boolean; + tlsRejectUnauthorized?: boolean; + tlsCertificate?: string; +}; + +export type TCreateMongoClientOptions = { + authCredentials?: { username: string; password: string }; + validateConnection?: boolean; +}; + +const DEFAULT_CONNECTION_TIMEOUT_MS = 10_000; + +export const createMongoClient = async ( + credentials: TMongoDBConnectionCredentials, + options?: TCreateMongoClientOptions +): Promise => { + const srvRegex = new RE2("^mongodb\\+srv:\\/\\/"); + const protocolRegex = new RE2("^mongodb:\\/\\/"); + + let normalizedHost = credentials.host.trim(); + const isSrvFromHost = srvRegex.test(normalizedHost); + if (isSrvFromHost) { + normalizedHost = srvRegex.replace(normalizedHost, ""); + } else if (protocolRegex.test(normalizedHost)) { + normalizedHost = protocolRegex.replace(normalizedHost, ""); + } + + const [hostIp] = await verifyHostInputValidity(normalizedHost); + + const isSrv = !credentials.port || isSrvFromHost; + const uri = isSrv ? `mongodb+srv://${hostIp}` : `mongodb://${hostIp}:${credentials.port}`; + + const authCredentials = options?.authCredentials ?? { + username: credentials.username, + password: credentials.password + }; + + const clientOptions: { + auth?: { username: string; password?: string }; + authSource?: string; + tls?: boolean; + tlsInsecure?: boolean; + ca?: string; + directConnection?: boolean; + connectTimeoutMS?: number; + serverSelectionTimeoutMS?: number; + socketTimeoutMS?: number; + } = { + auth: { + username: authCredentials.username, + password: authCredentials.password + }, + authSource: isSrv ? undefined : credentials.database, + directConnection: !isSrv, + connectTimeoutMS: DEFAULT_CONNECTION_TIMEOUT_MS, + serverSelectionTimeoutMS: DEFAULT_CONNECTION_TIMEOUT_MS, + socketTimeoutMS: DEFAULT_CONNECTION_TIMEOUT_MS + }; + + if (credentials.tlsEnabled) { + clientOptions.tls = true; + clientOptions.tlsInsecure = !credentials.tlsRejectUnauthorized; + if (credentials.tlsCertificate) { + clientOptions.ca = credentials.tlsCertificate; + } + } + + const client = new MongoClient(uri, clientOptions); + + if (options?.validateConnection) { + await client + .db(credentials.database) + .command({ ping: 1 }) + .then(() => true); + } + + return client; +}; + +export const validateMongoDBConnectionCredentials = async (config: TMongoDBConnectionConfig) => { + let client: MongoClient | null = null; + try { + client = await createMongoClient(config.credentials, { validateConnection: true }); + + if (client) await client.close(); + + return config.credentials; + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + throw new BadRequestError({ + message: `Unable to validate connection: ${(err as Error)?.message || "verify credentials"}` + }); + } finally { + if (client) await client.close(); + } +}; diff --git a/backend/src/services/app-connection/mongodb/mongodb-connection-schemas.ts b/backend/src/services/app-connection/mongodb/mongodb-connection-schemas.ts new file mode 100644 index 0000000000..c934a07414 --- /dev/null +++ b/backend/src/services/app-connection/mongodb/mongodb-connection-schemas.ts @@ -0,0 +1,89 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { MongoDBConnectionMethod } from "./mongodb-connection-enums"; + +export const BaseMongoDBUsernameAndPasswordConnectionSchema = z.object({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + username: z.string().min(1), + password: z.string().min(1), + database: z.string().min(1).trim(), + + tlsRejectUnauthorized: z.boolean(), + tlsEnabled: z.boolean(), + tlsCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const MongoDBConnectionAccessTokenCredentialsSchema = BaseMongoDBUsernameAndPasswordConnectionSchema; + +const BaseMongoDBConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.MongoDB) }); + +export const MongoDBConnectionSchema = BaseMongoDBConnectionSchema.extend({ + method: z.literal(MongoDBConnectionMethod.UsernameAndPassword), + credentials: MongoDBConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedMongoDBConnectionSchema = z.discriminatedUnion("method", [ + BaseMongoDBConnectionSchema.extend({ + method: z.literal(MongoDBConnectionMethod.UsernameAndPassword), + credentials: MongoDBConnectionAccessTokenCredentialsSchema.pick({ + host: true, + port: true, + username: true, + database: true, + tlsEnabled: true, + tlsRejectUnauthorized: true, + tlsCertificate: true + }) + }) +]); + +export const ValidateMongoDBConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(MongoDBConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.MongoDB).method), + credentials: MongoDBConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.MongoDB).credentials + ) + }) +]); + +export const CreateMongoDBConnectionSchema = ValidateMongoDBConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.MongoDB, { + supportsPlatformManagedCredentials: false, + supportsGateways: false + }) +); + +export const UpdateMongoDBConnectionSchema = z + .object({ + credentials: MongoDBConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.MongoDB).credentials + ) + }) + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.MongoDB, { + supportsPlatformManagedCredentials: false, + supportsGateways: false + }) + ); + +export const MongoDBConnectionListItemSchema = z.object({ + name: z.literal("MongoDB"), + app: z.literal(AppConnection.MongoDB), + methods: z.nativeEnum(MongoDBConnectionMethod).array(), + supportsPlatformManagement: z.literal(false) +}); diff --git a/backend/src/services/app-connection/mongodb/mongodb-connection-types.ts b/backend/src/services/app-connection/mongodb/mongodb-connection-types.ts new file mode 100644 index 0000000000..52212545a4 --- /dev/null +++ b/backend/src/services/app-connection/mongodb/mongodb-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateMongoDBConnectionSchema, + MongoDBConnectionSchema, + ValidateMongoDBConnectionCredentialsSchema +} from "./mongodb-connection-schemas"; + +export type TMongoDBConnection = z.infer; + +export type TMongoDBConnectionInput = z.infer & { + app: AppConnection.MongoDB; +}; + +export type TValidateMongoDBConnectionCredentialsSchema = typeof ValidateMongoDBConnectionCredentialsSchema; + +export type TMongoDBConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/approval-policy/approval-policy-dal.ts b/backend/src/services/approval-policy/approval-policy-dal.ts new file mode 100644 index 0000000000..67dd7a5211 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-dal.ts @@ -0,0 +1,150 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums"; +import { ApprovalPolicyStep } from "./approval-policy-types"; + +// Approval Policy +export type TApprovalPolicyDALFactory = ReturnType; +export const approvalPolicyDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicies); + + const findStepsByPolicyId = async (policyId: string) => { + try { + const dbInstance = db.replicaNode(); + const steps = await dbInstance(TableName.ApprovalPolicySteps).where({ policyId }).orderBy("stepNumber", "asc"); + + if (!steps.length) { + return []; + } + + const stepIds = steps.map((step) => step.id); + + const approvers = await dbInstance(TableName.ApprovalPolicyStepApprovers) + .whereIn("policyStepId", stepIds) + .select("policyStepId", "userId", "groupId"); + + const approversByStepId = approvers.reduce>( + (acc, approver) => { + const stepApprovers = acc[approver.policyStepId] || []; + stepApprovers.push({ + type: approver.userId ? ApproverType.User : ApproverType.Group, + id: (approver.userId || approver.groupId) as string + }); + acc[approver.policyStepId] = stepApprovers; + return acc; + }, + {} + ); + + return steps.map((step) => { + const stepApprovers = approversByStepId[step.id] || []; + + const formattedStep: ApprovalPolicyStep = { + requiredApprovals: step.requiredApprovals, + approvers: stepApprovers + }; + + if (step.name) { + formattedStep.name = step.name; + } + if (typeof step.notifyApprovers === "boolean") { + formattedStep.notifyApprovers = step.notifyApprovers; + } + + return formattedStep; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find approval policy steps" }); + } + }; + + const findByProjectId = async (policyType: ApprovalPolicyType, projectId: string) => { + try { + const dbInstance = db.replicaNode(); + const policies = await dbInstance(TableName.ApprovalPolicies).where({ type: policyType, projectId }); + + if (!policies.length) { + return []; + } + + const policyIds = policies.map((p) => p.id); + + const steps = await dbInstance(TableName.ApprovalPolicySteps) + .whereIn("policyId", policyIds) + .orderBy("stepNumber", "asc"); + + const stepsByPolicyId: Record = {}; + + if (steps.length) { + const stepIds = steps.map((step) => step.id); + + const approvers = await dbInstance(TableName.ApprovalPolicyStepApprovers) + .whereIn("policyStepId", stepIds) + .select("policyStepId", "userId", "groupId"); + + const approversByStepId = approvers.reduce>( + (acc, approver) => { + const stepApprovers = acc[approver.policyStepId] || []; + stepApprovers.push({ + type: approver.userId ? ApproverType.User : ApproverType.Group, + id: (approver.userId || approver.groupId) as string + }); + acc[approver.policyStepId] = stepApprovers; + return acc; + }, + {} + ); + + steps.forEach((step) => { + const stepApprovers = approversByStepId[step.id] || []; + const formattedStep: ApprovalPolicyStep = { + requiredApprovals: step.requiredApprovals, + approvers: stepApprovers + }; + + if (step.name) { + formattedStep.name = step.name; + } + if (typeof step.notifyApprovers === "boolean") { + formattedStep.notifyApprovers = step.notifyApprovers; + } + + if (!stepsByPolicyId[step.policyId]) { + stepsByPolicyId[step.policyId] = []; + } + stepsByPolicyId[step.policyId].push(formattedStep); + }); + } + + return policies.map((policy) => ({ + ...policy, + steps: stepsByPolicyId[policy.id] || [] + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find approval policies by project id" }); + } + }; + + return { + ...orm, + findStepsByPolicyId, + findByProjectId + }; +}; + +// Approval Policy Steps +export type TApprovalPolicyStepsDALFactory = ReturnType; +export const approvalPolicyStepsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicySteps); + return orm; +}; + +// Approval Policy Step Approvers +export type TApprovalPolicyStepApproversDALFactory = ReturnType; +export const approvalPolicyStepApproversDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicyStepApprovers); + return orm; +}; diff --git a/backend/src/services/approval-policy/approval-policy-enums.ts b/backend/src/services/approval-policy/approval-policy-enums.ts new file mode 100644 index 0000000000..bc57801f30 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-enums.ts @@ -0,0 +1,33 @@ +export enum ApprovalPolicyType { + PamAccess = "pam-access" +} + +export enum ApproverType { + Group = "group", + User = "user" +} + +export enum ApprovalRequestStatus { + Pending = "pending", + Approved = "approved", + Rejected = "rejected", + Expired = "expired", + Cancelled = "cancelled" +} + +export enum ApprovalRequestStepStatus { + Pending = "pending", + InProgress = "in-progress", + Completed = "completed" +} + +export enum ApprovalRequestApprovalDecision { + Approved = "approved", + Rejected = "rejected" +} + +export enum ApprovalRequestGrantStatus { + Active = "active", + Expired = "expired", + Revoked = "revoked" +} diff --git a/backend/src/services/approval-policy/approval-policy-factory.ts b/backend/src/services/approval-policy/approval-policy-factory.ts new file mode 100644 index 0000000000..42ef11ac7b --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-factory.ts @@ -0,0 +1,18 @@ +import { ApprovalPolicyType } from "./approval-policy-enums"; +import { + TApprovalPolicy, + TApprovalPolicyInputs, + TApprovalRequestData, + TApprovalResourceFactory +} from "./approval-policy-types"; +import { pamAccessPolicyFactory } from "./pam-access/pam-access-policy-factory"; + +type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory< + TApprovalPolicyInputs, + TApprovalPolicy, + TApprovalRequestData +>; + +export const APPROVAL_POLICY_FACTORY_MAP: Record = { + [ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation +}; diff --git a/backend/src/services/approval-policy/approval-policy-schemas.ts b/backend/src/services/approval-policy/approval-policy-schemas.ts new file mode 100644 index 0000000000..d912e3245b --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-schemas.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; + +import { + ApprovalPoliciesSchema, + ApprovalRequestApprovalsSchema, + ApprovalRequestGrantsSchema, + ApprovalRequestsSchema, + ApprovalRequestStepsSchema +} from "@app/db/schemas"; +import { ms } from "@app/lib/ms"; + +import { ApproverType } from "./approval-policy-enums"; + +const ApprovalPolicyStepSchema = z.object({ + name: z.string().min(1).max(128).nullable().optional(), + requiredApprovals: z.number().min(1).max(100), + notifyApprovers: z.boolean().nullable().optional(), + approvers: z + .object({ + type: z.nativeEnum(ApproverType), + id: z.string().uuid() + }) + .array() +}); + +const MaxRequestTtlSchema = z.string().refine( + (val) => { + const duration = ms(val) / 1000; + + // 1 hour to 30 days + return duration >= 3600 && duration <= 2592000; + }, + { message: "Duration must be between 1 hour and 30 days" } +); + +// Policy +export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({ + steps: ApprovalPolicyStepSchema.array() +}); + +export const BaseCreateApprovalPolicySchema = z.object({ + projectId: z.string().uuid(), + name: z.string().min(1).max(128), + maxRequestTtl: MaxRequestTtlSchema.nullable().optional(), + steps: ApprovalPolicyStepSchema.array() +}); + +export const BaseUpdateApprovalPolicySchema = z.object({ + name: z.string().min(1).max(128).optional(), + maxRequestTtl: MaxRequestTtlSchema.nullable().optional(), + steps: ApprovalPolicyStepSchema.array().optional() +}); + +// Request +const ApprovalRequestStepSchema = ApprovalRequestStepsSchema.extend({ + name: z.string().min(1).max(128).nullable().optional(), + requiredApprovals: z.number().min(1).max(100), + notifyApprovers: z.boolean().nullable().optional(), + stepNumber: z.number(), + status: z.string(), + startedAt: z.date().nullable().optional(), + completedAt: z.date().nullable().optional(), + approvers: z + .object({ + type: z.nativeEnum(ApproverType), + id: z.string().uuid() + }) + .array(), + approvals: ApprovalRequestApprovalsSchema.array() +}); + +export const BaseApprovalRequestSchema = ApprovalRequestsSchema.extend({ + steps: ApprovalRequestStepSchema.array() +}); + +export const BaseCreateApprovalRequestSchema = z.object({ + projectId: z.string().uuid(), + justification: z.string().max(256).nullable().optional(), + requestDuration: z + .string() + .refine( + (val) => { + const duration = ms(val) / 1000; + + // 1 minute to 30 days + return duration >= 60 && duration <= 2592000; + }, + { message: "Duration must be between 1 minute and 30 days" } + ) + .nullable() + .optional() +}); + +// Grants +export const BaseApprovalRequestGrantSchema = ApprovalRequestGrantsSchema; diff --git a/backend/src/services/approval-policy/approval-policy-service.ts b/backend/src/services/approval-policy/approval-policy-service.ts new file mode 100644 index 0000000000..80fa820ac4 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-service.ts @@ -0,0 +1,902 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies, TApprovalRequests } from "@app/db/schemas"; +import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionApprovalRequestActions, + ProjectPermissionApprovalRequestGrantActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { OrgServiceActor } from "@app/lib/types"; +import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; +import { NotificationType } from "@app/services/notification/notification-types"; + +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { + TApprovalPolicyDALFactory, + TApprovalPolicyStepApproversDALFactory, + TApprovalPolicyStepsDALFactory +} from "./approval-policy-dal"; +import { + ApprovalPolicyType, + ApprovalRequestApprovalDecision, + ApprovalRequestGrantStatus, + ApprovalRequestStatus, + ApprovalRequestStepStatus, + ApproverType +} from "./approval-policy-enums"; +import { APPROVAL_POLICY_FACTORY_MAP } from "./approval-policy-factory"; +import { + ApprovalPolicyStep, + TApprovalRequest, + TCreatePolicyDTO, + TCreateRequestDTO, + TUpdatePolicyDTO +} from "./approval-policy-types"; +import { + TApprovalRequestApprovalsDALFactory, + TApprovalRequestDALFactory, + TApprovalRequestGrantsDALFactory, + TApprovalRequestStepEligibleApproversDALFactory, + TApprovalRequestStepsDALFactory +} from "./approval-request-dal"; + +type TApprovalPolicyServiceFactoryDep = { + approvalPolicyDAL: TApprovalPolicyDALFactory; + approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory; + approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory; + approvalRequestApprovalsDAL: TApprovalRequestApprovalsDALFactory; + approvalRequestDAL: TApprovalRequestDALFactory; + approvalRequestStepsDAL: TApprovalRequestStepsDALFactory; + approvalRequestStepEligibleApproversDAL: TApprovalRequestStepEligibleApproversDALFactory; + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; + userGroupMembershipDAL: TUserGroupMembershipDALFactory; + notificationService: TNotificationServiceFactory; + permissionService: Pick; + projectMembershipDAL: Pick; +}; +export type TApprovalPolicyServiceFactory = ReturnType; + +export const approvalPolicyServiceFactory = ({ + approvalPolicyDAL, + approvalPolicyStepsDAL, + approvalPolicyStepApproversDAL, + approvalRequestApprovalsDAL, + approvalRequestDAL, + approvalRequestStepsDAL, + approvalRequestStepEligibleApproversDAL, + approvalRequestGrantsDAL, + userGroupMembershipDAL, + notificationService, + permissionService, + projectMembershipDAL +}: TApprovalPolicyServiceFactoryDep) => { + const $notifyApproversForStep = async (step: ApprovalPolicyStep, request: TApprovalRequests) => { + if (!step.notifyApprovers) return; + + const userIdsToNotify = new Set(); + + for await (const approver of step.approvers) { + if (approver.type === ApproverType.User) { + userIdsToNotify.add(approver.id); + } else if (approver.type === ApproverType.Group) { + const members = await userGroupMembershipDAL.find({ groupId: approver.id }); + members.forEach((member) => userIdsToNotify.add(member.userId)); + } + } + + if (userIdsToNotify.size === 0) return; + + // TODO: Potentially link to requests in the future to support click redirects + await notificationService.createUserNotifications( + Array.from(userIdsToNotify).map((userId) => ({ + userId, + orgId: request.organizationId, + type: NotificationType.APPROVAL_REQUIRED, + title: "Approval Required", + body: `You have a new approval request for ${request.type} from ${request.requesterName}.` + })) + ); + }; + + const $verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => { + const uniqueUserIds = [...new Set(userIds)]; + if (uniqueUserIds.length === 0) return; + + const allMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, uniqueUserIds); + const projectMemberships = allMemberships.filter((membership) => membership.projectId === projectId); + + if (projectMemberships.length !== uniqueUserIds.length) { + const projectMemberUserIds = new Set(projectMemberships.map((membership) => membership.userId)); + const userIdsNotInProject = uniqueUserIds.filter((id) => !projectMemberUserIds.has(id)); + throw new BadRequestError({ + message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}` + }); + } + }; + + const create = async ( + policyType: ApprovalPolicyType, + { projectId, name, maxRequestTtl, conditions, constraints, steps }: TCreatePolicyDTO, + actor: OrgServiceActor + ) => { + const { hasRole } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.Any + }); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); + } + + // Verify all users are part of project + const approverUserIds = steps + .flatMap((step) => step.approvers ?? []) + .filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id); + await $verifyProjectUserMembership(approverUserIds, actor.orgId, projectId); + + const policy = await approvalPolicyDAL.transaction(async (tx) => { + const newPolicy = await approvalPolicyDAL.create( + { + projectId, + organizationId: actor.orgId, + name, + maxRequestTtl, + conditions: { version: 1, conditions }, + constraints: { version: 1, constraints }, + type: policyType + }, + tx + ); + + // Create policy steps and their approvers + await Promise.all( + steps.map(async (step, i) => { + const newStep = await approvalPolicyStepsDAL.create( + { + policyId: newPolicy.id, + requiredApprovals: step.requiredApprovals, + stepNumber: i + 1, + name: step.name, + notifyApprovers: step.notifyApprovers + }, + tx + ); + + if (step.approvers?.length) { + await Promise.all( + step.approvers.map((approver) => + approvalPolicyStepApproversDAL.create( + { + policyStepId: newStep.id, + userId: approver.type === ApproverType.User ? approver.id : null, + groupId: approver.type === ApproverType.Group ? approver.id : null + }, + tx + ) + ) + ); + } + }) + ); + + return newPolicy; + }); + + return { + policy: { ...policy, steps } + }; + }; + + const list = async (policyType: ApprovalPolicyType, projectId: string, actor: OrgServiceActor) => { + const { hasRole } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.Any + }); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); + } + + const policies = await approvalPolicyDAL.findByProjectId(policyType, projectId); + + return { policies }; + }; + + const getById = async (policyId: string, actor: OrgServiceActor) => { + const policy = await approvalPolicyDAL.findById(policyId); + if (!policy) { + throw new ForbiddenRequestError({ message: "Policy not found" }); + } + + const { hasRole } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: policy.projectId, + actionProjectType: ActionProjectType.Any + }); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); + } + + const steps = await approvalPolicyDAL.findStepsByPolicyId(policyId); + + return { policy: { ...policy, steps } }; + }; + + const updateById = async ( + policyId: string, + { name, maxRequestTtl, conditions, constraints, steps }: TUpdatePolicyDTO, + actor: OrgServiceActor + ) => { + const policy = await approvalPolicyDAL.findById(policyId); + if (!policy) { + throw new ForbiddenRequestError({ message: "Policy not found" }); + } + + const { hasRole } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: policy.projectId, + actionProjectType: ActionProjectType.Any + }); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); + } + + if (steps !== undefined) { + // Verify all users are part of project + const approverUserIds = steps + .flatMap((step) => step.approvers ?? []) + .filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id); + await $verifyProjectUserMembership(approverUserIds, actor.orgId, policy.projectId); + } + + const updatedPolicy = await approvalPolicyDAL.transaction(async (tx) => { + const updateDoc: Partial = {}; + + if (name !== undefined) { + updateDoc.name = name; + } + + if (maxRequestTtl !== undefined) { + updateDoc.maxRequestTtl = maxRequestTtl; + } + + if (conditions !== undefined) { + updateDoc.conditions = { version: 1, conditions }; + } + + if (constraints !== undefined) { + updateDoc.constraints = { version: 1, constraints }; + } + + const updated = await approvalPolicyDAL.updateById(policyId, updateDoc, tx); + + if (steps !== undefined) { + await approvalPolicyStepsDAL.delete({ policyId }, tx); + + await Promise.all( + steps.map(async (step, i) => { + const newStep = await approvalPolicyStepsDAL.create( + { + policyId, + requiredApprovals: step.requiredApprovals, + stepNumber: i + 1, + name: step.name, + notifyApprovers: step.notifyApprovers + }, + tx + ); + + if (step.approvers?.length) { + await Promise.all( + step.approvers.map((approver) => + approvalPolicyStepApproversDAL.create( + { + policyStepId: newStep.id, + userId: approver.type === ApproverType.User ? approver.id : null, + groupId: approver.type === ApproverType.Group ? approver.id : null + }, + tx + ) + ) + ); + } + }) + ); + } + return updated; + }); + + const fetchedSteps = await approvalPolicyDAL.findStepsByPolicyId(policyId); + + return { + policy: { ...updatedPolicy, steps: fetchedSteps } + }; + }; + + const deleteById = async (policyId: string, actor: OrgServiceActor) => { + const policy = await approvalPolicyDAL.findById(policyId); + if (!policy) { + throw new ForbiddenRequestError({ message: "Policy not found" }); + } + + const { hasRole } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: policy.projectId, + actionProjectType: ActionProjectType.Any + }); + + if (!hasRole(ProjectMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); + } + + await approvalPolicyDAL.deleteById(policyId); + + return { + policyId, + projectId: policy.projectId + }; + }; + + const createRequest = async ( + policyType: ApprovalPolicyType, + { + projectId, + requestData, + requestDuration, + justification, + requesterName, + requesterEmail + }: TCreateRequestDTO & { + requesterName: string; + requesterEmail: string; + }, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalRequestActions.Create, + ProjectPermissionSub.ApprovalRequests + ); + + const fac = APPROVAL_POLICY_FACTORY_MAP[policyType](policyType); + + const policy = await fac.matchPolicy(approvalPolicyDAL, projectId, requestData); + + if (!policy) { + throw new ForbiddenRequestError({ + message: "No policies match the requested resource, you can access it without a request" + }); + } + + const constraintValidation = fac.validateConstraints(policy, requestData); + if (!constraintValidation.valid) { + const errorMessage = constraintValidation.errors + ? `Policy constraints not met: ${constraintValidation.errors.join("; ")}` + : "Policy constraints not met"; + throw new ForbiddenRequestError({ message: errorMessage }); + } + + let expiresAt: Date | undefined; + + if (requestDuration) { + const ttlMs = ms(requestDuration); + + expiresAt = new Date(Date.now() + ttlMs); + + if (policy.maxRequestTtl) { + const maxTtlMs = ms(policy.maxRequestTtl); + if (ttlMs > maxTtlMs) { + throw new BadRequestError({ + message: `Expiration time exceeds the maximum allowed TTL of ${policy.maxRequestTtl}` + }); + } + } + } + + const { request, steps } = await approvalRequestDAL.transaction(async (tx) => { + const newRequest = await approvalRequestDAL.create( + { + projectId, + organizationId: actor.orgId, + policyId: policy.id, + requesterId: actor.id, + requesterName, + requesterEmail, + type: policyType, + status: ApprovalRequestStatus.Pending, + justification, + currentStep: 1, + requestData: { version: 1, requestData }, + expiresAt + }, + tx + ); + + const newSteps = await Promise.all( + policy.steps.map(async (step, i) => { + const stepNum = i + 1; + const newStep = await approvalRequestStepsDAL.create( + { + requestId: newRequest.id, + stepNumber: stepNum, + name: step.name, + status: stepNum === 1 ? ApprovalRequestStepStatus.InProgress : ApprovalRequestStepStatus.Pending, + requiredApprovals: step.requiredApprovals, + notifyApprovers: step.notifyApprovers, + startedAt: stepNum === 1 ? new Date() : null + }, + tx + ); + + await Promise.all( + step.approvers.map((approver) => + approvalRequestStepEligibleApproversDAL.create( + { + stepId: newStep.id, + userId: approver.type === ApproverType.User ? approver.id : null, + groupId: approver.type === ApproverType.Group ? approver.id : null + }, + tx + ) + ) + ); + + return { + ...newStep, + approvers: step.approvers, + approvals: [] + }; + }) + ); + + return { request: newRequest, steps: newSteps }; + }); + + if (steps.length > 0) { + await $notifyApproversForStep(steps[0], request); + } + + return { + request: { ...request, steps } + }; + }; + + const getRequestById = async (requestId: string, actor: OrgServiceActor) => { + const request = await approvalRequestDAL.findById(requestId); + if (!request) { + throw new ForbiddenRequestError({ message: "Request not found" }); + } + + const steps = await approvalRequestDAL.findStepsByRequestId(requestId); + + const isRequester = request.requesterId === actor.id; + + // Check if user is an eligible approver for any step + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId); + const userGroupIds = new Set(userGroups.map((g) => g.groupId)); + + const isApprover = steps.some((step) => + step.approvers.some( + (approver) => + (approver.type === ApproverType.User && approver.id === actor.id) || + (approver.type === ApproverType.Group && userGroupIds.has(approver.id)) + ) + ); + + // If user is requester or approver, allow access regardless of role permission + if (!isRequester && !isApprover) { + // Otherwise, check role permission + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: request.projectId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalRequestActions.Read, + ProjectPermissionSub.ApprovalRequests + ); + } + + return { + request: { ...request, steps } + }; + }; + + const approveRequest = async (requestId: string, { comment }: { comment?: string }, actor: OrgServiceActor) => { + const request = await approvalRequestDAL.findById(requestId); + if (!request) { + throw new ForbiddenRequestError({ message: "Request not found" }); + } + + if (request.status !== ApprovalRequestStatus.Pending) { + throw new BadRequestError({ message: "Request is not pending" }); + } + + if (request.expiresAt && new Date(request.expiresAt) < new Date()) { + await approvalRequestDAL.updateById(requestId, { status: ApprovalRequestStatus.Expired }); + throw new BadRequestError({ message: "Request has expired" }); + } + + const steps = await approvalRequestDAL.findStepsByRequestId(requestId); + const currentStepIndex = steps.findIndex((s) => s.stepNumber === request.currentStep); + if (currentStepIndex === -1) { + throw new BadRequestError({ message: "Current step not found" }); + } + + const currentStep = steps[currentStepIndex]; + + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId); + const userGroupIds = new Set(userGroups.map((g) => g.groupId)); + + const isEligible = currentStep.approvers.some( + (approver) => + (approver.type === ApproverType.User && approver.id === actor.id) || + (approver.type === ApproverType.Group && userGroupIds.has(approver.id)) + ); + + if (!isEligible) { + throw new ForbiddenRequestError({ message: "You are not an eligible approver for this step" }); + } + + const hasApproved = currentStep.approvals.some((a) => a.approverUserId === actor.id); + if (hasApproved) { + throw new BadRequestError({ message: "You have already approved this request" }); + } + + const { updatedRequest, nextStepToNotify } = await approvalRequestDAL.transaction(async (tx) => { + let nextStepToNotifyInner = null; + + // Create approval + await approvalRequestApprovalsDAL.create( + { + stepId: currentStep.id, + approverUserId: actor.id, + decision: ApprovalRequestApprovalDecision.Approved, + comment + }, + tx + ); + + const newApprovalCount = currentStep.approvals.length + 1; + if (newApprovalCount >= currentStep.requiredApprovals) { + // Step completed + await approvalRequestStepsDAL.updateById( + currentStep.id, + { + status: ApprovalRequestStepStatus.Completed, + completedAt: new Date() + }, + tx + ); + + const nextStep = steps[currentStepIndex + 1]; + if (nextStep) { + // Move to next step + await approvalRequestDAL.updateById( + requestId, + { + currentStep: request.currentStep + 1 + }, + tx + ); + + await approvalRequestStepsDAL.updateById( + nextStep.id, + { + status: ApprovalRequestStepStatus.InProgress, + startedAt: new Date() + }, + tx + ); + + if (nextStep.notifyApprovers) { + nextStepToNotifyInner = nextStep; + } + } else { + // All steps completed + const completedReq = await approvalRequestDAL.updateById( + requestId, + { + status: ApprovalRequestStatus.Approved + }, + tx + ); + + return { updatedRequest: completedReq, nextStepToNotify: null }; + } + } + + return { updatedRequest: request, nextStepToNotify: nextStepToNotifyInner }; + }); + + if (nextStepToNotify) { + await $notifyApproversForStep(nextStepToNotify, updatedRequest); + } + + // Fetch fresh state + const finalSteps = await approvalRequestDAL.findStepsByRequestId(requestId); + const finalRequest = await approvalRequestDAL.findById(requestId); + + const newRequest = { ...finalRequest, steps: finalSteps }; + + if (updatedRequest.status === ApprovalRequestStatus.Approved) { + const fac = APPROVAL_POLICY_FACTORY_MAP[updatedRequest.type as ApprovalPolicyType]( + updatedRequest.type as ApprovalPolicyType + ); + await fac.postApprovalRoutine(approvalRequestGrantsDAL, newRequest as TApprovalRequest); + } + + return { request: newRequest }; + }; + + const rejectRequest = async (requestId: string, { comment }: { comment?: string }, actor: OrgServiceActor) => { + const request = await approvalRequestDAL.findById(requestId); + if (!request) { + throw new ForbiddenRequestError({ message: "Request not found" }); + } + + if (request.status !== ApprovalRequestStatus.Pending) { + throw new BadRequestError({ message: "Request is not pending" }); + } + + if (request.expiresAt && new Date(request.expiresAt) < new Date()) { + await approvalRequestDAL.updateById(requestId, { status: ApprovalRequestStatus.Expired }); + throw new BadRequestError({ message: "Request has expired" }); + } + + const steps = await approvalRequestDAL.findStepsByRequestId(requestId); + const currentStep = steps.find((s) => s.stepNumber === request.currentStep); + + if (!currentStep) { + throw new BadRequestError({ message: "Current step not found" }); + } + + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId); + const userGroupIds = new Set(userGroups.map((g) => g.groupId)); + + const isEligible = currentStep.approvers.some( + (approver) => + (approver.type === ApproverType.User && approver.id === actor.id) || + (approver.type === ApproverType.Group && userGroupIds.has(approver.id)) + ); + + if (!isEligible) { + throw new ForbiddenRequestError({ message: "You are not an eligible approver for this step" }); + } + + await approvalRequestDAL.transaction(async (tx) => { + await approvalRequestApprovalsDAL.create( + { + stepId: currentStep.id, + approverUserId: actor.id, + decision: ApprovalRequestApprovalDecision.Rejected, + comment + }, + tx + ); + + await approvalRequestDAL.updateById( + requestId, + { + status: ApprovalRequestStatus.Rejected + }, + tx + ); + }); + + const finalSteps = await approvalRequestDAL.findStepsByRequestId(requestId); + const finalRequest = await approvalRequestDAL.findById(requestId); + + return { request: { ...finalRequest, steps: finalSteps } }; + }; + + const listRequests = async (policyType: ApprovalPolicyType, projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.Any + }); + + const hasReadPermission = permission.can( + ProjectPermissionApprovalRequestActions.Read, + ProjectPermissionSub.ApprovalRequests + ); + + const requests = await approvalRequestDAL.findByProjectId(policyType, projectId); + + // If user has read permission, return all requests + if (hasReadPermission) { + return { requests }; + } + + // Otherwise, filter to only requests where user is requester or approver + const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId); + const userGroupIds = new Set(userGroups.map((g) => g.groupId)); + + const filteredRequests = []; + for (const request of requests) { + const isRequester = request.requesterId === actor.id; + + if (isRequester) { + filteredRequests.push(request); + // eslint-disable-next-line no-continue + continue; + } + + // Check if user is an eligible approver for any step + const isApprover = request.steps.some((step) => + step.approvers.some( + (approver) => + (approver.type === ApproverType.User && approver.id === actor.id) || + (approver.type === ApproverType.Group && userGroupIds.has(approver.id)) + ) + ); + + if (isApprover) { + filteredRequests.push(request); + } + } + + return { requests: filteredRequests }; + }; + + const cancelRequest = async (requestId: string, actor: OrgServiceActor) => { + const request = await approvalRequestDAL.findById(requestId); + if (!request) { + throw new ForbiddenRequestError({ message: "Request not found" }); + } + + if (request.status !== ApprovalRequestStatus.Pending) { + throw new BadRequestError({ message: "Request is not pending" }); + } + + if (request.requesterId !== actor.id) { + throw new ForbiddenRequestError({ message: "You are not the requester of this request" }); + } + + const updatedRequest = await approvalRequestDAL.updateById(requestId, { + status: ApprovalRequestStatus.Cancelled + }); + + const steps = await approvalRequestDAL.findStepsByRequestId(requestId); + + return { request: { ...updatedRequest, steps } }; + }; + + const listGrants = async (policyType: ApprovalPolicyType, projectId: string, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalRequestGrantActions.Read, + ProjectPermissionSub.ApprovalRequestGrants + ); + + const grants = await approvalRequestGrantsDAL.find({ projectId, type: policyType }); + return { grants }; + }; + + const getGrantById = async (grantId: string, actor: OrgServiceActor) => { + const grant = await approvalRequestGrantsDAL.findById(grantId); + if (!grant) { + throw new NotFoundError({ message: "Grant not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: grant.projectId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalRequestGrantActions.Read, + ProjectPermissionSub.ApprovalRequestGrants + ); + + return { grant }; + }; + + const revokeGrant = async ( + grantId: string, + { revocationReason }: { revocationReason?: string }, + actor: OrgServiceActor + ) => { + const grant = await approvalRequestGrantsDAL.findById(grantId); + if (!grant) { + throw new NotFoundError({ message: "Grant not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorId: actor.id, + actorOrgId: actor.orgId, + projectId: grant.projectId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionApprovalRequestGrantActions.Revoke, + ProjectPermissionSub.ApprovalRequestGrants + ); + + if (grant.status !== ApprovalRequestGrantStatus.Active) { + throw new BadRequestError({ message: "Grant is not active" }); + } + + const updatedGrant = await approvalRequestGrantsDAL.updateById(grantId, { + status: ApprovalRequestGrantStatus.Revoked, + revokedAt: new Date(), + revokedByUserId: actor.id, + revocationReason + }); + + return { grant: updatedGrant }; + }; + + return { + create, + list, + getById, + updateById, + deleteById, + createRequest, + listRequests, + getRequestById, + approveRequest, + rejectRequest, + cancelRequest, + listGrants, + getGrantById, + revokeGrant + }; +}; diff --git a/backend/src/services/approval-policy/approval-policy-types.ts b/backend/src/services/approval-policy/approval-policy-types.ts new file mode 100644 index 0000000000..8dccc1453b --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-types.ts @@ -0,0 +1,88 @@ +import { TApprovalPolicyDALFactory } from "@app/services/approval-policy/approval-policy-dal"; +import { TApprovalRequestGrantsDALFactory } from "@app/services/approval-policy/approval-request-dal"; + +import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums"; +import { + TPamAccessPolicy, + TPamAccessPolicyConditions, + TPamAccessPolicyConstraints, + TPamAccessPolicyInputs, + TPamAccessRequest, + TPamAccessRequestData +} from "./pam-access/pam-access-policy-types"; + +export type TApprovalPolicy = TPamAccessPolicy; +export type TApprovalPolicyInputs = TPamAccessPolicyInputs; +export type TApprovalPolicyConditions = TPamAccessPolicyConditions; +export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints; + +export type TApprovalRequest = TPamAccessRequest; +export type TApprovalRequestData = TPamAccessRequestData; + +export interface ApprovalPolicyStep { + name?: string | null; + requiredApprovals: number; + notifyApprovers?: boolean | null; + approvers: { + type: ApproverType; + id: string; + }[]; +} + +// Policy DTOs +export interface TCreatePolicyDTO { + projectId: TApprovalPolicy["projectId"]; + name: TApprovalPolicy["name"]; + maxRequestTtl?: TApprovalPolicy["maxRequestTtl"]; + conditions: TApprovalPolicy["conditions"]["conditions"]; + constraints: TApprovalPolicy["constraints"]["constraints"]; + steps: ApprovalPolicyStep[]; +} + +export interface TUpdatePolicyDTO { + name?: TApprovalPolicy["name"]; + maxRequestTtl?: TApprovalPolicy["maxRequestTtl"]; + conditions?: TApprovalPolicy["conditions"]["conditions"]; + constraints?: TApprovalPolicy["constraints"]["constraints"]; + steps?: ApprovalPolicyStep[]; +} + +// Request DTOs +export interface TCreateRequestDTO { + projectId: TApprovalRequest["projectId"]; + requestData: TApprovalRequest["requestData"]["requestData"]; + justification?: TApprovalRequest["justification"]; + requestDuration?: string | null; +} + +// Factory +export type TApprovalRequestFactoryMatchPolicy = ( + approvalPolicyDAL: TApprovalPolicyDALFactory, + projectId: string, + inputs: I +) => Promise

; +export type TApprovalRequestFactoryCanAccess = ( + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory, + projectId: string, + userId: string, + inputs: I +) => Promise; +export type TApprovalRequestFactoryValidateConstraints

= ( + policy: P, + inputs: R +) => { valid: boolean; errors?: string[] }; +export type TApprovalRequestFactoryPostApprovalRoutine = ( + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory, + request: TApprovalRequest +) => Promise; + +export type TApprovalResourceFactory< + I extends TApprovalPolicyInputs, + P extends TApprovalPolicy, + R extends TApprovalRequestData +> = (policyType: ApprovalPolicyType) => { + matchPolicy: TApprovalRequestFactoryMatchPolicy; + canAccess: TApprovalRequestFactoryCanAccess; + validateConstraints: TApprovalRequestFactoryValidateConstraints; + postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine; +}; diff --git a/backend/src/services/approval-policy/approval-request-dal.ts b/backend/src/services/approval-policy/approval-request-dal.ts new file mode 100644 index 0000000000..ced8bea419 --- /dev/null +++ b/backend/src/services/approval-policy/approval-request-dal.ts @@ -0,0 +1,199 @@ +import { TDbClient } from "@app/db"; +import { TableName, TApprovalRequestApprovals } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { + ApprovalPolicyType, + ApprovalRequestGrantStatus, + ApprovalRequestStatus, + ApproverType +} from "./approval-policy-enums"; +import { ApprovalPolicyStep } from "./approval-policy-types"; + +// Approval Request +export type TApprovalRequestDALFactory = ReturnType; +export const approvalRequestDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequests); + + const findStepsByRequestId = async (requestId: string) => { + try { + const dbInstance = db.replicaNode(); + const steps = await dbInstance(TableName.ApprovalRequestSteps).where({ requestId }).orderBy("stepNumber", "asc"); + + if (!steps.length) { + return []; + } + + const stepIds = steps.map((step) => step.id); + + const [approvers, approvals] = await Promise.all([ + dbInstance(TableName.ApprovalRequestStepEligibleApprovers) + .whereIn("stepId", stepIds) + .select("stepId", "userId", "groupId"), + dbInstance(TableName.ApprovalRequestApprovals).whereIn("stepId", stepIds) + ]); + + const approversByStepId = approvers.reduce>( + (acc, approver) => { + const stepApprovers = acc[approver.stepId] || []; + stepApprovers.push({ + type: approver.userId ? ApproverType.User : ApproverType.Group, + id: (approver.userId || approver.groupId) as string + }); + acc[approver.stepId] = stepApprovers; + return acc; + }, + {} + ); + + const approvalsByStepId = approvals.reduce>((acc, approval) => { + const stepApprovals = acc[approval.stepId] || []; + stepApprovals.push(approval); + acc[approval.stepId] = stepApprovals; + return acc; + }, {}); + + return steps.map((step) => { + return { + ...step, + approvers: approversByStepId[step.id] || [], + approvals: approvalsByStepId[step.id] || [] + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find approval request steps" }); + } + }; + + const findByProjectId = async (policyType: ApprovalPolicyType, projectId: string) => { + try { + const dbInstance = db.replicaNode(); + const requests = await dbInstance(TableName.ApprovalRequests).where({ type: policyType, projectId }); + + if (!requests.length) { + return []; + } + + const requestIds = requests.map((req) => req.id); + + const steps = await dbInstance(TableName.ApprovalRequestSteps) + .whereIn("requestId", requestIds) + .orderBy("stepNumber", "asc"); + + const stepsByRequestId: Record = {}; + + if (steps.length) { + const stepIds = steps.map((step) => step.id); + + const [approvers, approvals] = await Promise.all([ + dbInstance(TableName.ApprovalRequestStepEligibleApprovers) + .whereIn("stepId", stepIds) + .select("stepId", "userId", "groupId"), + dbInstance(TableName.ApprovalRequestApprovals).whereIn("stepId", stepIds) + ]); + + const approversByStepId = approvers.reduce>( + (acc, approver) => { + const stepApprovers = acc[approver.stepId] || []; + stepApprovers.push({ + type: approver.userId ? ApproverType.User : ApproverType.Group, + id: (approver.userId || approver.groupId) as string + }); + acc[approver.stepId] = stepApprovers; + return acc; + }, + {} + ); + + const approvalsByStepId = approvals.reduce>((acc, approval) => { + const stepApprovals = acc[approval.stepId] || []; + stepApprovals.push(approval); + acc[approval.stepId] = stepApprovals; + return acc; + }, {}); + + steps.forEach((step) => { + const formattedStep = { + ...step, + approvers: approversByStepId[step.id] || [], + approvals: approvalsByStepId[step.id] || [] + }; + + if (!stepsByRequestId[step.requestId]) { + stepsByRequestId[step.requestId] = []; + } + stepsByRequestId[step.requestId].push(formattedStep); + }); + } + + return requests.map((req) => ({ + ...req, + steps: stepsByRequestId[req.id] || [] + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find approval requests by project id" }); + } + }; + + const markExpiredRequests = async () => { + try { + const result = await db(TableName.ApprovalRequests) + .where("status", ApprovalRequestStatus.Pending) + .whereNotNull("expiresAt") + .where("expiresAt", "<", new Date()) + .update({ status: ApprovalRequestStatus.Expired }); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "Mark expired approval requests" }); + } + }; + + return { ...orm, findStepsByRequestId, findByProjectId, markExpiredRequests }; +}; + +// Approval Request Steps +export type TApprovalRequestStepsDALFactory = ReturnType; +export const approvalRequestStepsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestSteps); + return orm; +}; + +// Approval Request Step Eligible Approvers +export type TApprovalRequestStepEligibleApproversDALFactory = ReturnType< + typeof approvalRequestStepEligibleApproversDALFactory +>; +export const approvalRequestStepEligibleApproversDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestStepEligibleApprovers); + return orm; +}; + +// Approval Request Grants +export type TApprovalRequestGrantsDALFactory = ReturnType; +export const approvalRequestGrantsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestGrants); + + const markExpiredGrants = async () => { + try { + const result = await db(TableName.ApprovalRequestGrants) + .where("status", ApprovalRequestGrantStatus.Active) + .whereNotNull("expiresAt") + .where("expiresAt", "<", new Date()) + .update({ status: ApprovalRequestGrantStatus.Expired }); + + return result; + } catch (error) { + throw new DatabaseError({ error, name: "Mark expired approval grants" }); + } + }; + + return { ...orm, markExpiredGrants }; +}; + +// Approval Request Approvals +export type TApprovalRequestApprovalsDALFactory = ReturnType; +export const approvalRequestApprovalsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestApprovals); + return orm; +}; diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts new file mode 100644 index 0000000000..21b97e0d16 --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts @@ -0,0 +1,127 @@ +import picomatch from "picomatch"; + +import { ms } from "@app/lib/ms"; + +import { ApprovalRequestGrantStatus } from "../approval-policy-enums"; +import { + TApprovalRequestFactoryCanAccess, + TApprovalRequestFactoryMatchPolicy, + TApprovalRequestFactoryPostApprovalRoutine, + TApprovalRequestFactoryValidateConstraints, + TApprovalResourceFactory +} from "../approval-policy-types"; +import { TPamAccessPolicy, TPamAccessPolicyInputs, TPamAccessRequestData } from "./pam-access-policy-types"; + +export const pamAccessPolicyFactory: TApprovalResourceFactory< + TPamAccessPolicyInputs, + TPamAccessPolicy, + TPamAccessRequestData +> = (policyType) => { + const matchPolicy: TApprovalRequestFactoryMatchPolicy = async ( + approvalPolicyDAL, + projectId, + inputs + ) => { + const policies = await approvalPolicyDAL.findByProjectId(policyType, projectId); + + let bestMatch: { policy: TPamAccessPolicy; wildcardCount: number; pathLength: number } | null = null; + + for (const policy of policies) { + const p = policy as TPamAccessPolicy; + for (const c of p.conditions.conditions) { + // Find the most specific path pattern + // TODO(andrey): Make matching logic more advanced by accounting for wildcard positions + for (const pathPattern of c.accountPaths) { + if (picomatch(pathPattern)(inputs.accountPath)) { + const wildcardCount = (pathPattern.match(/\*/g) || []).length; + const pathLength = pathPattern.length; + + if ( + !bestMatch || + wildcardCount < bestMatch.wildcardCount || + (wildcardCount === bestMatch.wildcardCount && pathLength > bestMatch.pathLength) + ) { + bestMatch = { policy: p, wildcardCount, pathLength }; + } + } + } + } + } + + return bestMatch?.policy || null; + }; + + const canAccess: TApprovalRequestFactoryCanAccess = async ( + approvalRequestGrantsDAL, + projectId, + userId, + inputs + ) => { + const grants = await approvalRequestGrantsDAL.find({ + granteeUserId: userId, + type: policyType, + status: ApprovalRequestGrantStatus.Active, + projectId, + revokedAt: null + }); + + // TODO(andrey): Move some of this check to be part of SQL query + return grants.some((grant) => { + const grantAttributes = grant.attributes as TPamAccessPolicyInputs; + const isMatch = picomatch(grantAttributes.accountPath); + return isMatch(inputs.accountPath) && (!grant.expiresAt || grant.expiresAt > new Date()); + }); + }; + + const validateConstraints: TApprovalRequestFactoryValidateConstraints = ( + policy, + inputs + ) => { + const reqDuration = ms(inputs.accessDuration); + const durationConstraint = policy.constraints.constraints.accessDuration; + const minDuration = ms(durationConstraint.min); + const maxDuration = ms(durationConstraint.max); + + const errors: string[] = []; + + if (reqDuration < minDuration) { + errors.push( + `Access duration ${inputs.accessDuration} is below the minimum allowed duration of ${durationConstraint.min}` + ); + } + + if (reqDuration > maxDuration) { + errors.push( + `Access duration ${inputs.accessDuration} exceeds the maximum allowed duration of ${durationConstraint.max}` + ); + } + + return { + valid: errors.length === 0, + errors: errors.length > 0 ? errors : undefined + }; + }; + + const postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine = async (approvalRequestGrantsDAL, request) => { + const inputs = request.requestData.requestData; + const durationMs = ms(inputs.accessDuration); + const expiresAt = new Date(Date.now() + durationMs); + + await approvalRequestGrantsDAL.create({ + projectId: request.projectId, + requestId: request.id, + granteeUserId: request.requesterId, + status: ApprovalRequestGrantStatus.Active, + type: request.type, + attributes: inputs, + expiresAt + }); + }; + + return { + matchPolicy, + canAccess, + validateConstraints, + postApprovalRoutine + }; +}; diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts new file mode 100644 index 0000000000..caa2cfa67f --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts @@ -0,0 +1,101 @@ +import picomatch from "picomatch"; +import { z } from "zod"; + +import { ms } from "@app/lib/ms"; + +import { + BaseApprovalPolicySchema, + BaseApprovalRequestGrantSchema, + BaseApprovalRequestSchema, + BaseCreateApprovalPolicySchema, + BaseCreateApprovalRequestSchema, + BaseUpdateApprovalPolicySchema +} from "../approval-policy-schemas"; + +// Inputs +export const PamAccessPolicyInputsSchema = z.object({ + accountPath: z.string() +}); + +// Conditions +export const PamAccessPolicyConditionsSchema = z + .object({ + accountPaths: z + .string() + .refine( + (el) => { + try { + picomatch.parse([el]); + return true; + } catch { + return false; + } + }, + { message: "Invalid glob pattern" } + ) + .array() + }) + .array(); + +const DurationSchema = z.string().refine( + (val) => { + const duration = ms(val) / 1000; + + // 30 seconds to 7 days + return duration >= 30 && duration <= 604800; + }, + { message: "Duration must be between 30 seconds and 7 days" } +); + +// Constraints +export const PamAccessPolicyConstraintsSchema = z.object({ + accessDuration: z.object({ + min: DurationSchema, + max: DurationSchema + }) +}); + +// Request Data +export const PamAccessPolicyRequestDataSchema = z.object({ + accountPath: z.string(), + accessDuration: DurationSchema +}); + +// Policy +export const PamAccessPolicySchema = BaseApprovalPolicySchema.extend({ + conditions: z.object({ + version: z.literal(1), + conditions: PamAccessPolicyConditionsSchema + }), + constraints: z.object({ + version: z.literal(1), + constraints: PamAccessPolicyConstraintsSchema + }) +}); + +export const CreatePamAccessPolicySchema = BaseCreateApprovalPolicySchema.extend({ + conditions: PamAccessPolicyConditionsSchema, + constraints: PamAccessPolicyConstraintsSchema +}); + +export const UpdatePamAccessPolicySchema = BaseUpdateApprovalPolicySchema.extend({ + conditions: PamAccessPolicyConditionsSchema.optional(), + constraints: PamAccessPolicyConstraintsSchema.optional() +}); + +// Request +export const PamAccessRequestSchema = BaseApprovalRequestSchema.extend({ + requestData: z.object({ + version: z.literal(1), + requestData: PamAccessPolicyRequestDataSchema + }) +}); + +export const CreatePamAccessRequestSchema = BaseCreateApprovalRequestSchema.extend({ + requestData: PamAccessPolicyRequestDataSchema +}); + +// Grants +export const PamAccessRequestGrantSchema = BaseApprovalRequestGrantSchema.extend({ + attributes: PamAccessPolicyRequestDataSchema +}); diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts new file mode 100644 index 0000000000..78118fcb0f --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { + PamAccessPolicyConditionsSchema, + PamAccessPolicyConstraintsSchema, + PamAccessPolicyInputsSchema, + PamAccessPolicyRequestDataSchema, + PamAccessPolicySchema, + PamAccessRequestSchema +} from "./pam-access-policy-schemas"; + +// Policy +export type TPamAccessPolicy = z.infer; +export type TPamAccessPolicyInputs = z.infer; +export type TPamAccessPolicyConditions = z.infer; +export type TPamAccessPolicyConstraints = z.infer; + +// Request +export type TPamAccessRequest = z.infer; +export type TPamAccessRequestData = z.infer; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index fb7213109e..c090f02de1 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -196,7 +196,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD }; // to parse jwt identity in inject identity plugin - const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload, subOrganizationSelector?: string) => { + const fnValidateJwtIdentity = async (token: AuthModeJwtTokenPayload) => { const session = await tokenDAL.findOneTokenSession({ id: token.tokenVersionId, userId: token.userId @@ -214,13 +214,17 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD let rootOrgId = ""; let parentOrgId = ""; if (token.organizationId) { - if (subOrganizationSelector) { + // Check if token has sub-organization scope + if (token.subOrganizationId) { const subOrganization = await orgDAL.findOne({ - rootOrgId: token.organizationId, - slug: subOrganizationSelector + id: token.subOrganizationId }); if (!subOrganization) - throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); + throw new BadRequestError({ message: `Sub organization ${token.subOrganizationId} not found` }); + // Verify the sub-org belongs to the token's root organization + if (subOrganization.rootOrgId !== token.organizationId && subOrganization.id !== token.organizationId) { + throw new ForbiddenRequestError({ message: "Sub-organization does not belong to the token's organization" }); + } const orgMembership = await membershipUserDAL.findOne({ actorUserId: user.id, diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 8e0f654a3c..9f13d0ddd7 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -13,7 +13,13 @@ import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; import { crypto, generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { getUserPrivateKey } from "@app/lib/crypto/srp"; -import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + DatabaseError, + ForbiddenRequestError, + NotFoundError, + UnauthorizedError +} from "@app/lib/errors"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; @@ -142,6 +148,7 @@ export const authLoginServiceFactory = ({ ip, userAgent, organizationId, + subOrganizationId, authMethod, isMfaVerified, mfaMethod @@ -150,6 +157,7 @@ export const authLoginServiceFactory = ({ ip: string; userAgent: string; organizationId?: string; + subOrganizationId?: string; authMethod: AuthMethod; isMfaVerified?: boolean; mfaMethod?: MfaMethod; @@ -193,6 +201,7 @@ export const authLoginServiceFactory = ({ tokenVersionId: tokenSession.id, accessVersion: tokenSession.accessVersion, organizationId, + subOrganizationId, isMfaVerified, mfaMethod }, @@ -208,6 +217,7 @@ export const authLoginServiceFactory = ({ tokenVersionId: tokenSession.id, refreshVersion: tokenSession.refreshVersion, organizationId, + subOrganizationId, isMfaVerified, mfaMethod }, @@ -526,33 +536,73 @@ export const authLoginServiceFactory = ({ const user = await userDAL.findUserEncKeyByUserId(decodedToken.userId); if (!user) throw new BadRequestError({ message: "User not found", name: "Find user from token" }); - // Check if the user actually has access to the specified organization. - const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); + // Check user membership in the sub-organization + const orgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: organizationId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted + }); - const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); - - const selectedOrg = await orgDAL.findById(organizationId); - - if (!selectedOrgMembership) { + if (!orgMembership) { throw new ForbiddenRequestError({ - message: `User does not have access to the organization named ${selectedOrg?.name}` + message: `User does not have access to the organization with ID ${organizationId}` }); } - // Check if authEnforced is true and the current auth method is not an enforced method + const selectedOrg = await orgDAL.findById(organizationId); + if (!selectedOrg) { + throw new NotFoundError({ message: `Organization with ID '${organizationId}' not found` }); + } + + const isSubOrganization = Boolean(selectedOrg.rootOrgId && selectedOrg.id !== selectedOrg.rootOrgId); + + const membershipRole = (await membershipRoleDAL.findOne({ membershipId: orgMembership.id })).role; + + let rootOrg = selectedOrg; + + if (isSubOrganization) { + if (!selectedOrg.rootOrgId) { + throw new BadRequestError({ + message: "Invalid sub-organization" + }); + } + + rootOrg = await orgDAL.findById(selectedOrg.rootOrgId); + if (!rootOrg) { + throw new BadRequestError({ + message: "Invalid sub-organization" + }); + } + + // Check user membership in the root organization + const rootOrgMembership = await membershipUserDAL.findOne({ + actorUserId: user.id, + scopeOrgId: selectedOrg.rootOrgId, + scope: AccessScope.Organization, + status: OrgMembershipStatus.Accepted + }); + + if (!rootOrgMembership) { + throw new ForbiddenRequestError({ + message: "User does not have access to the root organization" + }); + } + } + if ( - selectedOrg.authEnforced && + rootOrg.authEnforced && !isAuthMethodSaml(decodedToken.authMethod) && decodedToken.authMethod !== AuthMethod.OIDC && - !(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin) + !(rootOrg.bypassOrgAuthEnabled && membershipRole === OrgMembershipRole.Admin) ) { throw new BadRequestError({ message: "Login with the auth method required by your organization." }); } - if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { - const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + if (rootOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = rootOrg.bypassOrgAuthEnabled && membershipRole === OrgMembershipRole.Admin; if (!canBypass) { throw new ForbiddenRequestError({ @@ -563,13 +613,13 @@ export const authLoginServiceFactory = ({ } if (decodedToken.authMethod === AuthMethod.GOOGLE) { - await orgDAL.updateById(selectedOrg.id, { + await orgDAL.updateById(rootOrg.id, { googleSsoAuthLastUsed: new Date() }); } - const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; - const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; + const shouldCheckMfa = rootOrg.enforceMfa || user.isMfaEnabled; + const orgMfaMethod = rootOrg.enforceMfa ? (rootOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const mfaMethod = orgMfaMethod ?? userMfaMethod; @@ -603,15 +653,16 @@ export const authLoginServiceFactory = ({ user, userAgent, ip: ipAddress, - organizationId, + organizationId: isSubOrganization ? rootOrg.id : organizationId, + subOrganizationId: isSubOrganization ? organizationId : undefined, isMfaVerified: decodedToken.isMfaVerified, mfaMethod: decodedToken.mfaMethod }); // In the event of this being a break-glass request (non-saml / non-oidc, when either is enforced) if ( - selectedOrg.authEnforced && - selectedOrg.bypassOrgAuthEnabled && + rootOrg.authEnforced && + rootOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && decodedToken.authMethod !== AuthMethod.OIDC && decodedToken.authMethod !== AuthMethod.GOOGLE @@ -671,29 +722,55 @@ export const authLoginServiceFactory = ({ } } - await auditLogService.createAuditLog({ - orgId: organizationId, - ipAddress, - userAgent, - userAgentType: getUserAgentType(userAgent), - actor: { - type: ActorType.USER, - metadata: { - email: user.email, - userId: user.id, - username: user.username, - authMethod: decodedToken.authMethod + // Create audit log for organization selection + if (isSubOrganization) { + await auditLogService.createAuditLog({ + orgId: organizationId, + ipAddress, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: user.email, + userId: user.id, + username: user.username, + authMethod: decodedToken.authMethod + } + }, + event: { + type: EventType.SELECT_SUB_ORGANIZATION, + metadata: { + organizationId, + organizationName: selectedOrg.name, + rootOrganizationId: selectedOrg.rootOrgId || "" + } } - }, - event: { - type: EventType.SELECT_ORGANIZATION, - metadata: { - organizationId, - organizationName: selectedOrg.name + }); + } else { + await auditLogService.createAuditLog({ + orgId: organizationId, + ipAddress, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: user.email, + userId: user.id, + username: user.username, + authMethod: decodedToken.authMethod + } + }, + event: { + type: EventType.SELECT_ORGANIZATION, + metadata: { + organizationId, + organizationName: selectedOrg.name + } } - } - }); - + }); + } return { ...tokens, user, diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index 14f4387b93..ae27b113da 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -258,13 +258,13 @@ export const authSignupServiceFactory = ({ let refreshTokenExpiresIn: string | number = appCfg.JWT_REFRESH_LIFETIME; if (organizationId) { - const org = await orgService.findOrganizationById( - user.id, - organizationId, - authMethod, - organizationId, - organizationId - ); + const org = await orgService.findOrganizationById({ + userId: user.id, + orgId: organizationId, + actorAuthMethod: authMethod, + actorOrgId: organizationId, + rootOrgId: organizationId + }); if (org && org.userTokenExpiration) { tokenSessionExpiresIn = getMinExpiresIn(appCfg.JWT_AUTH_LIFETIME, org.userTokenExpiration); refreshTokenExpiresIn = org.userTokenExpiration; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index ef54ac0be7..f93f511dd1 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_PROFILE = "acmeProfile", ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } @@ -55,6 +56,7 @@ export type AuthModeJwtTokenPayload = { tokenVersionId: string; accessVersion: number; organizationId?: string; + subOrganizationId?: string; isMfaVerified?: boolean; mfaMethod?: MfaMethod; }; @@ -74,6 +76,7 @@ export type AuthModeRefreshJwtTokenPayload = { tokenVersionId: string; refreshVersion: number; organizationId?: string; + subOrganizationId?: string; isMfaVerified?: boolean; mfaMethod?: MfaMethod; }; 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 bfd3d45f61..9616b30903 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"; @@ -24,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"; @@ -47,6 +50,60 @@ import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-prov 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; @@ -55,7 +112,7 @@ type TAcmeCertificateAuthorityFnsDeps = { "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById" >; externalCertificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -66,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< @@ -80,6 +138,7 @@ type TOrderCertificateDeps = { "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" >; projectDAL: Pick; + certificateProfileDAL?: Pick; }; type DBConfigurationColumn = { @@ -93,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" }); } @@ -141,15 +200,22 @@ const getAcmeChallengeRecord = ( 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[]; @@ -157,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 @@ -169,7 +240,8 @@ export const orderCertificate = async ( certificateBodyDAL, certificateSecretDAL, kmsService, - projectDAL + projectDAL, + certificateProfileDAL } = deps; const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); @@ -199,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( @@ -364,6 +436,7 @@ export const orderCertificate = async ( { caId: ca.id, pkiSubscriberId: subscriberId, + profileId, status: CertStatus.ACTIVE, friendlyName: commonName, commonName, @@ -373,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, @@ -397,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; }); }; @@ -413,7 +513,8 @@ export const AcmeCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAcmeCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -615,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); }; @@ -668,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/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 d1d791a90f..7ebd66146f 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,7 +649,8 @@ export const AzureAdCsCertificateAuthorityFns = ({ projectDAL, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TAzureAdCsCertificateAuthorityFnsDeps) => { const createCertificateAuthority = async ({ name, @@ -740,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); }; @@ -1024,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) { @@ -1163,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 2c2dfe4843..004e3d4a5e 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/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts index 352675441d..6f621a515a 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-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index b9b3b8d42e..a6dd4ce9ef 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"; @@ -63,7 +68,7 @@ type TCertificateAuthorityServiceFactoryDep = { internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory; projectDAL: Pick; permissionService: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; kmsService: Pick< @@ -73,6 +78,7 @@ type TCertificateAuthorityServiceFactoryDep = { pkiSubscriberDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + certificateProfileDAL?: Pick; }; export type TCertificateAuthorityServiceFactory = ReturnType; @@ -91,7 +97,8 @@ export const certificateAuthorityServiceFactory = ({ kmsService, pkiSubscriberDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }: TCertificateAuthorityServiceFactoryDep) => { const acmeFns = AcmeCertificateAuthorityFns({ appConnectionDAL, @@ -105,7 +112,8 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ @@ -120,7 +128,8 @@ export const certificateAuthorityServiceFactory = ({ pkiSubscriberDAL, projectDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + certificateProfileDAL }); const createCertificateAuthority = async ( @@ -137,8 +146,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Create, + subject(ProjectPermissionSub.CertificateAuthorities, { name }) ); if (type === CaType.INTERNAL) { @@ -207,8 +216,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Read, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) ); if (type === CaType.INTERNAL) { @@ -222,6 +231,7 @@ export const certificateAuthorityServiceFactory = ({ id: certificateAuthority.id, type, enableDirectIssuance: certificateAuthority.enableDirectIssuance, + subject: ProjectPermissionSub.CertificateAuthorities, name: certificateAuthority.name, projectId: certificateAuthority.projectId, configuration: certificateAuthority.internalCa, @@ -270,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) { @@ -323,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)) @@ -347,11 +367,11 @@ 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" }); @@ -378,8 +398,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) ); if (type === CaType.INTERNAL) { @@ -454,8 +474,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) { @@ -519,8 +539,8 @@ export const certificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: certificateAuthority.name }) ); if (type === CaType.INTERNAL) { @@ -601,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) { @@ -657,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, @@ -667,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({ @@ -677,6 +706,47 @@ 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, findCertificateAuthorityById, @@ -685,6 +755,7 @@ export const certificateAuthorityServiceFactory = ({ updateCertificateAuthority, deleteCertificateAuthority, getAzureAdcsTemplates, + getCaById, deprecatedUpdateCertificateAuthority, deprecatedDeleteCertificateAuthority }; 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 0000000000..f590f28506 --- /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/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 35c596cd30..91b7727ec3 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 @@ -67,6 +67,7 @@ import { TGetCaDTO, TImportCertToCaDTO, TIssueCertFromCaDTO, + TIssueCertFromCaResponse, TRenewCaCertDTO, TSignCertFromCaDTO, TSignIntermediateDTO, @@ -173,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; @@ -356,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); @@ -382,8 +383,8 @@ export const internalCertificateAuthorityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { name: ca.name }) ); } @@ -415,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); @@ -441,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) @@ -505,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" }); @@ -792,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({ @@ -829,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({ @@ -910,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" }); @@ -1058,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) { @@ -1198,7 +1199,7 @@ export const internalCertificateAuthorityServiceFactory = ({ isFromProfile, internal = false, tx - }: TIssueCertFromCaDTO) => { + }: TIssueCertFromCaDTO): Promise => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; let collectionId = pkiCollectionId; @@ -1532,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 { @@ -1544,6 +1546,8 @@ export const internalCertificateAuthorityServiceFactory = ({ issuingCaCertificate, privateKey: skLeaf, serialNumber, + certificateId: cert.id, + commonName, ca: expandInternalCa(ca) }; }; @@ -1571,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` @@ -1587,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) { @@ -1636,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" }); } @@ -1905,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, @@ -1924,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 8f4e9c0d61..c13f85aa5f 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 @@ -160,6 +160,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } | ({ isInternal: false; @@ -179,6 +180,7 @@ export type TSignCertFromCaDTO = keyAlgorithm?: string; isFromProfile?: boolean; profileId?: string; + tx?: Knex; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -248,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-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index dc74e2ea41..1572746830 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -4,6 +4,10 @@ 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, @@ -22,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" }); } @@ -33,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" }); } @@ -57,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" }); } @@ -135,7 +168,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), - db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeConfigSkipDnsOwnershipVerification") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -165,7 +202,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeConfigId ? ({ id: result.acmeConfigId, - encryptedEabSecret: result.acmeConfigEncryptedEabSecret + encryptedEabSecret: result.acmeConfigEncryptedEabSecret, + skipDnsOwnershipVerification: result.acmeConfigSkipDnsOwnershipVerification ?? false } as TCertificateProfileWithConfigs["acmeConfig"]) : undefined; @@ -203,6 +241,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => { 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, @@ -244,6 +285,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { issuerType?: IssuerType; caId?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ): Promise => { try { @@ -276,7 +318,17 @@ export const certificateProfileDALFactory = (db: TDbClient) => { baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.issuerType`, issuerType); } - const query = baseQuery + 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`, @@ -294,6 +346,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") @@ -304,9 +361,21 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), - db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId"), + db + .ref("skipDnsOwnershipVerification") + .withSchema(TableName.PkiAcmeEnrollmentConfig) + .as("acmeSkipDnsOwnershipVerification") ); + if (processedRules) { + query = applyProcessedPermissionRulesToQuery( + query, + TableName.PkiCertificateProfile, + processedRules + ) as typeof query; + } + const results = (await query .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .offset(offset) @@ -333,7 +402,18 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const acmeConfig = result.acmeId ? { - id: result.acmeId as string + id: result.acmeId as string, + skipDnsOwnershipVerification: !!result.acmeSkipDnsOwnershipVerification + } + : 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; @@ -349,11 +429,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => { 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; @@ -371,6 +455,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { issuerType?: IssuerType; caId?: string; } = {}, + processedRules?: ProcessedPermissionRules, tx?: Knex ): Promise => { try { @@ -398,6 +483,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { 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 0000000000..2d54d0d4fe --- /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 e6b574dea5..3ac4def572 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -30,7 +30,11 @@ export const createCertificateProfileSchema = z renewBeforeDays: z.number().min(1).max(30).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional() }) .refine( (data) => { @@ -155,6 +159,11 @@ export const updateCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) + .optional(), + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) .optional() }) .refine( 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 122dde8cdf..d10188e6f8 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"; @@ -100,6 +100,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + externalConfigs: null, createdAt: new Date(), updatedAt: new Date() }; @@ -168,7 +169,8 @@ describe("CertificateProfileService", () => { const mockPermissionService = { getProjectPermission: vi.fn().mockResolvedValue({ permission: { - throwUnlessCan: vi.fn() + throwUnlessCan: vi.fn(), + rules: [] } }) } as unknown as Pick; @@ -229,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({ @@ -261,7 +256,7 @@ describe("CertificateProfileService", () => { certificateBodyDAL: mockCertificateBodyDAL, certificateSecretDAL: mockCertificateSecretDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL, - certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL, permissionService: mockPermissionService, licenseService: mockLicenseService, kmsService: mockKmsService, @@ -604,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 () => { @@ -624,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: [] } + ); }); }); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 0ce3ca9b63..4cb60a5229 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"; @@ -68,6 +70,55 @@ const validateIssuerTypeConstraints = ( } }; +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, @@ -180,7 +231,7 @@ type TCertificateProfileServiceFactoryDep = { certificateBodyDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; - certificateAuthorityCertDAL: Pick; + externalCertificateAuthorityDAL: Pick; permissionService: Pick; licenseService: Pick; kmsService: Pick; @@ -190,10 +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, - issuerType: dalResult.issuerType as IssuerType + issuerType: dalResult.issuerType as IssuerType, + externalConfigs: parsedExternalConfigs } as TCertificateProfile; }; @@ -205,6 +268,8 @@ export const certificateProfileServiceFactory = ({ acmeEnrollmentConfigDAL, certificateBodyDAL, certificateSecretDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, permissionService, licenseService, kmsService, @@ -235,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); @@ -272,6 +339,14 @@ 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({ @@ -328,7 +403,13 @@ export const certificateProfileServiceFactory = ({ apiConfigId = apiConfig.id; } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); - const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + const acmeConfig = await acmeEnrollmentConfigDAL.create( + { + skipDnsOwnershipVerification: data.acmeConfig.skipDnsOwnershipVerification ?? false, + encryptedEabSecret + }, + tx + ); acmeConfigId = acmeConfig.id; } @@ -340,7 +421,8 @@ export const certificateProfileServiceFactory = ({ projectId, estConfigId, apiConfigId, - acmeConfigId + acmeConfigId, + externalConfigs: data.externalConfigs }, tx ); @@ -381,7 +463,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Edit, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: existingProfile.slug + }) ); if (data.certificateTemplateId) { @@ -414,10 +498,20 @@ export const certificateProfileServiceFactory = ({ 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 { estConfig, apiConfig, acmeConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -459,6 +553,16 @@ export const certificateProfileServiceFactory = ({ ); } + if (acmeConfig && existingProfile.acmeConfigId) { + await acmeEnrollmentConfigDAL.updateById( + existingProfile.acmeConfigId, + { + skipDnsOwnershipVerification: acmeConfig.skipDnsOwnershipVerification ?? false + }, + tx + ); + } + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); return profileResult; }); @@ -494,7 +598,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const converted = convertDalToService(profile); @@ -530,7 +636,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); if (profile.estConfig && profile.estConfig.caChain) { @@ -558,9 +666,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 }; }; @@ -589,7 +712,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug + }) ); const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); @@ -641,21 +766,35 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); - const profiles = await certificateProfileDAL.findByProjectId(projectId, { - offset, - limit, - search, - enrollmentType, - issuerType, - caId - }); + const processedRules = getProcessedPermissionRules( + permission, + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); - const totalCount = await certificateProfileDAL.countByProjectId(projectId, { - search, - enrollmentType, - issuerType, - 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) => { @@ -740,7 +879,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Delete, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const deletedProfile = await certificateProfileDAL.deleteById(profileId); @@ -786,7 +927,9 @@ export const certificateProfileServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); const certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { @@ -827,17 +970,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); @@ -846,6 +981,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({ @@ -939,7 +1092,9 @@ export const certificateProfileServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); } @@ -990,7 +1145,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 85260b25db..5b1d62387f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -15,19 +15,28 @@ export enum IssuerType { SELF_SIGNED = "self-signed" } -export type TCertificateProfile = Omit & { +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; @@ -37,7 +46,9 @@ export type TCertificateProfileUpdate = Omit; + +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 0000000000..812d11e739 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.test.ts @@ -0,0 +1,637 @@ +/* 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 { certificateRequest, projectId } = 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(certificateRequest).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440006", + 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 + }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); + }); + + 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 { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); + + expect(certificateRequest).toEqual({ + status: CertificateRequestStatus.PENDING, + certificateId: null, + certificate: null, + privateKey: null, + serialNumber: null, + errorMessage: null, + createdAt: mockRequestWithoutCert.createdAt, + updatedAt: mockRequestWithoutCert.updatedAt + }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); + }); + + 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 { certificateRequest, projectId } = 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(certificateRequest).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440008", + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: null, + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); + }); + + 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 { certificateRequest, projectId } = 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(certificateRequest).toEqual({ + status: CertificateRequestStatus.ISSUED, + certificateId: "550e8400-e29b-41d4-a716-446655440009", + certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----", + privateKey: null, + serialNumber: "123456", + errorMessage: null, + createdAt: mockRequestWithCert.createdAt, + updatedAt: mockRequestWithCert.updatedAt + }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); + }); + + 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 { certificateRequest, projectId } = await service.getCertificateFromRequest(mockGetData); + + expect(certificateRequest).toEqual({ + status: CertificateRequestStatus.FAILED, + certificate: null, + certificateId: null, + privateKey: null, + serialNumber: null, + errorMessage: "Certificate issuance failed", + createdAt: mockFailedRequest.createdAt, + updatedAt: mockFailedRequest.updatedAt + }); + expect(projectId).toEqual("550e8400-e29b-41d4-a716-446655440003"); + }); + + 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 0000000000..78bde276b2 --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-service.ts @@ -0,0 +1,295 @@ +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 ({ + acmeOrderId, + 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, + acmeOrderId, + ...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, + certificateRequestId + }: TGetCertificateFromRequestDTO) => { + const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId); + if (!certificateRequest) { + throw new NotFoundError({ message: "Certificate request not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certificateRequest.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + // If no certificate is attached, return basic info + if (!certificateRequest.certificate) { + return { + certificateRequest: { + status: certificateRequest.status as CertificateRequestStatus, + certificate: null, + certificateId: null, + privateKey: null, + serialNumber: null, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }, + projectId: certificateRequest.projectId + }; + } + + // 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 { + certificateRequest: { + status: certificateRequest.status as CertificateRequestStatus, + certificate: certBody.certificate, + certificateId: certificateRequest.certificate.id, + privateKey, + serialNumber: certificateRequest.certificate.serialNumber, + errorMessage: certificateRequest.errorMessage || null, + createdAt: certificateRequest.createdAt, + updatedAt: certificateRequest.updatedAt + }, + projectId: certificateRequest.projectId + }; + }; + + 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 0000000000..9ccf6fbaef --- /dev/null +++ b/backend/src/services/certificate-request/certificate-request-types.ts @@ -0,0 +1,44 @@ +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; + acmeOrderId?: string; +}; + +export type TGetCertificateRequestDTO = TProjectPermission & { + certificateRequestId: string; +}; + +export type TGetCertificateFromRequestDTO = Omit & { + 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 3b935f26a4..4951986b19 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 f73d516a6e..3b8f7bc13c 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 656c12e64b..5d24e4b8b3 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 0000000000..58a3a5d201 --- /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 93291b0517..3cb1746a4a 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,6 +19,7 @@ import { CertExtendedKeyUsageType, CertIncludeType, CertKeyUsageType, + CertSubjectAlternativeNameType, CertSubjectAttributeType } from "@app/services/certificate-common/certificate-constants"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; @@ -42,7 +43,7 @@ describe("CertificateV3Service", () => { const mockCertificateDAL: Pick< TCertificateDALFactory, - "findOne" | "findById" | "updateById" | "transaction" | "create" + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" > = { findOne: vi.fn(), findById: vi.fn(), @@ -57,7 +58,8 @@ describe("CertificateV3Service", () => { transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const mockTx = {}; return callback(mockTx); - }) + }), + find: vi.fn().mockResolvedValue([]) }; const mockCertificateSecretDAL: Pick = { @@ -65,12 +67,24 @@ describe("CertificateV3Service", () => { create: vi.fn() }; - const mockCertificateAuthorityDAL: Pick = { - findByIdWithAssociatedCa: 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 mockCertificateProfileDAL: Pick = { - findByIdWithConfigs: vi.fn() + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn(), + findById: vi.fn() }; const mockCertificateTemplateV2Service: Pick< @@ -168,7 +182,11 @@ describe("CertificateV3Service", () => { 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"))) + 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" }), @@ -178,7 +196,13 @@ describe("CertificateV3Service", () => { const mockTx = {}; return callback(mockTx); }) - } as any + } as any, + certificateIssuanceQueue: { + queueCertificateIssuance: vi.fn().mockResolvedValue(undefined) + }, + certificateRequestService: { + createCertificateRequest: vi.fn().mockResolvedValue({ id: "cert-req-123" }) + } }); }); @@ -267,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", @@ -326,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, @@ -461,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", @@ -503,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, @@ -729,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, @@ -790,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], @@ -799,168 +863,6 @@ 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, - issuerType: IssuerType.CA, - 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 = { @@ -1097,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({ @@ -1143,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( @@ -1229,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({ @@ -1275,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( @@ -1361,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({ @@ -1407,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( @@ -1493,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({ @@ -1539,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( @@ -1672,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); @@ -1689,6 +1700,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); @@ -2006,6 +2019,8 @@ describe("CertificateV3Service", () => { issuingCaCertificate: "issuing-ca", privateKey: "private-key", serialNumber: "789012", + certificateId: "cert-456", + commonName: "test.example.com", ca: mockCA }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index b648b5478a..fd46f2c5fd 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,4 +1,4 @@ -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"; @@ -20,7 +20,6 @@ import { TCertificateDALFactory } from "@app/services/certificate/certificate-da import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { CertExtendedKeyUsage, - CertificateOrderStatus, CertKeyAlgorithm, CertKeyType, CertKeyUsage, @@ -49,7 +48,9 @@ import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns import { CertExtendedKeyUsageType, CertKeyUsageType, - CertSubjectAlternativeNameType + CertSubjectAlternativeNameType, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard } from "../certificate-common/certificate-constants"; import { extractAlgorithmsFromCSR, @@ -59,15 +60,16 @@ import { bufferToString, buildCertificateSubjectFromTemplate, buildSubjectAlternativeNamesFromTemplate, - convertExtendedKeyUsageArrayFromLegacy, convertExtendedKeyUsageArrayToLegacy, - convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, 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"; @@ -85,11 +87,17 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" | "find" + >; certificateBodyDAL: Pick; certificateSecretDAL: Pick; - certificateAuthorityDAL: Pick; - certificateProfileDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById" + >; + certificateProfileDAL: Pick; acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, @@ -103,8 +111,16 @@ type TCertificateV3ServiceFactoryDep = { >; pkiSyncDAL: Pick; pkiSyncQueue: Pick; - kmsService: 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; @@ -155,7 +171,9 @@ const validateProfileAndPermissions = async ( ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionCertificateProfileActions.IssueCert, - ProjectPermissionSub.CertificateProfiles + subject(ProjectPermissionSub.CertificateProfiles, { + slug: profile.slug + }) ); return profile; @@ -292,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 => { @@ -441,11 +512,7 @@ const generateSelfSignedCertificate = async ({ ...(certificateRequest.keyUsages?.length ? [ new x509.KeyUsagesExtension( - (convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce( - // eslint-disable-next-line no-bitwise - (acc: number, usage) => acc | x509.KeyUsageFlags[usage], - 0 - ), + combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []), false ) ] @@ -759,6 +826,37 @@ const processSelfSignedCertificate = async ({ }; }; +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, @@ -773,7 +871,9 @@ export const certificateV3ServiceFactory = ({ pkiSyncDAL, pkiSyncQueue, kmsService, - projectDAL + projectDAL, + certificateIssuanceQueue, + certificateRequestService }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -857,7 +957,7 @@ export const certificateV3ServiceFactory = ({ const result = await certificateDAL.transaction(async (tx) => { const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); - return processSelfSignedCertificate({ + const selfSignedResult = await processSelfSignedCertificate({ certificateRequest, template, profile, @@ -869,9 +969,31 @@ export const certificateV3ServiceFactory = ({ 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 } = result; + const { selfSignedResult, certificateData, certificateRequestId } = result; const subjectCommonName = (selfSignedResult.certificateSubject.common_name as string) || @@ -890,13 +1012,30 @@ export const certificateV3ServiceFactory = ({ }); } + 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: selfSignedResult.privateKey.toString("utf8"), + privateKey: privateKeyForResponse, serialNumber: selfSignedResult.serialNumber, certificateId: certificateData.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: subjectCommonName @@ -915,8 +1054,16 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "direct certificate issuance"); validateAlgorithmCompatibility(ca, template); - const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = - await internalCaService.issueCertFromCa({ + 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 || "", @@ -932,38 +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(cert.notAfter) - ); + 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(cert.id, updateData); + 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); } + // 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 + ); + + const privateKeyForResponse = canReadPrivateKey ? bufferToString(privateKey) : undefined; + return { certificate: bufferToString(certificate), issuingCaCertificate: bufferToString(issuingCaCertificate), certificateChain: finalCertificateChain, - privateKey: bufferToString(privateKey), + privateKey: privateKeyForResponse, serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -1047,33 +1237,64 @@ 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)); - - const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId }; - if (finalRenewBeforeDays !== undefined) { - updateData2.renewBeforeDays = finalRenewBeforeDays; - } - await certificateDAL.updateById(cert.id, updateData2); - const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); if (removeRootsFromChain) { @@ -1086,6 +1307,7 @@ export const certificateV3ServiceFactory = ({ certificateChain: certificateChainString, serialNumber, certificateId: cert.id, + certificateRequestId, projectId: profile.projectId, profileName: profile.slug, commonName: cert.commonName || "" @@ -1098,8 +1320,7 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId, - removeRootsFromChain + actorOrgId }: TOrderCertificateFromProfileDTO): Promise => { const profile = await validateProfileAndPermissions( profileId, @@ -1113,37 +1334,41 @@ export const certificateV3ServiceFactory = ({ EnrollmentType.API ); - const certificateRequest = { - commonName: certificateOrder.commonName, - keyUsages: certificateOrder.keyUsages, - extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames.map((san) => { - let certType: CertSubjectAlternativeNameType; - switch (san.type) { - case "dns": - certType = CertSubjectAlternativeNameType.DNS_NAME; - break; - case "ip": - certType = CertSubjectAlternativeNameType.IP_ADDRESS; - break; - default: - throw new BadRequestError({ - message: `Unsupported Subject Alternative Name type: ${san.type as string}` - }); - } - return { - type: certType, - 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 @@ -1169,42 +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, - removeRootsFromChain + 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/v1/cert-manager/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" }); }; @@ -1216,7 +1460,9 @@ export const certificateV3ServiceFactory = ({ actorOrgId, internal = false, removeRootsFromChain - }: TRenewCertificateDTO & { internal?: boolean }): Promise => { + }: Omit & { + internal?: boolean; + }): Promise => { const renewalResult = await certificateDAL.transaction(async (tx) => { const originalCert = await certificateDAL.findById(certificateId, tx); if (!originalCert) { @@ -1229,14 +1475,30 @@ 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." + }); + } } let profile = null; @@ -1273,10 +1535,12 @@ export const certificateV3ServiceFactory = ({ 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 issuerType = profile?.issuerType || (originalCert.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); @@ -1303,7 +1567,10 @@ export const certificateV3ServiceFactory = ({ }); } - validateCaSupport(ca, "direct certificate issuance"); + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType === CaType.INTERNAL) { + validateCaSupport(ca, "direct certificate issuance"); + } } const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; @@ -1329,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 @@ -1408,41 +1643,66 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); } - validateAlgorithmCompatibility(ca, { - algorithms: template?.algorithms - } as { algorithms?: { signature?: string[] } }); + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; - 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: parseKeyUsages(originalCert.keyUsages), - extendedKeyUsages: 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.findOne({ serialNumber, caId: ca.id }, tx); - if (!foundCert) { - throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + // 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` + }); } - newCert = foundCert; } else { // Self-signed certificate renewal const effectiveAlgorithms = getEffectiveAlgorithms( @@ -1511,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, @@ -1518,10 +1800,76 @@ 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, @@ -1538,6 +1886,7 @@ export const certificateV3ServiceFactory = ({ certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, + certificateRequestId, projectId: renewalResult.originalCert.projectId, profileName: renewalResult.profile?.slug || "Self-signed Certificate", commonName: renewalResult.originalCert.commonName || "" @@ -1568,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) { @@ -1671,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 ab638c5ed8..50d7104067 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, @@ -45,7 +44,7 @@ export type TOrderCertificateFromProfileDTO = { profileId: string; certificateOrder: { altNames: Array<{ - type: ACMESANType; + type: CertSubjectAlternativeNameType; value: string; }>; validity: { @@ -58,6 +57,8 @@ export type TOrderCertificateFromProfileDTO = { notAfter?: Date; signatureAlgorithm?: string; keyAlgorithm?: string; + template?: string; + csr?: string; }; removeRootsFromChain?: boolean; } & Omit; @@ -69,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; @@ -105,6 +86,7 @@ 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 7af79319b6..72cef90fad 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 44be47fc79..7ea6d03cd4 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"; @@ -108,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 { @@ -140,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({ @@ -174,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); @@ -234,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"); @@ -309,7 +327,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 + }) ); const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); @@ -397,7 +419,7 @@ export const certificateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, + ProjectPermissionCertificateActions.Import, ProjectPermissionSub.Certificates ); @@ -610,11 +632,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, + 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 }); @@ -726,7 +760,13 @@ 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) diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts index afa8f17ef5..758c08491d 100644 --- a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -1,61 +1,13 @@ -import { Knex } from "knex"; - import { TDbClient } from "@app/db"; import { TableName } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; import { ormify } from "@app/lib/knex"; -import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; - export type TAcmeEnrollmentConfigDALFactory = ReturnType; export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); - const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - throw new Error("Failed to create ACME enrollment config"); - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Create ACME enrollment config" }); - } - }; - - const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { - try { - const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); - const [acmeConfig] = result; - - if (!acmeConfig) { - return null; - } - - return acmeConfig; - } catch (error) { - throw new DatabaseError({ error, name: "Update ACME enrollment config" }); - } - }; - - const findById = async (id: string, tx?: Knex) => { - try { - const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); - - return acmeConfig || null; - } catch (error) { - throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); - } - }; - return { - ...acmeEnrollmentConfigOrm, - create, - updateById, - findById + ...acmeEnrollmentConfigOrm }; }; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 7fe5a475d0..2ea68faa7f 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -37,4 +37,6 @@ export interface TApiConfigData { renewBeforeDays?: number; } -export interface TAcmeConfigData {} +export interface TAcmeConfigData { + skipDnsOwnershipVerification?: boolean; +} 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 74b624a7e1..2a67866d3a 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,7 +18,9 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { .where(filter) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityAccessToken}.identityId`) .select(selectAllTableCols(TableName.IdentityAccessToken)) - .select(db.ref("orgId").withSchema(TableName.Identity).as("identityScopeOrgId")) + .select(db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId")) + .select(db.ref("subOrganizationId").withSchema(TableName.IdentityAccessToken).as("subOrganizationId")) + .select(db.ref("name").withSchema(TableName.Identity).as("identityName")) .first(); return doc; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 244e98908f..4aaff748cf 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -184,11 +184,7 @@ export const identityAccessTokenServiceFactory = ({ return { revokedToken }; }; - const fnValidateIdentityAccessToken = async ( - token: TIdentityAccessTokenJwtPayload, - ipAddress?: string, - subOrganizationSelector?: string - ) => { + const fnValidateIdentityAccessToken = async (token: TIdentityAccessTokenJwtPayload, ipAddress?: string) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, isAccessTokenRevoked: false @@ -209,46 +205,30 @@ export const identityAccessTokenServiceFactory = ({ trustedIps: trustedIps as TIp[] }); } - let orgId = ""; - let orgName = ""; - let parentOrgId = ""; - const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); - const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; - if (subOrganizationSelector) { - const subOrganization = await orgDAL.findOne({ rootOrgId, slug: subOrganizationSelector }); - if (!subOrganization) - throw new BadRequestError({ message: `Sub organization ${subOrganizationSelector} not found` }); + const scopeOrgId = identityAccessToken.subOrganizationId || identityAccessToken.identityOrgId; - const identityOrgMembership = await membershipIdentityDAL.findOne({ - scope: AccessScope.Organization, - actorIdentityId: identityAccessToken.identityId, - scopeOrgId: subOrganization.id - }); + const identityOrgDetails = await orgDAL.findOne({ id: scopeOrgId }); - if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to this organization" }); - } - orgId = subOrganization.id; - orgName = subOrganization.name; + const isSubOrg = Boolean(identityOrgDetails.rootOrgId); - parentOrgId = subOrganization.parentOrgId as string; - } else { - const identityOrgMembership = await membershipIdentityDAL.findOne({ - scope: AccessScope.Organization, - actorIdentityId: identityAccessToken.identityId, - scopeOrgId: identityOrgDetails.id - }); + const rootOrgId = isSubOrg ? identityOrgDetails.rootOrgId || identityOrgDetails.id : identityOrgDetails.id; - if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to this organization" }); - } + // Verify identity membership in the organization + const identityOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identityAccessToken.identityId, + scopeOrgId: identityOrgDetails.id + }); - orgId = identityOrgDetails.id; - orgName = identityOrgDetails.name; - parentOrgId = rootOrgId; + if (!identityOrgMembership) { + throw new BadRequestError({ message: "Identity does not belong to this organization" }); } + const orgId = identityOrgDetails.id; + const orgName = identityOrgDetails.name; + const parentOrgId = identityOrgDetails.parentOrgId || rootOrgId; + let { accessTokenNumUses } = identityAccessToken; const tokenStatusInCache = await accessTokenQueue.getIdentityTokenDetailsInCache(identityAccessToken.id); if (tokenStatusInCache) { diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index fba7fee983..79b6ef5252 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -53,7 +53,7 @@ type TIdentityAliCloudAuthServiceFactoryDep = { membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityAliCloudAuthServiceFactory = ReturnType; @@ -67,7 +67,7 @@ export const identityAliCloudAuthServiceFactory = ({ permissionService, orgDAL }: TIdentityAliCloudAuthServiceFactoryDep) => { - const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { + const login = async ({ identityId, subOrganizationName, ...params }: TLoginAliCloudAuthDTO) => { const appCfg = getConfig(); const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); if (!identityAliCloudAuth) { @@ -80,6 +80,10 @@ export const identityAliCloudAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; try { const requestUrl = new URL("https://sts.aliyuncs.com"); @@ -103,6 +107,30 @@ export const identityAliCloudAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( @@ -132,7 +160,8 @@ export const identityAliCloudAuthServiceFactory = ({ accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.ALICLOUD_AUTH + authMethod: IdentityAuthMethod.ALICLOUD_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts index 86133491e3..575341dd35 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-types.ts @@ -11,6 +11,7 @@ export type TLoginAliCloudAuthDTO = { SignatureVersion: string; SignatureNonce: string; Signature: string; + subOrganizationName?: string; }; export type TAttachAliCloudAuthDTO = { diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index b3b6bbfce3..b102362b37 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -53,7 +53,7 @@ type TIdentityAwsAuthServiceFactoryDep = { membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityAwsAuthServiceFactory = ReturnType; @@ -101,7 +101,13 @@ export const identityAwsAuthServiceFactory = ({ permissionService, orgDAL }: TIdentityAwsAuthServiceFactoryDep) => { - const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const login = async ({ + identityId, + iamHttpRequestMethod, + iamRequestBody, + iamRequestHeaders, + subOrganizationName + }: TLoginAwsAuthDTO) => { const appCfg = getConfig(); const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); if (!identityAwsAuth) { @@ -112,6 +118,11 @@ export const identityAwsAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + try { const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); const body: string = Buffer.from(iamRequestBody, "base64").toString(); @@ -179,6 +190,30 @@ export const identityAwsAuthServiceFactory = ({ } } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -207,7 +242,8 @@ export const identityAwsAuthServiceFactory = ({ accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AWS_AUTH + authMethod: IdentityAuthMethod.AWS_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts index 9844c8a63d..4570932f07 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-types.ts @@ -5,6 +5,7 @@ export type TLoginAwsAuthDTO = { iamHttpRequestMethod: string; iamRequestBody: string; iamRequestHeaders: string; + subOrganizationName?: string; }; export type TAttachAwsAuthDTO = { diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 3c05511d19..c205d76c86 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -49,7 +49,7 @@ type TIdentityAzureAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityAzureAuthServiceFactory = ReturnType; @@ -63,7 +63,7 @@ export const identityAzureAuthServiceFactory = ({ licenseService, orgDAL }: TIdentityAzureAuthServiceFactoryDep) => { - const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const login = async ({ identityId, jwt: azureJwt, subOrganizationName }: TLoginAzureAuthDTO) => { const appCfg = getConfig(); const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); if (!identityAzureAuth) { @@ -74,6 +74,10 @@ export const identityAzureAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; try { const azureIdentity = await validateAzureIdentity({ @@ -98,6 +102,30 @@ export const identityAzureAuthServiceFactory = ({ } } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -126,7 +154,8 @@ export const identityAzureAuthServiceFactory = ({ accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AZURE_AUTH + authMethod: IdentityAuthMethod.AZURE_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts index 485753b6fd..78d99dd555 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -3,6 +3,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TLoginAzureAuthDTO = { identityId: string; jwt: string; + subOrganizationName?: string; }; export type TAttachAzureAuthDTO = { diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 847abd81f3..471f5a5423 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -47,7 +47,7 @@ type TIdentityGcpAuthServiceFactoryDep = { identityAccessTokenDAL: Pick; permissionService: Pick; licenseService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityGcpAuthServiceFactory = ReturnType; @@ -61,7 +61,7 @@ export const identityGcpAuthServiceFactory = ({ licenseService, orgDAL }: TIdentityGcpAuthServiceFactoryDep) => { - const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const login = async ({ identityId, jwt: gcpJwt, subOrganizationName }: TLoginGcpAuthDTO) => { const appCfg = getConfig(); const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); if (!identityGcpAuth) { @@ -72,6 +72,11 @@ export const identityGcpAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + try { let gcpIdentityDetails: TGcpIdentityDetails; switch (identityGcpAuth.type) { @@ -138,6 +143,30 @@ export const identityGcpAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -166,7 +195,8 @@ export const identityGcpAuthServiceFactory = ({ accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.GCP_AUTH + authMethod: IdentityAuthMethod.GCP_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts index 063630c738..b26fa65396 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-types.ts @@ -3,6 +3,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TLoginGcpAuthDTO = { identityId: string; jwt: string; + subOrganizationName?: string; }; export type TAttachGcpAuthDTO = { diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 82935e4a48..2d68f10dda 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -60,7 +60,7 @@ type TIdentityJwtAuthServiceFactoryDep = { permissionService: Pick; licenseService: Pick; kmsService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityJwtAuthServiceFactory = ReturnType; @@ -75,7 +75,7 @@ export const identityJwtAuthServiceFactory = ({ kmsService, orgDAL }: TIdentityJwtAuthServiceFactoryDep) => { - const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const login = async ({ identityId, jwt: jwtValue, subOrganizationName }: TLoginJwtAuthDTO) => { const appCfg = getConfig(); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); if (!identityJwtAuth) { @@ -86,6 +86,11 @@ export const identityJwtAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + try { const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -218,6 +223,30 @@ export const identityJwtAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -246,7 +275,8 @@ export const identityJwtAuthServiceFactory = ({ accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.JWT_AUTH + authMethod: IdentityAuthMethod.JWT_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts index bc19aba830..54d9a928d4 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-types.ts @@ -49,4 +49,5 @@ export type TRevokeJwtAuthDTO = { export type TLoginJwtAuthDTO = { identityId: string; jwt: string; + subOrganizationName?: string; }; 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 212cb0894a..5d4021fef6 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 @@ -78,7 +78,7 @@ type TIdentityKubernetesAuthServiceFactoryDep = { gatewayV2Service: TGatewayV2ServiceFactory; gatewayDAL: Pick; gatewayV2DAL: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityKubernetesAuthServiceFactory = ReturnType; @@ -185,7 +185,7 @@ export const identityKubernetesAuthServiceFactory = ({ return callbackResult; }; - const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const login = async ({ identityId, jwt: serviceAccountJwt, subOrganizationName }: TLoginKubernetesAuthDTO) => { const appCfg = getConfig(); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { @@ -198,6 +198,10 @@ export const identityKubernetesAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; try { const { decryptor } = await kmsService.createCipherPairWithDataKey({ @@ -459,6 +463,30 @@ export const identityKubernetesAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -487,7 +515,8 @@ export const identityKubernetesAuthServiceFactory = ({ accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.KUBERNETES_AUTH + authMethod: IdentityAuthMethod.KUBERNETES_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index 269fa19e08..decb2957e4 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -3,6 +3,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TLoginKubernetesAuthDTO = { identityId: string; jwt: string; + subOrganizationName?: string; }; export enum IdentityKubernetesAuthTokenReviewMode { diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index 455b524125..92b754e5a7 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -70,7 +70,7 @@ type TIdentityLdapAuthServiceFactoryDep = { TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" >; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityLdapAuthServiceFactory = ReturnType; @@ -153,7 +153,7 @@ export const identityLdapAuthServiceFactory = ({ return { opts, ldapConfig }; }; - const login = async ({ identityId }: TLoginLdapAuthDTO) => { + const login = async ({ identityId, subOrganizationName }: TLoginLdapAuthDTO) => { const appCfg = getConfig(); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); @@ -167,6 +167,11 @@ export const identityLdapAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + const plan = await licenseService.getPlan(identity.orgId); if (!plan.ldap) { throw new BadRequestError({ @@ -174,6 +179,29 @@ export const identityLdapAuthServiceFactory = ({ "Failed to login to identity due to plan restriction. Upgrade plan to login to use LDAP authentication." }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } try { const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { @@ -204,7 +232,8 @@ export const identityLdapAuthServiceFactory = ({ accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.LDAP_AUTH + authMethod: IdentityAuthMethod.LDAP_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts index a4aea7573d..b438c937f6 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-types.ts @@ -59,6 +59,7 @@ export type TGetLdapAuthDTO = { export type TLoginLdapAuthDTO = { identityId: string; + subOrganizationName?: string; }; export type TRevokeLdapAuthDTO = { diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index 05e56c77d1..18f01e83e9 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -51,7 +51,7 @@ type TIdentityOciAuthServiceFactoryDep = { membershipIdentityDAL: Pick; licenseService: Pick; permissionService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityOciAuthServiceFactory = ReturnType; @@ -65,7 +65,7 @@ export const identityOciAuthServiceFactory = ({ permissionService, orgDAL }: TIdentityOciAuthServiceFactoryDep) => { - const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { + const login = async ({ identityId, headers, userOcid, subOrganizationName }: TLoginOciAuthDTO) => { const appCfg = getConfig(); const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); if (!identityOciAuth) { @@ -76,6 +76,11 @@ export const identityOciAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + try { // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { @@ -108,6 +113,30 @@ export const identityOciAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( @@ -137,7 +166,8 @@ export const identityOciAuthServiceFactory = ({ accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OCI_AUTH + authMethod: IdentityAuthMethod.OCI_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts index 8eb33a8661..0ac043ab83 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-types.ts @@ -9,6 +9,7 @@ export type TLoginOciAuthDTO = { "x-date"?: string; date?: string; }; + subOrganizationName?: string; }; export type TAttachOciAuthDTO = { 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 a5178f36d3..1e033d6624 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 @@ -61,7 +61,7 @@ type TIdentityOidcAuthServiceFactoryDep = { permissionService: Pick; licenseService: Pick; kmsService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityOidcAuthServiceFactory = ReturnType; @@ -76,7 +76,7 @@ export const identityOidcAuthServiceFactory = ({ kmsService, orgDAL }: TIdentityOidcAuthServiceFactoryDep) => { - const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { + const login = async ({ identityId, jwt: oidcJwt, subOrganizationName }: TLoginOidcAuthDTO) => { const appCfg = getConfig(); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); if (!identityOidcAuth) { @@ -87,6 +87,11 @@ export const identityOidcAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + try { const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -286,6 +291,30 @@ export const identityOidcAuthServiceFactory = ({ }); } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -314,7 +343,8 @@ export const identityOidcAuthServiceFactory = ({ accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OIDC_AUTH + authMethod: IdentityAuthMethod.OIDC_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts index fc5da3e273..5d06d778cd 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-types.ts @@ -38,6 +38,7 @@ export type TGetOidcAuthDTO = { export type TLoginOidcAuthDTO = { identityId: string; jwt: string; + subOrganizationName?: string; }; export type TRevokeOidcAuthDTO = { diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 630638a06b..bfa4db14d9 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -46,7 +46,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { licenseService: Pick; permissionService: Pick; kmsService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; const parseSubjectDetails = (data: string) => { @@ -68,7 +68,11 @@ export const identityTlsCertAuthServiceFactory = ({ kmsService, orgDAL }: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { - const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { + const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ + identityId, + clientCertificate, + subOrganizationName + }) => { const appCfg = getConfig(); const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); if (!identityTlsCertAuth) { @@ -81,6 +85,10 @@ export const identityTlsCertAuthServiceFactory = ({ if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; try { const { decryptor } = await kmsService.createCipherPairWithDataKey({ @@ -128,6 +136,30 @@ export const identityTlsCertAuthServiceFactory = ({ } } + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( @@ -157,7 +189,8 @@ export const identityTlsCertAuthServiceFactory = ({ accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, accessTokenNumUses: 0, accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.TLS_CERT_AUTH + authMethod: IdentityAuthMethod.TLS_CERT_AUTH, + subOrganizationId }, tx ); diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts index cf35bb5eed..77932042b6 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-types.ts @@ -4,6 +4,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TLoginTlsCertAuthDTO = { identityId: string; clientCertificate: string; + subOrganizationName?: string; }; export type TAttachTlsCertAuthDTO = { 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 bdc8ab1c1d..bda66e6e1e 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 @@ -59,7 +59,7 @@ type TIdentityTokenAuthServiceFactoryDep = { >; permissionService: Pick; licenseService: Pick; - orgDAL: Pick; + orgDAL: Pick; }; export type TIdentityTokenAuthServiceFactory = ReturnType; @@ -424,7 +424,8 @@ export const identityTokenAuthServiceFactory = ({ actorAuthMethod, actorOrgId, name, - isActorSuperAdmin + isActorSuperAdmin, + subOrganizationName }: TCreateTokenAuthTokenDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -503,6 +504,36 @@ export const identityTokenAuthServiceFactory = ({ const identity = await identityDAL.findById(identityTokenAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; + + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( identity.projectId @@ -529,7 +560,8 @@ export const identityTokenAuthServiceFactory = ({ accessTokenNumUses: 0, accessTokenNumUsesLimit: identityTokenAuth.accessTokenNumUsesLimit, name, - authMethod: IdentityAuthMethod.TOKEN_AUTH + authMethod: IdentityAuthMethod.TOKEN_AUTH, + subOrganizationId }, tx ); @@ -621,48 +653,61 @@ export const identityTokenAuthServiceFactory = ({ const getTokenAuthTokenById = async ({ tokenId, - identityId, - isActorSuperAdmin, actorId, actor, actorAuthMethod, actorOrgId }: TGetTokenAuthTokenByIdDTO) => { - await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); + 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 + identityId: foundToken.identityId }); - if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${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" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const token = await identityAccessTokenDAL.findOne({ - [`${TableName.IdentityAccessToken}.id` as "id"]: tokenId, - [`${TableName.IdentityAccessToken}.authMethod` as "authMethod"]: IdentityAuthMethod.TOKEN_AUTH, - [`${TableName.IdentityAccessToken}.identityId` as "identityId"]: identityId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - if (!token) throw new NotFoundError({ message: `Token with ID ${tokenId} not found` }); + 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, identityMembershipOrg }; + return { token: foundToken, identityMembershipOrg }; }; const updateTokenAuthToken = async ({ 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 fdecc6d4c9..14fc95fa95 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 @@ -30,6 +30,7 @@ export type TRevokeTokenAuthDTO = { export type TCreateTokenAuthTokenDTO = { identityId: string; name?: string; + subOrganizationName?: string; isActorSuperAdmin?: boolean; } & Omit; @@ -42,8 +43,6 @@ export type TGetTokenAuthTokensDTO = { export type TGetTokenAuthTokenByIdDTO = { tokenId: string; - identityId: string; - isActorSuperAdmin?: boolean; } & Omit; export type TUpdateTokenAuthTokenDTO = { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 5ea5c4a6ee..d47b930490 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -41,6 +41,7 @@ import { TGetUaClientSecretsDTO, TGetUaDTO, TGetUniversalAuthClientSecretByIdDTO, + TLoginUaDTO, TRevokeUaClientSecretDTO, TRevokeUaDTO, TUpdateUaDTO @@ -54,7 +55,7 @@ type TIdentityUaServiceFactoryDep = { membershipIdentityDAL: TMembershipIdentityDALFactory; permissionService: Pick; licenseService: Pick; - orgDAL: Pick; + orgDAL: Pick; keyStore: Pick< TKeyStoreFactory, "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" @@ -79,7 +80,7 @@ export const identityUaServiceFactory = ({ keyStore, identityDAL }: TIdentityUaServiceFactoryDep) => { - const login = async (clientId: string, clientSecret: string, ip: string) => { + const login = async ({ clientId, clientSecret, ip, subOrganizationName }: TLoginUaDTO) => { const appCfg = getConfig(); const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { @@ -90,6 +91,10 @@ export const identityUaServiceFactory = ({ const identity = await identityDAL.findById(identityUa.identityId); const org = await orgDAL.findById(identity.orgId); + const isSubOrgIdentity = Boolean(org.rootOrgId); + + // If the identity is a sub-org identity, then the scope is always the org.id, and if it's a root org identity, then we need to resolve the scope if a subOrganizationName is specified + let subOrganizationId = isSubOrgIdentity ? org.id : null; try { checkIPAgainstBlocklist({ @@ -229,6 +234,30 @@ export const identityUaServiceFactory = ({ accessTokenMaxTTL: 1000000000 }; + if (subOrganizationName) { + if (!isSubOrgIdentity) { + const subOrg = await orgDAL.findOne({ rootOrgId: org.id, slug: subOrganizationName }); + + if (!subOrg) { + throw new NotFoundError({ message: `Sub organization with name ${subOrganizationName} not found` }); + } + + const subOrgMembership = await membershipIdentityDAL.findOne({ + scope: AccessScope.Organization, + actorIdentityId: identity.id, + scopeOrgId: subOrg.id + }); + + if (!subOrgMembership) { + throw new UnauthorizedError({ + message: `Identity not authorized to access sub organization ${subOrganizationName}` + }); + } + + subOrganizationId = subOrg.id; + } + } + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); await membershipIdentityDAL.update( @@ -259,6 +288,7 @@ export const identityUaServiceFactory = ({ accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, accessTokenPeriod: identityUa.accessTokenPeriod, authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + subOrganizationId, ...accessTokenTTLParams }, tx diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index 8e7644b589..3ff8f5cc5a 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -1,5 +1,12 @@ import { TProjectPermission } from "@app/lib/types"; +export type TLoginUaDTO = { + clientId: string; + clientSecret: string; + ip: string; + subOrganizationName?: string; +}; + export type TAttachUaDTO = { identityId: string; accessTokenTTL: number; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 8f868978da..a63f0d41dd 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-user/membership-user-service.ts b/backend/src/services/membership-user/membership-user-service.ts index 4b14ee7713..59772a603d 100644 --- a/backend/src/services/membership-user/membership-user-service.ts +++ b/backend/src/services/membership-user/membership-user-service.ts @@ -188,16 +188,29 @@ export const membershipUserServiceFactory = ({ }); if (existingMemberships.length === users.length) return { memberships: [] }; + const orgDetails = await orgDAL.findById(dto.permission.orgId); + const isSubOrganization = Boolean(orgDetails.rootOrgId); const newMembershipUsers = users.filter((user) => !existingMemberships?.find((el) => el.actorUserId === user.id)); await factory.onCreateMembershipUserGuard(dto, newMembershipUsers); - const newMemberships = newMembershipUsers.map((user) => ({ - scope: scopeData.scope, - ...scopeDatabaseFields, - actorUserId: user.id, - status: scopeData.scope === AccessScope.Organization ? OrgMembershipStatus.Invited : undefined, - inviteEmail: scopeData.scope === AccessScope.Organization ? user.email : undefined - })); + const newMemberships = newMembershipUsers.map((user) => { + let status: OrgMembershipStatus | undefined; + if (scopeData.scope === AccessScope.Organization) { + if (isSubOrganization) { + status = OrgMembershipStatus.Accepted; + } else { + status = OrgMembershipStatus.Invited; + } + } + + return { + scope: scopeData.scope, + ...scopeDatabaseFields, + actorUserId: user.id, + status, + inviteEmail: status === OrgMembershipStatus.Invited ? user.email : undefined + }; + }); const customInputRoles = data.roles.filter((el) => factory.isCustomRole(el.role)); const hasCustomRole = customInputRoles.length > 0; 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 deb819c7a8..2668ed6650 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 @@ -1,6 +1,6 @@ import { ForbiddenError } from "@casl/ability"; -import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, OrganizationActionScope, OrgMembershipStatus } from "@app/db/schemas"; import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -122,16 +122,33 @@ export const newOrgMembershipUserFactory = ({ const signUpTokens: { email: string; link: string }[] = []; const orgDetails = await orgDAL.findById(dto.permission.orgId); if (orgDetails.rootOrgId) { - const emails = newUsers.map((el) => el.email).filter(Boolean); - await smtpService.sendMail({ - template: SmtpTemplates.SubOrgInvite, - subjectLine: "Infisical sub-organization invitation", - recipients: emails as string[], - substitutions: { - subOrganizationName: orgDetails.slug, - callback_url: `${appCfg.SITE_URL}/organizations/${dto.permission.orgId}/projects?subOrganization=${orgDetails.slug}` + // checking if the users have accepted the invitation in the root organization to send the email + const orgMembershipAccepted = await membershipUserDAL.find({ + scope: AccessScope.Organization, + scopeOrgId: orgDetails.rootOrgId, + status: OrgMembershipStatus.Accepted, + $in: { + actorUserId: newUsers.map((el) => el.id) } }); + + const orgMembershipAcceptedUserIds = orgMembershipAccepted.map((el) => el.actorUserId as string); + + const emails = newUsers + .filter((el) => Boolean(el?.email) && orgMembershipAcceptedUserIds.includes(el.id)) + .map((el) => el?.email as string); + + if (emails.length) { + await smtpService.sendMail({ + template: SmtpTemplates.SubOrgInvite, + subjectLine: "Infisical sub-organization invitation", + recipients: emails, + substitutions: { + subOrganizationName: orgDetails.slug, + callback_url: `${appCfg.SITE_URL}/organizations/${dto.permission.orgId}/projects` + } + }); + } } else { await Promise.allSettled( newUsers.map(async (el) => { diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index 84cf35a508..56045174a5 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -17,7 +17,8 @@ export enum NotificationType { PROJECT_INVITATION = "project-invitation", SECRET_SYNC_FAILED = "secret-sync-failed", GATEWAY_HEALTH_ALERT = "gateway-health-alert", - RELAY_HEALTH_ALERT = "relay-health-alert" + RELAY_HEALTH_ALERT = "relay-health-alert", + APPROVAL_REQUIRED = "approval-required" } export interface TCreateUserNotificationDTO { diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index be5c300b56..dbdd04586f 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -28,5 +28,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ shareSecretsProductEnabled: true, maxSharedSecretLifetime: true, maxSharedSecretViewLimit: true, - blockDuplicateSecretSyncDestinations: true + blockDuplicateSecretSyncDestinations: true, + rootOrgId: true, + parentOrgId: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index b0bbd9a2ab..abcdebf514 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -150,36 +150,47 @@ export const orgServiceFactory = ({ /* * Get organization details by the organization id * */ - const findOrganizationById = async ( - userId: string, - orgId: string, - actorAuthMethod: ActorAuthMethod, - rootOrgId: string, - actorOrgId: string - ) => { + const findOrganizationById = async ({ + userId, + orgId, + actorAuthMethod, + rootOrgId, + actorOrgId + }: { + userId: string; + orgId: string; + actorAuthMethod: ActorAuthMethod; + rootOrgId: string; + actorOrgId: string; + }) => { await permissionService.getOrgPermission({ actor: ActorType.USER, actorId: userId, orgId, actorAuthMethod, - actorOrgId: rootOrgId, + actorOrgId, scope: OrganizationActionScope.Any }); const appCfg = getConfig(); - const org = await orgDAL.findOrgById(orgId); - if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); + const hasSubOrg = rootOrgId !== actorOrgId; + + const org = await orgDAL.findOrgById(rootOrgId); + if (!org) throw new NotFoundError({ message: `Organization with ID '${rootOrgId}' not found` }); - const hasSubOrg = actorOrgId !== rootOrgId; let subOrg; if (hasSubOrg) { subOrg = await orgDAL.findOne({ rootOrgId, id: actorOrgId }); + + if (!subOrg) throw new NotFoundError({ message: `Sub-organization with ID '${actorOrgId}' not found` }); } - if (!org.userTokenExpiration) { - return { ...org, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME, subOrganization: subOrg }; + const data = hasSubOrg && subOrg ? subOrg : org; + if (!data.userTokenExpiration) { + return { ...data, userTokenExpiration: appCfg.JWT_REFRESH_LIFETIME }; } - return { ...org, subOrganization: subOrg }; + return data; }; + /* * Get all organization a user part of * */ diff --git a/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts b/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts new file mode 100644 index 0000000000..f14da58f0a --- /dev/null +++ b/backend/src/services/pam-session-expiration/pam-session-expiration-queue.ts @@ -0,0 +1,81 @@ +import { TPamSessionDALFactory } from "@app/ee/services/pam-session/pam-session-dal"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamSessionExpirationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamSessionDAL: Pick; +}; + +export type TPamSessionExpirationServiceFactory = ReturnType; + +export const pamSessionExpirationServiceFactory = ({ + queueService, + pamSessionDAL +}: TPamSessionExpirationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.startPg( + QueueJobs.PamSessionExpiration, + async (jobs) => { + await Promise.all( + jobs.map(async (job) => { + const { sessionId } = job.data; + try { + logger.info({ sessionId }, `${QueueName.PamSessionExpiration}: expiring session`); + const updated = await pamSessionDAL.expireSessionById(sessionId); + if (updated > 0) { + logger.info({ sessionId }, `${QueueName.PamSessionExpiration}: session expired successfully`); + } else { + logger.info( + { sessionId }, + `${QueueName.PamSessionExpiration}: session not expired (already ended or not found)` + ); + } + } catch (error) { + logger.error(error, `${QueueName.PamSessionExpiration}: failed to expire session ${sessionId}`); + throw error; + } + }) + ); + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 30 + } + ); + }; + + // Schedule a session expiration job to run at the session's expiresAt time + const scheduleSessionExpiration = async (sessionId: string, expiresAt: Date) => { + const now = new Date(); + const delayMs = Math.max(0, expiresAt.getTime() - now.getTime()); + const startAfter = new Date(now.getTime() + delayMs); + + await queueService.queuePg( + QueueJobs.PamSessionExpiration, + { sessionId }, + { + startAfter, + singletonKey: `pam-session-expiration-${sessionId}` + } + ); + + logger.info( + { sessionId, expiresAt: expiresAt.toISOString(), scheduledFor: startAfter.toISOString() }, + `${QueueName.PamSessionExpiration}: scheduled session expiration` + ); + }; + + return { + init, + scheduleSessionExpiration + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-dal.ts b/backend/src/services/pki-sync/pki-sync-dal.ts index 460bbfc42f..d04344b1e8 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-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts index 02a76db2af..f5e574213f 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/project/project-service.ts b/backend/src/services/project/project-service.ts index d30462e9b3..a48daefdc2 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, @@ -1981,7 +1986,7 @@ export const projectServiceFactory = ({ if (project.type === ProjectType.SecretManager) { projectTypeUrl = "secret-management"; } else if (project.type === ProjectType.CertificateManager) { - projectTypeUrl = "cert-management"; + projectTypeUrl = "cert-manager"; } const callbackPath = `/organizations/${project.orgId}/projects/${projectTypeUrl}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}`; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index 60310765bb..68d261193b 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -7,6 +7,7 @@ import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TUserNotificationDALFactory } from "@app/services/notification/user-notification-dal"; +import { TApprovalRequestDALFactory, TApprovalRequestGrantsDALFactory } from "../approval-policy/approval-request-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal"; import { TOrgServiceFactory } from "../org/org-service"; @@ -31,6 +32,8 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = { userNotificationDAL: Pick; keyValueStoreDAL: Pick; scimService: Pick; + approvalRequestDAL: Pick; + approvalRequestGrantsDAL: Pick; }; export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; @@ -49,7 +52,9 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ scimService, orgService, userNotificationDAL, - keyValueStoreDAL + keyValueStoreDAL, + approvalRequestDAL, + approvalRequestGrantsDAL }: TDailyResourceCleanUpQueueServiceFactoryDep) => { const appCfg = getConfig(); @@ -94,6 +99,8 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await auditLogDAL.pruneAuditLog(); await userNotificationDAL.pruneNotifications(); await keyValueStoreDAL.pruneExpiredKeys(); + await approvalRequestDAL.markExpiredRequests(); + await approvalRequestGrantsDAL.markExpiredGrants(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); } catch (error) { logger.error(error, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index d8220e4f54..0753f9640c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -416,6 +416,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { tagSlugs?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } ) => { try { @@ -481,6 +482,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { ); } + if (filters?.excludeRotatedSecrets) { + void query.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + const secrets = await query; // @ts-expect-error not inferred by knex @@ -594,6 +599,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { void bd.whereIn(`${TableName.SecretTag}.slug`, slugs); } }) + .where((bd) => { + if (filters?.excludeRotatedSecrets) { + void bd.whereNull(`${TableName.SecretRotationV2SecretMapping}.secretId`); + } + }) .orderBy( filters?.orderBy === SecretsOrderBy.Name ? "key" : "id", filters?.orderDirection ?? OrderByDirection.ASC 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 d42a26effc..d8d06bd462 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 @@ -483,8 +483,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); - if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) - throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); + if (sharedSecretToModify.isRotatedSecret && inputSecret.newSecretName) + throw new BadRequestError({ message: "Cannot update rotated secret name" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -888,6 +888,7 @@ export const secretV2BridgeServiceFactory = ({ | "tagSlugs" | "environment" | "search" + | "excludeRotatedSecrets" >) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -1934,8 +1935,14 @@ export const secretV2BridgeServiceFactory = ({ if (el.isRotatedSecret) { const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); - if (input && (input.newSecretName || input.secretValue)) - throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + if (input) { + if (input.newSecretName) { + delete input.newSecretName; + } + if (input.secretValue !== undefined) { + delete input.secretValue; + } + } } }); @@ -2061,8 +2068,11 @@ export const secretV2BridgeServiceFactory = ({ commitChanges, inputSecrets: secretsToUpdate.map((el) => { const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0]; + const shouldUpdateValue = !originalSecret.isRotatedSecret && typeof el.secretValue !== "undefined"; + const shouldUpdateName = !originalSecret.isRotatedSecret && el.newSecretName; + const encryptedValue = - typeof el.secretValue !== "undefined" + shouldUpdateValue && el.secretValue !== undefined ? { encryptedValue: secretManagerEncryptor({ plainText: Buffer.from(el.secretValue) }).cipherTextBlob, references: secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences @@ -2077,7 +2087,7 @@ export const secretV2BridgeServiceFactory = ({ (value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob ), skipMultilineEncoding: el.skipMultilineEncoding, - key: el.newSecretName || el.secretKey, + key: shouldUpdateName ? el.newSecretName : el.secretKey, tags: el.tagIds, secretMetadata: el.secretMetadata, ...encryptedValue diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 5e2ffc1a0f..f8613f57a3 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -50,6 +50,7 @@ export type TGetSecretsDTO = { limit?: number; search?: string; keys?: string[]; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretsMissingReadValuePermissionDTO = Omit< @@ -362,6 +363,7 @@ export type TFindSecretsByFolderIdsFilter = { includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; keys?: string[]; + excludeRotatedSecrets?: boolean; }; export type TGetSecretsRawByFolderMappingsDTO = { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 9ba1df47d7..27690249db 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1154,6 +1154,7 @@ export const secretServiceFactory = ({ | "search" | "includeTagsInSearch" | "includeMetadataInSearch" + | "excludeRotatedSecrets" >) => { const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index d8c778d7e3..be2c8b2149 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -214,6 +214,7 @@ export type TGetSecretsRawDTO = { keys?: string[]; includeTagsInSearch?: boolean; includeMetadataInSearch?: boolean; + excludeRotatedSecrets?: boolean; } & TProjectPermission; export type TGetSecretAccessListDTO = { diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx index f7a89f4e14..66df944393 100644 --- a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -61,9 +61,7 @@ export const PkiExpirationAlertTemplate = ({

- - View Certificate Alerts - + View Certificate Alerts
); diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 63bab2666b..e7fdf20b4b 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -640,7 +640,8 @@ export const superAdminServiceFactory = ({ accessTokenNumUses: 0, accessTokenNumUsesLimit: tokenAuth.accessTokenNumUsesLimit, name: "Instance Admin Token", - authMethod: IdentityAuthMethod.TOKEN_AUTH + authMethod: IdentityAuthMethod.TOKEN_AUTH, + subOrganizationId: organization.id }, tx ); diff --git a/backend/src/services/telemetry/telemetry-types.ts b/backend/src/services/telemetry/telemetry-types.ts index d2e977605b..560520f25f 100644 --- a/backend/src/services/telemetry/telemetry-types.ts +++ b/backend/src/services/telemetry/telemetry-types.ts @@ -1,4 +1,6 @@ import { + AcmeAccountActor, + AcmeProfileActor, IdentityActor, KmipClientActor, PlatformActor, @@ -60,6 +62,8 @@ export type TSecretModifiedEvent = { | ScimClientActor | PlatformActor | UnknownUserActor + | AcmeAccountActor + | AcmeProfileActor | KmipClientActor; }; }; diff --git a/docs/api-reference/endpoints/certificates/certificate-request.mdx b/docs/api-reference/endpoints/certificates/certificate-request.mdx new file mode 100644 index 0000000000..fcf601f5ed --- /dev/null +++ b/docs/api-reference/endpoints/certificates/certificate-request.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Request" +openapi: "GET /api/v1/cert-manager/certificates/certificate-requests/{requestId}" +--- diff --git a/docs/api-reference/endpoints/certificates/create-certificate.mdx b/docs/api-reference/endpoints/certificates/create-certificate.mdx new file mode 100644 index 0000000000..54b70c4641 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/create-certificate.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue Certificate" +openapi: "POST /api/v1/cert-manager/certificates" +--- 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 0000000000..69bc14cdca --- /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/docs.json b/docs/docs.json index fb84f2420f..00acabe07e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -130,6 +130,7 @@ "integrations/app-connections/laravel-forge", "integrations/app-connections/ldap", "integrations/app-connections/mssql", + "integrations/app-connections/mongodb", "integrations/app-connections/mysql", "integrations/app-connections/netlify", "integrations/app-connections/northflank", @@ -444,6 +445,7 @@ "documentation/platform/secret-rotation/aws-iam-user-secret", "documentation/platform/secret-rotation/azure-client-secret", "documentation/platform/secret-rotation/ldap-password", + "documentation/platform/secret-rotation/mongodb-credentials", "documentation/platform/secret-rotation/mssql-credentials", "documentation/platform/secret-rotation/mysql-credentials", "documentation/platform/secret-rotation/okta-client-secret", @@ -708,6 +710,7 @@ { "group": "Infrastructure Integrations", "pages": [ + "integrations/platforms/certificate-agent", "documentation/platform/pki/k8s-cert-manager", "documentation/platform/pki/integration-guides/gloo-mesh", "documentation/platform/pki/integration-guides/windows-server-acme", @@ -783,7 +786,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" ] } ] @@ -1385,6 +1404,18 @@ "api-reference/endpoints/app-connections/mssql/delete" ] }, + { + "group": "MongoDB", + "pages": [ + "api-reference/endpoints/app-connections/mongodb/list", + "api-reference/endpoints/app-connections/mongodb/available", + "api-reference/endpoints/app-connections/mongodb/get-by-id", + "api-reference/endpoints/app-connections/mongodb/get-by-name", + "api-reference/endpoints/app-connections/mongodb/create", + "api-reference/endpoints/app-connections/mongodb/update", + "api-reference/endpoints/app-connections/mongodb/delete" + ] + }, { "group": "MySQL", "pages": [ @@ -1921,6 +1952,19 @@ "api-reference/endpoints/secret-rotations/mssql-credentials/update" ] }, + { + "group": "MongoDB Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mongodb-credentials/create", + "api-reference/endpoints/secret-rotations/mongodb-credentials/delete", + "api-reference/endpoints/secret-rotations/mongodb-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mongodb-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mongodb-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mongodb-credentials/list", + "api-reference/endpoints/secret-rotations/mongodb-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mongodb-credentials/update" + ] + }, { "group": "MySQL Credentials", "pages": [ @@ -2485,8 +2529,8 @@ "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/certificate-request", + "api-reference/endpoints/certificates/create-certificate", "api-reference/endpoints/certificates/renew", "api-reference/endpoints/certificates/update-config", "api-reference/endpoints/certificates/revoke", @@ -3036,6 +3080,186 @@ { "source": "/documentation/platform/pki/est", "destination": "/documentation/platform/pki/enrollment-methods/est" + }, + { + "source": "/api-reference/endpoints/integrations/create-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/create", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete-auth-by-id", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/delete", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/find-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/list-auth", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/list-project-integrations", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/endpoints/integrations/update", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/api-reference/overview/examples/integration", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/documentation/platform/integrations", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/circleci", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/codefresh", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/octopus-deploy", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/rundeck", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cicd/travisci", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-parameter-store", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-secret-manager", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-app-configuration", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-devops", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/azure-key-vault", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/checkly", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloud-66", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloudflare-pages", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/cloudflare-workers", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/databricks", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/digital-ocean-app-platform", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/flyio", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/gcp-secret-manager", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/hashicorp-vault", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/hasura-cloud", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/heroku", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/laravel-forge", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/netlify", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/northflank", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/qovery", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/railway", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/render", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/supabase", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/terraform-cloud", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/vercel", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/windmill", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/overview", + "destination": "/integrations/secret-syncs" + }, + { + "source": "/integrations/cloud/aws-amplify", + "destination": "/integrations/cicd/aws-amplify" + }, + { + "source": "/integrations/cloud/teamcity", + "destination": "/integrations/secret-syncs/teamcity" } ] } diff --git a/docs/documentation/platform/pam/architecture.mdx b/docs/documentation/platform/pam/architecture.mdx new file mode 100644 index 0000000000..5f5a12433a --- /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 0000000000..4ed0616f8f --- /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 0000000000..4eaeebb745 --- /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 0000000000..8da9237128 --- /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 a6e0094f51..2b311c48c0 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 0000000000..e716b7f762 --- /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 0000000000..c3204ff565 --- /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 0000000000..954f2f9928 --- /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/resources/aws-iam.mdx b/docs/documentation/platform/pam/resources/aws-iam.mdx new file mode 100644 index 0000000000..662d76b5e4 --- /dev/null +++ b/docs/documentation/platform/pam/resources/aws-iam.mdx @@ -0,0 +1,258 @@ +--- +title: "AWS IAM" +sidebarTitle: "AWS IAM" +description: "Learn how to configure AWS Management Console access through Infisical PAM for secure, audited, and just-in-time access to AWS." +--- + +Infisical PAM supports secure, just-in-time access to the **AWS Management Console** through federated sign-in. This allows your team to access AWS without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Unlike database or SSH resources that require a Gateway for network connectivity, AWS Console access works differently. Infisical uses AWS STS (Security Token Service) to assume roles on your behalf and generates temporary federated sign-in URLs. + +```mermaid +sequenceDiagram + participant User + participant Infisical + participant Resource Role as Resource Role
(Your AWS Account) + participant Target Role as Target Role
(Your AWS Account) + participant Console as AWS Console + + User->>Infisical: Request AWS Console access + Infisical->>Resource Role: AssumeRole (with ExternalId) + Resource Role-->>Infisical: Temporary credentials + Infisical->>Target Role: AssumeRole (role chaining) + Target Role-->>Infisical: Session credentials + Infisical->>Console: Generate federation URL + Console-->>Infisical: Signed console URL + Infisical-->>User: Return console URL + User->>Console: Open AWS Console (federated) +``` + +### Key Concepts + +1. **Resource Role**: An IAM role in your AWS account that trusts Infisical. This is the "bridge" role that Infisical assumes first. + +2. **Target Role**: The IAM role that end users will actually use in the AWS Console. The Resource Role assumes this role on behalf of the user. + +3. **Role Chaining**: Infisical uses AWS role chaining - it first assumes the Resource Role, then uses those credentials to assume the Target Role. This provides an additional layer of security and audit capability. + +4. **External ID**: A unique identifier (your Infisical Project ID) used in the trust policy to prevent [confused deputy attacks](https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html). + +## Session Behavior + +### Session Duration + +The session duration is set when creating the account and applies to all access requests. You can specify the duration using human-readable formats like `15m`, `30m`, or `1h`. Due to AWS role chaining limitations: + +- **Minimum**: 15 minutes (`15m`) +- **Maximum**: 1 hour (`1h`) + +### Session Tracking + +Infisical tracks: +- When the session was created +- Who accessed which role +- When the session expires + + + **Important**: AWS Console sessions cannot be terminated early. Once a federated URL is generated, the session remains valid until the configured duration expires. However, you can [revoke active sessions](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html) by modifying the role's trust policy. + + +### CloudTrail Integration + +All actions performed in the AWS Console are logged in [AWS CloudTrail](https://console.aws.amazon.com/cloudtrail). The session is identified by the `RoleSessionName`, which includes the user's email address for attribution: + +``` +arn:aws:sts::123456789012:assumed-role/pam-readonly/user@example.com +``` + +This allows you to correlate Infisical PAM sessions with CloudTrail logs for complete audit visibility. + +## Prerequisites + +Before configuring AWS Console access in Infisical PAM, you need to set up two IAM roles in your AWS account: + +1. **Resource Role** - Trusted by Infisical, can assume target roles +2. **Target Role(s)** - The actual roles users will use in the console + + + **No Gateway Required**: Unlike database or SSH resources, AWS Console access does not require an Infisical Gateway. Infisical communicates directly with AWS APIs. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your AWS account. It contains the Resource Role that Infisical will assume. + + + + First, create an IAM policy that allows the Resource Role to assume your target roles. For simplicity, you can use a wildcard to allow assuming any role in your account: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam:::role/*" + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-policy.png) + + + **For more granular control**: If you want to restrict which roles the Resource Role can assume, replace the wildcard (`/*`) with a more specific pattern. For example: + - `arn:aws:iam:::role/pam-*` to only allow roles with the `pam-` prefix + - `arn:aws:iam:::role/infisical-*` to only allow roles with the `infisical-` prefix + + This allows you to limit the blast radius of the Resource Role's permissions. + + + + + Create an IAM role (e.g., `InfisicalResourceRole`) with: + - The permissions policy from the previous step attached + - The following trust policy: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::root" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-trust-policy.png) + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/resource-role-attach-policy.png) + + + **Security Best Practice**: Always use the External ID condition. This prevents confused deputy attacks where another Infisical customer could potentially trick Infisical into assuming your role. + + + **Infisical AWS Account IDs:** + | Region | Account ID | + |--------|------------| + | US | `381492033652` | + | EU | `345594589636` | + + + **For Dedicated Instances**: Your AWS account ID differs from the ones listed above. Please contact Infisical support to obtain your dedicated AWS account ID. + + + + **For Self-Hosted Instances**: Use the AWS account ID where your Infisical instance is deployed. This is the account that hosts your Infisical infrastructure and will be assuming the Resource Role. + + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **AWS IAM** + 3. Enter a name for the resource (e.g., `production-aws`) + 4. Enter the **Resource Role ARN** - the ARN of the role you created in the previous step + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/create-resource.png) + + Clicking **Create Resource** will validate that Infisical can assume the Resource Role. If the connection fails, verify: + - The trust policy has the correct Infisical AWS account ID + - The External ID matches your project ID + - The role ARN is correct + + + +## Create PAM Accounts + +A PAM Account represents a specific Target Role that users can request access to. You can create multiple accounts per resource, each pointing to a different target role with different permission levels. + + + + Each target role needs a trust policy that allows your Resource Role to assume it: + + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam:::role/InfisicalResourceRole" + }, + "Action": "sts:AssumeRole", + "Condition": { + "StringEquals": { + "sts:ExternalId": "" + } + } + }] + } + ``` + + ![Create AWS IAM Resource](/images/pam/resources/aws-iam/target-role-trust-policy.png) + + + + 1. Navigate to the **Accounts** tab in your PAM project + 2. Click **Add Account** and select the AWS IAM resource you created + 3. Fill in the account details: + + ![Create AWS IAM Account](/images/pam/resources/aws-iam/create-account.png) + + + A friendly name for this account (e.g., `readonly`, `admin`, `developer`) + + + + Optional description of what this account is used for + + + + The ARN of the IAM role users will assume (e.g., `arn:aws:iam::123456789012:role/pam-readonly`) + + + + Session duration using human-readable format (e.g., `15m`, `30m`, `1h`). Minimum 15 minutes, maximum 1 hour. + + + Due to AWS role chaining limitations, the maximum session duration is **1 hour**, regardless of the target role's configured maximum session duration. + + + + + +## Access the AWS Console + +Once your resource and accounts are configured, users can request access through Infisical: + +![Create AWS IAM Resource](/images/pam/resources/aws-iam/access-account.png) + + + + Go to the **Accounts** tab in your PAM project. + + + + Find the AWS Console account you want to access. + + + + Click the **Access** button. + + Infisical will: + 1. Assume the Resource Role using your project's External ID + 2. Assume the Target Role using role chaining + 3. Generate a federated sign-in URL + 4. Open the AWS Console in a new browser tab + + The user will be signed into the AWS Console with the permissions of the Target Role. + + \ No newline at end of file diff --git a/docs/documentation/platform/pam/resources/kubernetes.mdx b/docs/documentation/platform/pam/resources/kubernetes.mdx new file mode 100644 index 0000000000..a92ec51c7d --- /dev/null +++ b/docs/documentation/platform/pam/resources/kubernetes.mdx @@ -0,0 +1,224 @@ +--- +title: "Kubernetes" +sidebarTitle: "Kubernetes" +description: "Learn how to configure Kubernetes cluster access through Infisical PAM for secure, audited, and just-in-time access to your Kubernetes clusters." +--- + +Infisical PAM supports secure, just-in-time access to Kubernetes clusters through service account token authentication. This allows your team to access Kubernetes clusters without sharing long-lived credentials, while maintaining a complete audit trail of who accessed what and when. + +## How It Works + +Kubernetes access in Infisical PAM uses an Infisical Gateway to securely proxy connections to your Kubernetes API server. When a user requests access, Infisical generates a temporary kubeconfig that routes traffic through the Gateway, enabling secure access without exposing your cluster directly. + +```mermaid +sequenceDiagram + participant User + participant CLI as Infisical CLI + participant Infisical + participant Gateway as Infisical Gateway + participant K8s as Kubernetes API Server + + User->>CLI: Request Kubernetes access + CLI->>Infisical: Authenticate & request session + Infisical-->>CLI: Session credentials & Gateway info + CLI->>CLI: Start local proxy + CLI->>Gateway: Establish secure tunnel + User->>CLI: kubectl commands + CLI->>Gateway: Proxy kubectl requests + Gateway->>K8s: Forward with SA token + K8s-->>Gateway: Response + Gateway-->>CLI: Return response + CLI-->>User: kubectl output +``` + +### Key Concepts + +1. **Gateway**: An Infisical Gateway deployed in your network that can reach the Kubernetes API server. The Gateway handles secure communication between users and your cluster. + +2. **Service Account Token**: A Kubernetes service account token that grants access to the cluster. This token is stored securely in Infisical and used by the Gateway to authenticate with the Kubernetes API. + +3. **Local Proxy**: The Infisical CLI starts a local proxy on your machine that intercepts kubectl commands and routes them securely through the Gateway to your cluster. + +4. **Session Tracking**: All access sessions are logged, including when the session was created, who accessed the cluster, session duration, and when it ended. + +### Session Tracking + +Infisical tracks: +- When the session was created +- Who accessed which cluster +- Session duration +- All kubectl commands executed during the session +- When the session ended + + + **Session Logs**: After ending a session (by stopping the proxy), you can view detailed session logs in the Sessions page, including all commands executed during the session. + + +## Prerequisites + +Before configuring Kubernetes access in Infisical PAM, you need: + +1. **Infisical Gateway** - A Gateway deployed in your network with access to the Kubernetes API server +2. **Service Account** - A Kubernetes service account with appropriate RBAC permissions +3. **Infisical CLI** - The Infisical CLI installed on user machines + + + **Gateway Required**: Unlike AWS Console access, Kubernetes access requires an Infisical Gateway to be deployed and registered with your Infisical instance. The Gateway must have network connectivity to your Kubernetes API server. + + +## Create the PAM Resource + +The PAM Resource represents the connection between Infisical and your Kubernetes cluster. + + + + Before creating the resource, ensure you have an Infisical Gateway running and registered with your Infisical instance. The Gateway must have network access to your Kubernetes API server. + + + + 1. Navigate to your PAM project and go to the **Resources** tab + 2. Click **Add Resource** and select **Kubernetes** + 3. Enter a name for the resource (e.g., `production-k8s`, `staging-cluster`) + 4. Enter the **Kubernetes API Server URL** - the URL to your Kubernetes API endpoint (e.g.`https://kubernetes.example.com:6443`) + 5. Select the **Gateway** that has access to this cluster + 6. Configure SSL verification options if needed + + + **SSL Verification**: You may need to disable SSL verification if your Kubernetes API server uses a self-signed certificate or if the certificate's hostname doesn't match the URL you're using to access it. + + + + +## Create a Service Account + +Infisical PAM currently supports service account token authentication for Kubernetes. You'll need to create a service account with appropriate permissions in your cluster. + + + + Create a file named `sa.yaml` with the following content: + + ```yaml sa.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-pam-sa + namespace: kube-system + --- + # Bind the ServiceAccount to the desired ClusterRole + # This example uses cluster-admin - adjust based on your needs + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-pam-binding + subjects: + - kind: ServiceAccount + name: infisical-pam-sa + namespace: kube-system + roleRef: + kind: ClusterRole + name: cluster-admin # Change this to a more restrictive role as needed + apiGroup: rbac.authorization.k8s.io + --- + # Create a static, non-expiring token for the ServiceAccount + apiVersion: v1 + kind: Secret + metadata: + name: infisical-pam-sa-token + namespace: kube-system + annotations: + kubernetes.io/service-account.name: infisical-pam-sa + type: kubernetes.io/service-account-token + ``` + + + **Security Best Practice**: The example above uses `cluster-admin` for simplicity. In production environments, you should create custom ClusterRoles or Roles with the minimum permissions required for each use case. + + + + + Apply the configuration to your cluster: + + ```bash + kubectl apply -f sa.yaml + ``` + + This creates: + - A ServiceAccount named `infisical-pam-sa` in the `kube-system` namespace + - A ClusterRoleBinding that grants the service account its permissions + - A Secret containing a static, non-expiring token for the service account + + + + Get the service account token that you'll use when creating the PAM account: + + ```bash + kubectl -n kube-system get secret infisical-pam-sa-token -o jsonpath='{.data.token}' | base64 -d + ``` + + Copy this token - you'll need it in the next step. + + + +## Create PAM Accounts + +Once you have configured the PAM resource, you'll need to configure a PAM account for your Kubernetes resource. +A PAM Account represents a specific service account that users can request access to. You can create multiple accounts per resource, each with different permission levels. + + + + Go to the **Accounts** tab in your PAM project. + + + + Click **Add Account** and select the Kubernetes resource you created. + + + + Fill in the account details and paste the service account token you retrieved earlier. + + + +## Access Kubernetes Cluster + +Once your resource and accounts are configured, users can request access through the Infisical CLI: + + + + 1. Navigate to the **Accounts** tab in your PAM project + 2. Find the Kubernetes account you want to access + 3. Click the **Access** button + 4. Copy the provided CLI command + + + + + Run the copied command in your terminal. + + The CLI will: + 1. Authenticate with Infisical + 2. Establish a secure connection through the Gateway + 3. Start a local proxy on your machine + 4. Configure kubectl to use the proxy + + + + Once the proxy is running, you can use `kubectl` commands as normal: + + ```bash + kubectl get pods + kubectl get namespaces + kubectl describe deployment my-app + ``` + + All commands are routed securely through the Infisical Gateway to your cluster. + + + + When you're done, stop the proxy by pressing `Ctrl+C` in the terminal where it's running. This will: + - Close the secure tunnel + - End the session + - Log the session details to Infisical + + You can view session logs in the **Sessions** page of your PAM project. + + diff --git a/docs/documentation/platform/pam/session-recording.mdx b/docs/documentation/platform/pam/session-recording.mdx deleted file mode 100644 index e9061430c4..0000000000 --- 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/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx deleted file mode 100644 index da73de37df..0000000000 --- a/docs/documentation/platform/pki/certificates.mdx +++ /dev/null @@ -1,401 +0,0 @@ ---- -title: "Certificates" -sidebarTitle: "Certificates" -description: "Learn how to issue X.509 certificates with Infisical." ---- - -## Concept - -Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA. - -
- -```mermaid -graph TD - A[Root CA] - A --> B[Intermediate CA] - A --> C[Intermediate CA] - B --> D[Leaf Certificate] - C --> E[Leaf Certificate] -``` - -
- -## Workflow - -The typical workflow for managing certificates consists of the following steps: - -1. Issuing a certificate under an intermediate CA with details like name and validity period. As part of certificate issuance, you can either issue a certificate directly from a CA or do it via a certificate template. -2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, - you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed - data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. - - - Note that this workflow can be executed via the Infisical UI or manually such - as via API. - - -## Guide to Issuing Certificates - -In the following steps, we explore how to issue a X.509 certificate under a CA. - - - - - - - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection. - - With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like `.*.acme.com` or perhaps that the max TTL cannot be more than 1 year. - - Head to your Project > Certificate Authorities > Your Issuing CA and create a certificate template. - - ![pki certificate template modal](/images/platform/pki/certificate/cert-template-modal.png) - - Here's some guidance on each field: - - - Template Name: A name for the certificate template. - - Issuing CA: The Certificate Authority (CA) that will issue certificates based on this template. - - Certificate Collection (Optional): The certificate collection that certificates should be added to when issued under the template. - - Common Name (CN): A regular expression used to validate the common name in certificate requests. - - Alternative Names (SANs): A regular expression used to validate subject alternative names in certificate requests. - - TTL: The maximum Time-to-Live (TTL) for certificates issued using this template. - - Key Usage: The key usage constraint or default value for certificates issued using this template. - - Extended Key Usage: The extended key usage constraint or default value for certificates issued using this template. - - - To create a certificate, head to your Project > Internal PKI > Certificates and press **Issue** under the Certificates section. - - ![pki issue certificate](/images/platform/pki/certificate/cert-issue.png) - - Here, set the **Certificate Template** to the template from step 1 and fill out the rest of the details for the certificate to be issued. - - ![pki issue certificate modal](/images/platform/pki/certificate/cert-issue-modal.png) - - Here's some guidance on each field: - - - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. - - Common Name (CN): The common name for the certificate like `service.acme.com`. - - Alternative Names (SANs): A comma-delimited list of Subject Alternative Names (SANs) for the certificate; these can be hostnames or email addresses like `app1.acme.com, app2.acme.com`. - - TTL: The lifetime of the certificate in seconds. - - Key Usage: The key usage extension of the certificate. - - Extended Key Usage: The extended key usage extension of the certificate. - - - Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None** - and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same. - - That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. - - - - - Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. - - ![pki certificate body](/images/platform/pki/certificate/cert-body.png) - - - Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance. - The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time. - - - - - - - - - A certificate template is a set of policies for certificates issued under that template; each template is bound to a specific CA and can also be bound to a certificate collection for alerting such that any certificate issued under the template is automatically added to the collection. - - With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year. - - To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates-v2/create) API endpoint, specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --request POST \ - --url https://us.infisical.com/api/v2/certificate-templates \ - --header 'Content-Type: application/json' \ - --data '{ - "projectId": "", - "name": "", - "description": "", - "subject": [ - { - "type": "common_name", - "allowed": [ - "*.infisical.com" - ] - } - ], - "sans": [ - { - "type": "dns_name", - "allowed": [ - "*.sample.com" - ] - } - ], - "keyUsages": { - "allowed": [ - "digital_signature" - ] - }, - "extendedKeyUsages": { - "allowed": [ - "client_auth" - ] - }, - "algorithms": { - "signature": [ - "SHA256-RSA" - ], - "keyAlgorithm": [ - "RSA-2048" - ] - }, - "validity": { - "max": "365d" - } - }' - ``` - - ### Sample response - - ```bash Response - { - "certificateTemplate": { - "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "name": "", - "description": "", - "subject": [ - { - "type": "common_name", - "allowed": [ - "*.infisical.com" - ] - } - ], - "sans": [ - { - "type": "dns_name", - "allowed": [ - "*.sample.com" - ] - } - ], - "keyUsages": { - "allowed": [ - "digital_signature" - ] - }, - "extendedKeyUsages": { - "allowed": [ - "client_auth" - ] - }, - "algorithms": { - "signature": [ - "SHA256-RSA" - ], - "keyAlgorithm": [ - "RSA-2048" - ] - }, - "validity": { - "max": "365d" - }, - "createdAt": "2023-11-07T05:31:56Z", - "updatedAt": "2023-11-07T05:31:56Z" - } - } - ``` - - - - To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint, - specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "profileId": "", - "commonName": "service.acme.com", - "ttl": "1y", - "signatureAlgorithm": "RSA-SHA256", - "keyAlgorithm": "RSA_2048" - }' - ``` - - ### Sample response - - ```bash Response - { - certificate: "...", - certificateChain: "...", - issuingCaCertificate: "...", - privateKey: "...", - serialNumber: "..." - } - ``` - - - Note that Infisical PKI supports issuing certificates without certificate templates as well. If this is desired, then you can set the **Certificate Template** field to **None** - and specify the **Issuing CA** and optional **Certificate Collection** fields; the rest of the fields for the issued certificate remain the same. - - That said, we recommend using certificate templates to enforce policies and attach expiration monitoring on issued certificates. - - - - Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. - - - If you have an external private key, you can also create a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint, specifying the issuing CA. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "certificateTemplateId": "", - "csr": "...", - "ttl": "1y", - }' - ``` - - ### Sample response - - ```bash Response - { - certificate: "...", - certificateChain: "...", - issuingCaCertificate: "...", - privateKey: "...", - serialNumber: "..." - } - ``` - - - - - - -## Guide to Revoking Certificates - -In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA. - - - - - - Assuming that you've issued a certificate under a CA, you can revoke it by - selecting the **Revoke Certificate** option for it and specifying the reason - for revocation. - - ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) - - ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) - - - - In order to check the revocation status of a certificate, you can check it - against the CRL of a CA by heading to its Issuing CA and downloading the CRL. - - ![pki view crl](/images/platform/pki/ca/ca-crl.png) - - To verify a certificate against the - downloaded CRL with OpenSSL, you can use the following command: - -```bash -openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem -``` - -Note that you can also obtain the CRL from the certificate itself by -referencing the CRL distribution point extension on the certificate. - -To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: - -```bash -openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem -``` - - - - - - - - Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, - specifying the serial number of the certificate and the reason for revocation. - - ### Sample request - - ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates//revoke' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "revocationReason": "UNSPECIFIED" - }' - ``` - - ### Sample response - - ```bash Response - { - message: "Successfully revoked certificate", - serialNumber: "...", - revokedAt: "..." - } - ``` - - - In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. - - ### Sample request - - ```bash Request - curl --location --request GET 'https://app.infisical.com/api/v1/cert-manager/ca/internal//crls' \ - --header 'Authorization: Bearer ' - ``` - - ### Sample response - - ```bash Response - [ - { - id: "...", - crl: "..." - }, - ... - ] - ``` - - To verify a certificate against the CRL with OpenSSL, you can use the following command: - - ```bash - openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem - ``` - - - - - - -## FAQ - - - - To renew a certificate, you have to issue a new certificate from the same CA - with the same common name as the old certificate. The original certificate - will continue to be valid through its original TTL unless explicitly - revoked. - - diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index eab06fe435..1f2a5b7f2b 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -29,13 +29,13 @@ Refer to the documentation for each [enrollment method](/documentation/platform/ ## Guide to Renewing Certificates To [renew a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), you can either request a new certificate from a certificate profile or have the platform -automatically request a new one for you. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate +automatically request a new one for you to be delivered downstream to a target destination. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate profile as well as your infrastructure use-case. ### Client-Driven Certificate Renewal Client-driven certificate renewal is when renewal is initiated client-side by the end-entity consuming the certificate. -This is the most common approach to certificate renewal and is suitable for most use-cases. +More specifically, the client (e.g. [Infisical Agent](/integrations/platforms/certificate-agent), [ACME client](https://letsencrypt.org/docs/client-options/), etc.) monitors the certificate and makes a request for Infisical to issue a new certificate back to it when the existing certificate is nearing expiration. This is the most common approach to certificate renewal and is suitable for most use-cases. ### Server-Driven Certificate Renewal diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx index fad7264686..8567e48a74 100644 --- a/docs/documentation/platform/pki/enrollment-methods/acme.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -28,6 +28,17 @@ In the following steps, we explore how to issue a X.509 certificate using the AC ![pki acme config](/images/platform/pki/enrollment-methods/acme/acme-config.png) + + + By default, when the ACME client requests a certificate against the certificate profile for a particular domain, Infisical will verify domain ownership using the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method prior to issuing a certificate back to the client. + + If you want Infisical to skip domain ownership validation entirely, you can enable the **Skip DNS Ownership Validation** checkbox. + + Note that skipping domain ownership validation for the ACME enrollment method is **not the same** as skipping validation for an [External ACME CA integration](/documentation/platform/pki/ca/acme-ca). + + When using the ACME enrollment, the domain ownership check occurring between the ACME client and Infisical can be skipped. In contrast, External ACME CA integrations always require domain ownership validation, as Infisical must complete a DNS-01 challenge with the upstream ACME-compatible CA. + + Once you've created the certificate profile, you can obtain its ACME configuration details by clicking the **Reveal ACME EAB** option on the profile. diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx index bfbac7f2e1..a6a9d04dac 100644 --- a/docs/documentation/platform/pki/enrollment-methods/api.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -100,32 +100,34 @@ Here, select the certificate profile from step 1 that will be used to issue the - To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint. + To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ "profileId": "", - "commonName": "service.acme.com", - "ttl": "1y", - "signatureAlgorithm": "RSA-SHA256", - "keyAlgorithm": "RSA_2048", - "keyUsages": ["digital_signature", "key_encipherment"], - "extendedKeyUsages": ["server_auth"], - "altNames": [ - { - "type": "DNS", - "value": "service.acme.com" - }, - { - "type": "DNS", - "value": "www.service.acme.com" - } - ] + "attributes": { + "commonName": "service.acme.com", + "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048", + "keyUsages": ["digital_signature", "key_encipherment"], + "extendedKeyUsages": ["server_auth"], + "altNames": [ + { + "type": "DNS", + "value": "service.acme.com" + }, + { + "type": "DNS", + "value": "www.service.acme.com" + } + ] + } }' ``` @@ -133,31 +135,39 @@ Here, select the certificate profile from step 1 that will be used to issue the ```bash Response { - "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", - "serialNumber": "123456789012345678", - "certificateId": "880h3456-e29b-41d4-a716-446655440003" + "certificate": { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", + "serialNumber": "123456789012345678", + "certificateId": "880h3456-e29b-41d4-a716-446655440003" + }, + "certificateRequestId": "..." } ``` - Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + Note: If the certificate is available to be issued immediately, the `certificate` field in the response will contain the certificate data. If issuance is delayed (for example, due to pending approval or additional processing), the `certificate` field will be `null` and you can use the `certificateRequestId` to poll for status or retrieve the certificate when it is ready using the [Get Certificate Request](/api-reference/endpoints/certificates/certificate-request) API endpoint. + + Also be sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + - If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint. + If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the same [Issue Certificate](/api-reference/endpoints/certificates/create-certificate) API endpoint. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates/sign-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v1/cert-manager/certificates' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data-raw '{ "profileId": "", "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBE9oaW8...\n-----END CERTIFICATE REQUEST-----", - "ttl": "1y" + "attributes": { + "ttl": "1y" + } }' ``` @@ -165,11 +175,14 @@ Here, select the certificate profile from step 1 that will be used to issue the ```bash Response { - "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", - "serialNumber": "123456789012345679", - "certificateId": "990i4567-e29b-41d4-a716-446655440004" + "certificate": { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "serialNumber": "123456789012345679", + "certificateId": "990i4567-e29b-41d4-a716-446655440004" + }, + "certificateRequestId": "..." } ``` diff --git a/docs/documentation/platform/pki/k8s-cert-manager.mdx b/docs/documentation/platform/pki/k8s-cert-manager.mdx index b0f696ba96..2479a72c9c 100644 --- a/docs/documentation/platform/pki/k8s-cert-manager.mdx +++ b/docs/documentation/platform/pki/k8s-cert-manager.mdx @@ -139,7 +139,7 @@ The following steps show how to install cert-manager (using `kubectl`) and obtai ``` - - 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. + - Currently, the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) only supports the [HTTP-01 challenge](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) method. Support for the [DNS-01 challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) method is planned for a future release. If domain ownership validation is not desired, you can disable it by enabling the **Skip DNS ownership validation** option in your ACME certificate profile configuration. - 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/ diff --git a/docs/documentation/platform/secret-rotation/mongodb-credentials.mdx b/docs/documentation/platform/secret-rotation/mongodb-credentials.mdx new file mode 100644 index 0000000000..5b38081264 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/mongodb-credentials.mdx @@ -0,0 +1,177 @@ +--- +title: "MongoDB Credentials Rotation" +description: "Learn how to automatically rotate MongoDB credentials." +--- + +## Prerequisites + +1. Create a [MongoDB Connection](/integrations/app-connections/mongodb) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. + + An example creation statement might look like: + ```bash + // Switch to the target database + use my_database + + // Create first user + db.createUser({ + user: "infisical_user_1", + pwd: "temporary_password", + roles: [] + }) + + // Create second user + db.createUser({ + user: "infisical_user_2", + pwd: "temporary_password", + roles: [] + }) + + // Grant necessary permissions to both users + db.grantRolesToUser("infisical_user_1", [ + { role: "readWrite", db: "my_database" } + ]) + + db.grantRolesToUser("infisical_user_2", [ + { role: "readWrite", db: "my_database" } + ]) + ``` + + + To learn more about MongoDB's permission system, please visit their [documentation](https://www.mongodb.com/docs/manual/core/security-built-in-roles/). + + +3. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. + +## Create a MongoDB Credentials Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **MongoDB Credentials** option. + ![Select MongoDB Credentials](/images/secret-rotations-v2/mongodb-credentials/select-mongodb-credentials-option.png) + + 3. Select the **MongoDB Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-configuration.png) + + - **MongoDB Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-parameters.png) + + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-secrets-mapping.png) + + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-confirm.png) + + 8. Your **MongoDB Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-created.png) + + + To create a MongoDB Credentials Rotation, make an API request to the [Create MongoDB + Credentials Rotation](/api-reference/endpoints/secret-rotations/mongodb-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/mongodb-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mongodb-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "11c76f38-cd13-4137-b1a3-ecd6a429952c", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "MONGODB_DB_USERNAME", + "password": "MONGODB_DB_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mongodb-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "MONGODB_DB_USERNAME", + "password": "MONGODB_DB_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "b3257e1f-8d32-4e86-8bfd-b1f1bc1bf2c3", + "connectionId": "11c76f38-cd13-4137-b1a3-ecd6a429952c", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": null, + "nextRotationAt": "2023-11-07T05:31:56Z", + "isLastRotationManual": true, + "connection": { + "app": "mongodb", + "name": "my-mongodb-connection", + "id": "11c76f38-cd13-4137-b1a3-ecd6a429952c" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "170a40f1-1b48-4cc7-addf-e563aa9fbe37" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "b3257e1f-8d32-4e86-8bfd-b1f1bc1bf2c3", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "mongodb-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + + diff --git a/docs/images/app-connections/general/add-connection.png b/docs/images/app-connections/general/add-connection.png index b6dce69ac1..5ebadae098 100644 Binary files a/docs/images/app-connections/general/add-connection.png and b/docs/images/app-connections/general/add-connection.png differ diff --git a/docs/images/app-connections/mongodb/mongodb-app-connection-form.png b/docs/images/app-connections/mongodb/mongodb-app-connection-form.png new file mode 100644 index 0000000000..f57ca1aa54 Binary files /dev/null and b/docs/images/app-connections/mongodb/mongodb-app-connection-form.png differ diff --git a/docs/images/app-connections/mongodb/mongodb-app-connection-generated.png b/docs/images/app-connections/mongodb/mongodb-app-connection-generated.png new file mode 100644 index 0000000000..eed7d01af4 Binary files /dev/null and b/docs/images/app-connections/mongodb/mongodb-app-connection-generated.png differ diff --git a/docs/images/app-connections/mongodb/mongodb-app-connection-option.png b/docs/images/app-connections/mongodb/mongodb-app-connection-option.png new file mode 100644 index 0000000000..0999decb30 Binary files /dev/null and b/docs/images/app-connections/mongodb/mongodb-app-connection-option.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 0000000000..cc64aad4a1 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 0000000000..7ff6c459ed 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 0000000000..af79dc365a 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 0000000000..cba352726e 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 0000000000..0769b572c2 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 0000000000..8477d62971 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 0000000000..f44e5e6709 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 0000000000..8c9113b9be 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 0000000000..e14b2ab3a8 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 34f1c74346..0000000000 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 ac34b9dca8..0000000000 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 5e379eccc8..0000000000 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 3c908cd49c..0000000000 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 5c2fa41cf5..0000000000 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 0000000000..f8e9d8b3bc 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 0000000000..d4f31218d3 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 0000000000..2efd603126 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 0000000000..eaebbd70a2 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 0000000000..4be14838a9 Binary files /dev/null and b/docs/images/pam/product-reference/session-recording/sessions-page.png differ diff --git a/docs/images/pam/resources/aws-iam/access-account.png b/docs/images/pam/resources/aws-iam/access-account.png new file mode 100644 index 0000000000..6e6a57e1ef Binary files /dev/null and b/docs/images/pam/resources/aws-iam/access-account.png differ diff --git a/docs/images/pam/resources/aws-iam/create-account.png b/docs/images/pam/resources/aws-iam/create-account.png new file mode 100644 index 0000000000..4d2203667c Binary files /dev/null and b/docs/images/pam/resources/aws-iam/create-account.png differ diff --git a/docs/images/pam/resources/aws-iam/create-resource.png b/docs/images/pam/resources/aws-iam/create-resource.png new file mode 100644 index 0000000000..766b86ffc5 Binary files /dev/null and b/docs/images/pam/resources/aws-iam/create-resource.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png b/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png new file mode 100644 index 0000000000..39201c7cdd Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-attach-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-policy.png b/docs/images/pam/resources/aws-iam/resource-role-policy.png new file mode 100644 index 0000000000..1c52aaebba Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png b/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png new file mode 100644 index 0000000000..7c018f985e Binary files /dev/null and b/docs/images/pam/resources/aws-iam/resource-role-trust-policy.png differ diff --git a/docs/images/pam/resources/aws-iam/target-role-trust-policy.png b/docs/images/pam/resources/aws-iam/target-role-trust-policy.png new file mode 100644 index 0000000000..b91dd00308 Binary files /dev/null and b/docs/images/pam/resources/aws-iam/target-role-trust-policy.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 ce369f5156..0000000000 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 2926caf675..0000000000 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 a90cda5871..0000000000 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 8faab291db..0000000000 Binary files a/docs/images/pam/session-recording/sessions-page.png and /dev/null 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 index 11ea8b075b..0b5581fe0f 100644 Binary files a/docs/images/platform/pki/enrollment-methods/acme/acme-config.png and b/docs/images/platform/pki/enrollment-methods/acme/acme-config.png differ diff --git a/docs/images/secret-rotations-v2/generic/add-secret-rotation.png b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png index 86b84001b2..4b1b626ae2 100644 Binary files a/docs/images/secret-rotations-v2/generic/add-secret-rotation.png and b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-configuration.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-configuration.png new file mode 100644 index 0000000000..8d0db7d034 Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-confirm.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-confirm.png new file mode 100644 index 0000000000..d568e2d5d8 Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-created.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-created.png new file mode 100644 index 0000000000..e18ef7b0fa Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-details.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-details.png new file mode 100644 index 0000000000..949ff40bee Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-parameters.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-parameters.png new file mode 100644 index 0000000000..14723c0a6d Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-secrets-mapping.png new file mode 100644 index 0000000000..aceb38a8da Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/mongodb-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/mongodb-credentials/select-mongodb-credentials-option.png b/docs/images/secret-rotations-v2/mongodb-credentials/select-mongodb-credentials-option.png new file mode 100644 index 0000000000..4de9501145 Binary files /dev/null and b/docs/images/secret-rotations-v2/mongodb-credentials/select-mongodb-credentials-option.png differ diff --git a/docs/integrations/app-connections/mongodb.mdx b/docs/integrations/app-connections/mongodb.mdx new file mode 100644 index 0000000000..e677eaa4e9 --- /dev/null +++ b/docs/integrations/app-connections/mongodb.mdx @@ -0,0 +1,141 @@ +--- +title: "MongoDB Connection" +description: "Learn how to configure a MongoDB Connection for Infisical." +--- + +Infisical supports the use of Username & Password authentication to connect with MongoDB databases. + +## Configure a MongoDB user for Infisical + + + + Infisical recommends creating a designated user in your MongoDB database for your connection. + + ```bash + use [TARGET-DATABASE] + db.createUser({ + user: "infisical_manager", + pwd: "[ENTER-YOUR-USER-PASSWORD]", + roles: [] + }) + ``` + + + + Depending on how you intend to use your MongoDB connection, you'll need to grant one or more of the following permissions. + + + To learn more about MongoDB's permission system, please visit their [documentation](https://www.mongodb.com/docs/manual/core/security-built-in-roles/). + + + + + For Secret Rotations, your Infisical user will require the ability to create, update, and delete users in the target database: + + ```bash + use [TARGET-DATABASE] + db.grantRolesToUser("infisical_manager", [ + { role: "userAdmin", db: "[TARGET-DATABASE]" } + ]) + ``` + + + The `userAdmin` role allows managing users (create, update passwords, delete) within the specified database. + + + + + + + +## Create MongoDB Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **MongoDB Connection** option from the available integrations. + + ![Select MongoDB Connection](/images/app-connections/mongodb/mongodb-app-connection-option.png) + + + Complete the MongoDB Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The MongoDB host URL for your database + - The MongoDB port for your database + - The MongoDB username for your database + - The MongoDB password for your database + - The MongoDB database name to connect to + + You can optionally configure SSL/TLS for your MongoDB connection in the **SSL** section. + + ![MongoDB Connection Modal](/images/app-connections/mongodb/mongodb-app-connection-form.png) + + + After clicking Create, your **MongoDB Connection** is established and ready to use with your Infisical project. + + ![MongoDB Connection Created](/images/app-connections/mongodb/mongodb-app-connection-generated.png) + + + + + To create a MongoDB Connection, make an API request to the [Create MongoDB Connection](/api-reference/endpoints/app-connections/mongodb/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/mongodb \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mongodb-connection", + "method": "username-and-password", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "host": "[MONGODB HOST]", + "port": 27017, + "username": "[MONGODB USERNAME]", + "password": "[MONGODB PASSWORD]", + "database": "[MONGODB DATABASE]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-mongodb-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "mongodb", + "method": "username-and-password", + "credentials": { + "host": "[MONGODB HOST]", + "port": 27017, + "username": "[MONGODB USERNAME]", + "database": "[MONGODB DATABASE]", + "sslEnabled": false, + "sslRejectUnauthorized": false, + "sslCertificate": "" + } + } + } + ``` + + + diff --git a/docs/integrations/cicd/teamcity.mdx b/docs/integrations/cicd/teamcity.mdx deleted file mode 100644 index 0b58f1b807..0000000000 --- a/docs/integrations/cicd/teamcity.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "TeamCity" -description: "How to sync secrets from Infisical to TeamCity" ---- - - - The TeamCity Native Integration will be deprecated in 2026. Please migrate to our new [TeamCity Sync](../secret-syncs/teamcity). - diff --git a/docs/integrations/platforms/certificate-agent.mdx b/docs/integrations/platforms/certificate-agent.mdx new file mode 100644 index 0000000000..90bddeacb2 --- /dev/null +++ b/docs/integrations/platforms/certificate-agent.mdx @@ -0,0 +1,552 @@ +--- +title: "Infisical Agent" +sidebarTitle: "Infisical Agent" +description: "Learn how to use Infisical CLI Agent to manage certificates automatically." +--- + +## Concept + +The Infisical Agent is a client daemon that is packaged into the [Infisical CLI](/cli/overview). +It can be used to request a certificate from Infisical using the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles), persist it to a specified path on the filesystem, and automatically monitor and renew it before expiration. + +The Infisical Agent is notable: + +- Automating certificate management: The agent can request, persist, monitor, and renew certificates from Infisical automatically without manual intervention. It also supports post-event hooks to execute custom commands after certificate issuance, renewal, or failure events. +- Leveraging workload identity: The agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using an infrastructure-native authentication method such as [AWS Auth](/docs/documentation/platform/identities/aws-auth), [Azure Auth](/docs/documentation/platform/identities/azure-auth), [GCP Auth](/docs/documentation/platform/identities/gcp-auth), [Kubernetes Auth](/docs/documentation/platform/identities/kubernetes-auth), etc. + +The typical workflow for using the agent involves installing the Infisical CLI on the target machine, creating a configuration file defining the certificate to request and how it should be managed, and then starting the agent with that configuration so it can request, persist, monitor, and renew the certificate before it expires. +This follows a [client-driven approach](/documentation/platform/pki/certificates/certificates#client-driven-certificate-renewal) to certificate renewal. + +## Workflow + +A typical workflow for using the Infisical Agent to request certificates from Infisical consists of the following steps: + +1. Create a [certificate profile](/documentation/platform/pki/certificates/profiles) in Infisical with the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) configured on it. +2. Install the [Infisical CLI](/cli/overview) on the target machine. +3. Create an agent [configuration file](/integrations/platforms/certificate-agent#agent-configuration) containing details about the certificate to request and how it should be managed such as renewal thresholds, post-event hooks, etc. +4. Start the agent with that configuration so it can request, persist, monitor, and going forward automatically renew the certificate before it expires on the target machine. + +## Operating the Agent + +This section describes how to use the Infisical Agent to request certificates from Infisical. It covers how the agent authenticates with Infisical, +and how to configure it to start requesting certificates from Infisical. + +### Authentication + +The Infisical Agent can authenticate with Infisical as a [machine identity](/documentation/platform/identities/machine-identities) using one of its supported authentication methods. + +Upon successful authentication, the agent receives a short-lived access token that it uses to make subsequent authenticated requests to obtain and renew certificates from Infisical; +the agent automatically handles token renewal as documented [here](/integrations/platforms/infisical-agent#token-renewal). + + + + The Universal Auth method uses a client ID and secret for authentication. + + + + To create a universal auth machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/universal-auth). + + + Update the agent configuration file with the auth method and credentials: + + ```yaml + auth: + type: "universal-auth" + config: + client-id: "./client-id" # Path to file containing client ID + client-secret: "./client-secret" # Path to file containing client secret + remove-client-secret-on-read: false # Optional: remove secret file after reading + ``` + + You can also provide credentials directly: + + ```yaml + auth: + type: "universal-auth" + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + ``` + + + + + + The Kubernetes Auth method is used when running the agent in a Kubernetes environment. + + + + To create a Kubernetes machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/kubernetes-auth). + + + Configure the agent to use Kubernetes service account authentication: + + ```yaml + auth: + type: "kubernetes-auth" + config: + identity-id: "your-kubernetes-identity-id" + service-account-token-path: "/var/run/secrets/kubernetes.io/serviceaccount/token" + ``` + + + + + + The Azure Auth method is used when running the agent in an Azure environment. + + + + To create an Azure machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/azure-auth). + + + Configure the agent to use Azure managed identity authentication: + + ```yaml + auth: + type: "azure-auth" + config: + identity-id: "your-azure-identity-id" + ``` + + + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method and identity ID: + + ```yaml + auth: + type: "gcp-id-token" + config: + identity-id: "your-gcp-identity-id" + ``` + + + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method, identity ID, and service account key: + + ```yaml + auth: + type: "gcp-iam" + config: + identity-id: "your-gcp-identity-id" + service-account-key: "/path/to/service-account-key.json" + ``` + + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment. + + + + To create an AWS machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/aws-auth). + + + Update the agent configuration file with the specified auth method and identity ID: + + ```yaml + auth: + type: "aws-iam" + config: + identity-id: "your-aws-identity-id" + ``` + + + + + + +### Agent Configuration + +The Infisical Agent relies on a YAML configuration file to define its behavior, including how it should authenticate with Infisical, the certificate it should request, and how that certificate should be managed including auto-renewal. + +The code snippet below shows an example configuration file that instructs the agent to request and continuously renew a certificate from Infisical. + +Note that not all configuration options in this file are required but this example includes all of the available options. + +```yaml example-cert-agent-config.yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: ["api.example.com", "api-v2.example.com"] + ttl: "90d" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + + # Enable automatic certificate renewal + lifecycle: + renew-before-expiry: "30d" + status-check-interval: "6h" + + # Configure where to store the issued certificate and its associated private key and certificate chain + file-output: + private-key: + path: "/etc/ssl/private/web.key" + permission: "0600" # Read/write for owner only + certificate: + path: "/etc/ssl/certs/web.crt" + permission: "0644" # Read for all, write for owner + chain: + path: "/etc/ssl/certs/web-chain.crt" + permission: "0644" # Read for all, write for owner + omit-root: true # Exclude the root CA certificate in chain + + # Configure custom commands to execute after certificate issuance, renewal, or failure events + post-hooks: + on-issuance: + command: | + echo "Certificate issued for ${CERT_COMMON_NAME}" + systemctl reload nginx + timeout: 30 + + on-renewal: + command: | + echo "Certificate renewed for ${CERT_COMMON_NAME}" + systemctl reload nginx + timeout: 30 + + on-failure: + command: | + echo "Certificate operation failed: ${ERROR_MESSAGE}" + mail -s "Certificate Alert" admin@company.com < /dev/null + timeout: 30 +``` + +To be more specific, the configuration file instructs the agent to: + +- Authenticate with Infisical using the [Universal Auth](/integrations/platforms/certificate-agent#universal-auth) authentication method. +- Request a 90-day certificate against the [certificate profile](/documentation/platform/pki/certificates/profiles) named `prof-web-server-12345` with the common name `web.company.com` and the subject alternative names `web.company.com` and `www.company.com`. +- Automatically renew the certificate 30 days before expiration by checking the certificate status every 6 hours and retrying up to 3 times with a base delay of 200ms and a maximum delay of 5s if the certificate status check fails. +- Store the certificate and its associated private key and certificate chain (excluding the root CA certificate) in the filesystem at the specified paths with the specified permissions. +- Execute custom commands after certificate issuance, renewal, or failure events such as reloading an `nginx` service or sending an email notification. + +### Agent Execution + +After creating the configuration file, you can run the command below with the `--config` flag pointing to the path where the agent configuration file is located. + +```bash +infisical cert-manager agent --config /path/to/your/agent-config.yaml +``` + +This will start the agent as a daemon process, continuously monitoring and managing certificates according to your configuration. You can also run it in the foreground for debugging: + +```bash +infisical cert-manager agent --config /path/to/your/agent-config.yaml --verbose +``` + +For production deployments, you may consider running the agent as a system service to ensure it starts automatically and runs continuously. + +### Agent Certificate Configuration Parameters + +The table below provides a complete list of parameters that can be configured in the **certificate configuration** section of the agent configuration file: + +| Parameter | Required | Description | +| ------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `profile-name` | Yes | The name of the [certificate profile](/documentation/platform/pki/certificates/profiles) to request a certificate against (e.g., `web-server-12345`) | +| `project-slug` | Yes | The slug of the project to request a certificate against (e.g., `my-project-slug`) | +| `common-name` | Optional | The common name for the certificate (e.g. `www.example.com`) | +| `alt-names` | Optional | The list of subject alternative names for the certificate (e.g., `["www.example.com", "api.example.com"]`) | +| `ttl` | Optional (uses profile default if not specified) | The time-to-live duration for the certificate, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) | +| `key-algorithm` | Optional | The algorithm for the certificate key pair. One of: `RSA_2048`, `RSA_3072`, `RSA_4096`, `EC_prime256v1`, `EC_secp384r1`, `EC_secp521r1`. | +| `signature-algorithm` | Optional | The algorithm used to sign the certificate. One of: `RSA-SHA256`, `RSA-SHA384`, `RSA-SHA512`, `ECDSA-SHA256`, `ECDSA-SHA384`, `ECDSA-SHA512`. | +| `key-usages` | Optional | The list of key usage values for the certificate. One or more of: `digital_signature`, `key_encipherment`, `non_repudiation`, `data_encipherment`, `key_agreement`, `key_cert_sign`, `crl_sign`, `encipher_only`, `decipher_only`. | +| `extended-key-usages` | Optional | The list of extended key usage values for the certificate. One or more of: `server_auth`, `client_auth`, `code_signing`, `email_protection`, `timestamping`, `ocsp_signing`. | +| `csr-path` | Conditional | The path to a certificate signing request (CSR) file (e.g., `./csr/webserver.csr`, `/etc/ssl/csr.pem`). This is required if using a pre-generated CSR. | +| `file-output.private-key.path` | Optional (required if the `csr-path` is not specified) | The path to store the private key (required if not using a CSR) | +| `file-output.private-key.permission` | Optional (defaults to `0600`) | The octal file permissions for the private key file (e.g. `0600`) | +| `file-output.certificate.path` | Yes | The path to store the issued certificate in the filesystem | +| `file-output.certificate.permission` | Optional (defaults to `0600`) | The octal file permissions for the certificate file (e.g. `0644`) | +| `file-output.chain.path` | Optional | The path to store the certificate chain in the filesystem. | +| `file-output.chain.permission` | Optional (defaults to `0600`) | The octal permissions for the chain file (e.g. `0644`) | +| `file-output.chain.omit-root` | Optional (defaults to `true`) | Whether to exclude the root CA certificate from the returned certificate chain | +| `lifecycle.renew-before-expiry` | Optional (auto-renewal is disabled if not set) | Duration before certificate expiration when renewal checks should begin, specified as a duration string (e.g. `72h`, `90d`, `1y`, etc.) | +| `lifecycle.status-check-interval` | Optional (defaults to `10s`) | How frequently the agent checks certificate status and renewal needs, specified as a duration string (e.g. `10s`, `30m`, `1d`, etc.) | +| `post-hooks.on-issuance.command` | Optional | The shell command to execute after a certificate is successfully issued for the first time (e.g., `systemctl reload nginx`, `/usr/local/bin/reload-service.sh`) | +| `post-hooks.on-issuance.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-issuance post-hook command before it is terminated (e.g., `30`, `60`, `120`) | +| `post-hooks.on-renewal.command` | Optional | The shell command to execute after a certificate is successfully renewed (e.g., `systemctl reload nginx`, `docker restart web-server`) | +| `post-hooks.on-renewal.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-renewal post-hook command before it is terminated (e.g., `30`, `60`, `120`) | +| `post-hooks.on-failure.command` | Optional | The shell command to execute when certificate issuance or renewal fails (e.g., `logger 'Certificate renewal failed'`, `/usr/local/bin/alert.sh`) | +| `post-hooks.on-failure.timeout` | Optional (defaults to `30`) | Maximum execution time in seconds for the on-failure post-hook command before it is terminated (e.g., `10`, `30`, `60`) | + +### Post-Event Hooks + +The Infisical Agent supports running custom commands in response to certificate lifecycle events such as issuance, renewal, and failure through the `post-hooks` configuration +in the agent configuration file. + + + + Runs when a new certificate is successfully issued: + + ```yaml + post-hooks: + on-issuance: + command: | + echo "New certificate issued for ${CERT_COMMON_NAME}" + chown nginx:nginx ${CERT_FILE_PATH} + chmod 644 ${CERT_FILE_PATH} + systemctl reload nginx + timeout: 30 + ``` + + + + + Runs when a certificate is successfully renewed: + + ```yaml + post-hooks: + on-renewal: + command: | + echo "Certificate renewed for ${CERT_COMMON_NAME}" + # Reload services that use the certificate + systemctl reload nginx + systemctl reload haproxy + + # Send notification + curl -X POST https://hooks.slack.com/... \ + -d "{'text': 'Certificate for ${CERT_COMMON_NAME} renewed successfully'}" + timeout: 60 + ``` + + + + + Runs when certificate operations fail: + + ```yaml + post-hooks: + on-failure: + command: | + echo "Certificate operation failed for ${CERT_COMMON_NAME}: ${ERROR_MESSAGE}" + # Send alert + mail -s "Certificate Failure Alert" admin@company.com < /dev/null + # Log to syslog + logger -p daemon.error "Certificate agent failure: ${ERROR_MESSAGE}" + timeout: 30 + ``` + + + + +### Retrying mechanism + +The Infisical Agent will automatically attempt to retry any failed API requests including authentication, certificate issuance, and renewal operations. +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: + +```yaml +infisical: + address: "https://app.infisical.com" + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" +# ... rest of the agent configuration file +``` + +## Example Agent Configuration Files + +Since there are several ways you might want to use the Infisical Agent to request certificates from Infisical, +we provide a few example configuration files for common use cases below to help you get started. + +### One-Time Certificate Issuance + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical +once without performing any subsequent auto-renewal. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: + - "api.example.com" + - "api-v2.example.com" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + ttl: "30d" + file-output: + private-key: + path: "/etc/ssl/private/api.example.com.key" + permission: "0600" + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + omit-root: true +``` + +### One-Time Certificate Issuance using a Pre-Generated CSR + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical +once using a pre-generated CSR. + +Note that when `csr-path` is specified: + +- The `private-key` is omitted from the configuration file because we assume that it is pre-generated and managed externally, with only the CSR being submitted to Infisical for signing. +- The agent will not be able to perform any auto-renewal operations, as it is assumed to not have access to the private key required to generate a new CSR. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + csr-path: "/etc/ssl/requests/api.csr" + file-output: + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + omit-root: true +``` + +### Certificate Issuance with Automatic Renewal + +The code snippet below shows a configuration file that instructs the agent to request a certificate from Infisical and continuously renew it 14 days before expiration, checking the certificate status every 6 hours. + +```yaml +version: v1 + +# Infisical server configuration +infisical: + address: "https://app.infisical.com" # The URL of the Infisical instance (e.g. https://app.infisical.com, https://eu.infisical.com, https://your-self-hosted-instance.com) + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# Infisical authentication configuration +auth: + type: "universal-auth" # The authentication method to use (e.g. universal-auth, kubernetes-auth, azure-auth, gcp-id-token, gcp-iam, aws-iam) + config: + client-id: "your-client-id" + client-secret: "your-client-secret" + +# Certificate configuration +certificates: + - profile-name: "prof-web-server-12345" + project-slug: "my-project-slug" + attributes: + common-name: "api.example.com" + alt-names: + - "api.example.com" + - "api-v2.example.com" + key-algorithm: "RSA_2048" + signature-algorithm: "RSA-SHA256" + key-usages: + - "digital_signature" + - "key_encipherment" + extended-key-usages: + - "server_auth" + ttl: "30d" + lifecycle: + renew-before-expiry: "14d" # Renew 14 days before expiration + status-check-interval: "6h" # Check certificate status every 6 hours + file-output: + private-key: + path: "/etc/ssl/private/api.example.com.key" + permission: "0600" + certificate: + path: "/etc/ssl/certs/api.example.com.crt" + permission: "0644" + chain: + path: "/etc/ssl/certs/api.example.com.chain.crt" + permission: "0644" + post-hooks: + on-issuance: + command: "systemctl reload nginx" + timeout: 30 + on-renewal: + command: "systemctl reload nginx && logger 'Certificate renewed'" + timeout: 30 +``` diff --git a/docs/self-hosting/guides/cdn-caching.mdx b/docs/self-hosting/guides/cdn-caching.mdx new file mode 100644 index 0000000000..4a6f237f8b --- /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/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index d7001d6eb9..c79ef366ba 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -362,6 +362,13 @@ export const AppConnectionsBrowser = () => { "Learn how to connect your Northflank projects to pull secrets from Infisical.", category: "Hosting", }, + { + name: "MongoDB", + slug: "mongodb", + path: "/integrations/app-connections/mongodb", + description: "Learn how to connect your MongoDB to pull secrets from Infisical.", + category: "Databases" + } ].sort(function (a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx index 3dbede6987..0666237b31 100644 --- a/docs/snippets/RotationsBrowser.jsx +++ b/docs/snippets/RotationsBrowser.jsx @@ -16,7 +16,8 @@ export const RotationsBrowser = () => { {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, {"name": "Redis", "slug": "redis-credentials", "path": "/documentation/platform/secret-rotation/redis-credentials", "description": "Learn how to automatically rotate Redis database credentials.", "category": "Databases"}, {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, - {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} + {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"}, + {"name": "MongoDB Credentials", "slug": "mongodb-credentials", "path": "/documentation/platform/secret-rotation/mongodb-credentials", "description": "Learn how to automatically rotate MongoDB credentials.", "category": "Databases"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/.storybook/decorators/RouterDecorator.tsx b/frontend/.storybook/decorators/RouterDecorator.tsx index a559c5cd1e..c44ee6b125 100644 --- a/frontend/.storybook/decorators/RouterDecorator.tsx +++ b/frontend/.storybook/decorators/RouterDecorator.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import type { Decorator } from "@storybook/react-vite"; import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router"; -export const RouterDecorator: Decorator = (Story) => { +export const RouterDecorator: Decorator = (Story, params) => { const router = useMemo(() => { const routeTree = createRootRoute({ component: Story @@ -11,7 +11,7 @@ export const RouterDecorator: Decorator = (Story) => { return createRouter({ routeTree }); - }, [Story]); + }, [Story, params]); return ; }; diff --git a/frontend/index.html b/frontend/index.html index e3a0519155..b1dca0a761 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/package-lock.json b/frontend/package-lock.json index e8fdda0965..bbb72bfa7c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", @@ -136,6 +137,7 @@ "prettier": "3.4.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^6.2.0", @@ -3353,6 +3355,85 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -14777,6 +14858,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8009c2118a..075807caa8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -51,6 +51,7 @@ "@radix-ui/react-radio-group": "^1.2.2", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", @@ -145,6 +146,7 @@ "prettier": "3.4.2", "prettier-plugin-tailwindcss": "^0.6.14", "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", "vite": "^6.2.0", diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index d9887a929e..bfd4a19346 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -66,7 +66,7 @@ const PROJECT_TYPE_MENU_ITEMS = [ value: ProjectType.SecretManager }, { - label: "Certificates Management", + label: "Certificate Manager", value: ProjectType.CertificateManager }, { diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index e8553f6d98..01904b8523 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -67,6 +67,7 @@ const Content = ({ secretRotation }: ContentProps) => { case SecretRotation.MySqlCredentials: case SecretRotation.MsSqlCredentials: case SecretRotation.OracleDBCredentials: + case SecretRotation.MongoDBCredentials: Component = ( = { [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields, [SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields, - [SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields + [SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields, + [SecretRotation.MongoDBCredentials]: SqlCredentialsRotationParametersFields }; export const SecretRotationV2ParametersFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index e484a64b12..d48c69ccac 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -24,7 +24,8 @@ const COMPONENT_MAP: Record = { [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields, [SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields, - [SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields + [SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields, + [SecretRotation.MongoDBCredentials]: SqlCredentialsRotationReviewFields }; export const SecretRotationV2ReviewFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index e05fd31f50..a211abff85 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -21,7 +21,8 @@ const COMPONENT_MAP: Record = { [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields, [SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields, - [SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields + [SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields, + [SecretRotation.MongoDBCredentials]: SqlCredentialsRotationSecretsMappingFields }; export const SecretRotationV2SecretsMappingFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index 199036a8fc..3ea0f75ed4 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -4,6 +4,7 @@ import { Auth0ClientSecretRotationSchema } from "@app/components/secret-rotation import { AwsIamUserSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/aws-iam-user-secret-rotation-schema"; import { AzureClientSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/azure-client-secret-rotation-schema"; import { LdapPasswordRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/ldap-password-rotation-schema"; +import { MongoDBCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mongodb-credentials-rotation-schema"; import { MsSqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mssql-credentials-rotation-schema"; import { MySqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/mysql-credentials-rotation-schema"; import { PostgresCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/postgres-credentials-rotation-schema"; @@ -27,7 +28,8 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) => LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema, OktaClientSecretRotationSchema, - RedisCredentialsRotationSchema + RedisCredentialsRotationSchema, + MongoDBCredentialsRotationSchema ]), z.object({ id: z.string().optional() }) ) diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/mongodb-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/mongodb-credentials-rotation-schema.ts new file mode 100644 index 0000000000..24c22cfed9 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/mongodb-credentials-rotation-schema.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SqlCredentialsRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +export const MongoDBCredentialsRotationSchema = z + .object({ + type: z.literal(SecretRotation.MongoDBCredentials) + }) + .merge(SqlCredentialsRotationSchema) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 506c81a212..a76f4cbbe3 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -9,7 +9,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormControl, Input, Select, SelectItem, Switch, Tooltip } from "@app/components/v2"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; +import { + SecretSync, + SecretSyncInitialSyncBehavior, + useSecretSyncOption +} from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields"; @@ -139,13 +143,25 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { )} /> - {!syncOption?.canImportSecrets && ( + {!syncOption?.canImportSecrets ? (

{destinationName} only supports overwriting destination secrets.{" "} {!currentSyncOption.disableSecretDeletion && - "Secrets not present in Infisical will be removed from the destination."} + `Secrets not present in Infisical will be removed from the destination. Consider adding a key schema or disabling secret deletion if you do not want existing secrets to be removed from ${destinationName}.`}

+ ) : ( + currentSyncOption.initialSyncBehavior === + SecretSyncInitialSyncBehavior.OverwriteDestination && + !currentSyncOption.disableSecretDeletion && ( +

+ + Secrets not present in Infisical will be removed from the destination. If you have + secrets in {destinationName} that you do not want deleted, consider setting initial + sync behavior to import destination secrets. Alternatively, configure a key schema + or disable secret deletion below to have Infisical ignore these secrets. +

+ ) )} )} @@ -183,26 +199,26 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { className="max-w-md" content={ - We highly recommend using a{" "} + We highly recommend configuring a{" "} - Key Schema + key schema {" "} - to ensure that Infisical only manages the specific keys you intend, keeping - everything else untouched. + to ensure that Infisical only manages secrets in {destinationName} that match + the key pattern.

Destination secrets that do not match the schema will not be deleted or updated.
} > -
- Infisical strongly advises setting a Key Schema{" "} - +
+ Infisical strongly advises configuring a key schema{" "} +
} diff --git a/frontend/src/components/v2/Input/Input.tsx b/frontend/src/components/v2/Input/Input.tsx index 30cd4ce8ec..9227d57b31 100644 --- a/frontend/src/components/v2/Input/Input.tsx +++ b/frontend/src/components/v2/Input/Input.tsx @@ -65,6 +65,12 @@ const inputParentContainerVariants = cva("inline-flex font-inter items-center bo } }); +const data1pIgnore = (autoComplete?: string) => { + if (!autoComplete) return true; + + return !autoComplete.match(/(email|password|username)/i); +}; + export type InputProps = Omit, "size"> & VariantProps & Props; @@ -86,6 +92,7 @@ export const Input = forwardRef( isReadOnly, autoCapitalization, warning, + autoComplete, ...props }, ref @@ -116,6 +123,8 @@ export const Input = forwardRef( readOnly={isReadOnly} disabled={isDisabled} onInput={handleInput} + autoComplete={autoComplete} + data-1p-ignore={data1pIgnore(autoComplete)} className={twMerge( leftIcon ? "pl-10" : "pl-2.5", rightIcon || warning ? "pr-10" : "pr-2.5", diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 5a2ba2e123..ffe4c9daee 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -32,7 +32,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P

{children}

-
{description}
+
{description}
); diff --git a/frontend/src/components/v3/generic/Accordion/Accordion.tsx b/frontend/src/components/v3/generic/Accordion/Accordion.tsx new file mode 100644 index 0000000000..dcf4d5559d --- /dev/null +++ b/frontend/src/components/v3/generic/Accordion/Accordion.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; + +import { cn } from "../../utils"; + +function UnstableAccordion({ ...props }: React.ComponentProps) { + return ( + + ); +} + +function UnstableAccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function UnstableAccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + "cursor-pointer hover:bg-foreground/5", + "data-[state=open]:bg-foreground/5", + className + )} + {...props} + > + + {children} + + + ); +} + +function UnstableAccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ); +} + +export { + UnstableAccordion, + UnstableAccordionContent, + UnstableAccordionItem, + UnstableAccordionTrigger +}; diff --git a/frontend/src/components/v3/generic/Accordion/index.ts b/frontend/src/components/v3/generic/Accordion/index.ts new file mode 100644 index 0000000000..16e0243c2a --- /dev/null +++ b/frontend/src/components/v3/generic/Accordion/index.ts @@ -0,0 +1 @@ +export * from "./Accordion"; diff --git a/frontend/src/components/v3/generic/Alert/Alert.tsx b/frontend/src/components/v3/generic/Alert/Alert.tsx new file mode 100644 index 0000000000..2e8190df15 --- /dev/null +++ b/frontend/src/components/v3/generic/Alert/Alert.tsx @@ -0,0 +1,63 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import { cva, type VariantProps } from "cva"; + +import { cn } from "../../utils"; + +const alertVariants = cva( + "relative w-full border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-container text-card-foreground", + info: "bg-info/5 text-info border-info/20", + org: "bg-org/5 text-org border-org/20", + "sub-org": "bg-sub-org/5 text-sub-org border-sub-org/20" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +function UnstableAlert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function UnstableAlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableAlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { UnstableAlert, UnstableAlertDescription, UnstableAlertTitle }; diff --git a/frontend/src/components/v3/generic/Alert/index.ts b/frontend/src/components/v3/generic/Alert/index.ts new file mode 100644 index 0000000000..b8e17a03c9 --- /dev/null +++ b/frontend/src/components/v3/generic/Alert/index.ts @@ -0,0 +1 @@ +export * from "./Alert"; diff --git a/frontend/src/components/v3/generic/Badge/Badge.stories.tsx b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx index 4cbf955f93..244bd81087 100644 --- a/frontend/src/components/v3/generic/Badge/Badge.stories.tsx +++ b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform"; +import { UnstableButtonGroup } from "../ButtonGroup"; import { Badge } from "./Badge"; /** @@ -33,13 +34,34 @@ const meta = { argTypes: { variant: { control: "select", - options: ["neutral", "success", "info", "warning", "danger", "project", "org", "sub-org"] + options: [ + "default", + "outline", + "neutral", + "success", + "info", + "warning", + "danger", + "project", + "org", + "sub-org" + ] }, isTruncatable: { table: { disable: true } }, + isFullWidth: { + table: { + disable: true + } + }, + isSquare: { + table: { + disable: true + } + }, asChild: { table: { disable: true @@ -57,6 +79,38 @@ const meta = { export default meta; type Story = StoryObj; +export const Default: Story = { + name: "Variant: Default", + args: { + variant: "default", + children: <>Default + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other badge variants are not applicable or as the key when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Outline: Story = { + name: "Variant: Outline", + args: { + variant: "outline", + children: <>Outline + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other badge variants are not applicable or as the value when displaying key-value pairs with ButtonGroup." + } + } + } +}; + export const Neutral: Story = { name: "Variant: Neutral", args: { @@ -71,8 +125,7 @@ export const Neutral: Story = { parameters: { docs: { description: { - story: - "Use this variant when indicating neutral or disabled states or when linking to external documents." + story: "Use this variant when indicating neutral or disabled states." } } } @@ -133,7 +186,8 @@ export const Info: Story = { parameters: { docs: { description: { - story: "Use this variant when indicating informational states." + story: + "Use this variant when indicating informational states or when linking to external documentation." } } } @@ -374,3 +428,22 @@ export const IsFullWidth: Story = {
) }; + +export const KeyValuePair: Story = { + name: "Example: Key-Value Pair", + args: {}, + parameters: { + docs: { + description: { + story: + "Use a default and outline badge in conjunction with the `` component to display key-value pairs." + } + } + }, + decorators: () => ( + + Key + Value + + ) +}; diff --git a/frontend/src/components/v3/generic/Badge/Badge.tsx b/frontend/src/components/v3/generic/Badge/Badge.tsx index f94bff5f2e..a31f279f2e 100644 --- a/frontend/src/components/v3/generic/Badge/Badge.tsx +++ b/frontend/src/components/v3/generic/Badge/Badge.tsx @@ -6,7 +6,7 @@ import { cn } from "@app/components/v3/utils"; const badgeVariants = cva( [ - "select-none items-center align-middle rounded-sm h-4.5 px-1.5 text-xs", + "select-none border items-center align-middle rounded-sm h-4.5 px-1.5 text-xs", "gap-x-1 [a&,button&]:cursor-pointer inline-flex font-normal", "[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:stroke-[2.25] [&_svg:not([class*='size-'])]:size-3", "transition duration-200 ease-in-out" @@ -24,19 +24,22 @@ const badgeVariants = cva( true: "w-4.5 justify-center px-0.5" }, variant: { - ghost: "text-mineshaft-200 gap-x-2", - neutral: "bg-neutral/25 text-neutral [a&,button&]:hover:bg-neutral/35", - success: "bg-success/25 text-success [a&,button&]:hover:bg-success/35", - info: "bg-info/25 text-info [a&,button&]:hover:bg-info/35", - warning: "bg-warning/25 text-warning [a&,button&]:hover:bg-warning/35", - danger: "bg-danger/25 text-danger [a&,button&]:hover:bg-danger/35", - project: "bg-project/25 text-project [a&,button&]:hover:bg-project/35", - org: "bg-org/25 text-org [a&,button&]:hover:bg-org/35", - "sub-org": "bg-sub-org/25 text-sub-org [a&,button&]:hover:bg-sub-org/35" + ghost: "text-foreground border-none", + default: "bg-label text-background border-label [a&,button&]:hover:bg-primary/35", + outline: "text-label border-label border", + neutral: "bg-neutral/15 border-neutral/10 text-neutral [a&,button&]:hover:bg-neutral/35", + success: "bg-success/15 border-success/10 text-success [a&,button&]:hover:bg-success/35", + info: "bg-info/15 border-info/10 border text-info [a&,button&]:hover:bg-info/35", + warning: "bg-warning/15 border-warning/10 text-warning [a&,button&]:hover:bg-warning/35", + danger: "bg-danger/15 border-danger/10 text-danger border [a&,button&]:hover:bg-danger/35", + project: + "bg-project/15 text-project border-project/10 border [a&,button&]:hover:bg-project/35", + org: "bg-org/15 border border-org/10 text-org [a&,button&]:hover:bg-org/35", + "sub-org": "bg-sub-org/15 border-sub-org/10 text-sub-org [a&,button&]:hover:bg-sub-org/35" } }, defaultVariants: { - variant: "neutral" + variant: "default" } } ); @@ -44,7 +47,6 @@ const badgeVariants = cva( type TBadgeProps = VariantProps & React.ComponentProps<"span"> & { asChild?: boolean; - variant: NonNullable["variant"]>; // TODO: REMOVE }; const Badge = forwardRef( diff --git a/frontend/src/components/v3/generic/Button/Button.stories.tsx b/frontend/src/components/v3/generic/Button/Button.stories.tsx new file mode 100644 index 0000000000..0f975e70de --- /dev/null +++ b/frontend/src/components/v3/generic/Button/Button.stories.tsx @@ -0,0 +1,357 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + AsteriskIcon, + BanIcon, + CheckIcon, + CircleXIcon, + ExternalLinkIcon, + InfoIcon, + RadarIcon, + TriangleAlertIcon, + UserIcon +} from "lucide-react"; + +import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform"; +import { UnstableButton } from "./Button"; + +/** + * Buttons act as an indicator that can optionally be made interactable. + * You can place text and icons inside a Button. + * Buttons are often used for the indication of a status, state or scope. + */ +const meta = { + title: "Generic/Button", + component: UnstableButton, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: [ + "default", + "outline", + "neutral", + "success", + "info", + "warning", + "danger", + "project", + "org", + "sub-org" + ] + }, + size: { + control: "select", + options: ["xs", "sm", "md", "lg"] + }, + isPending: { + control: "boolean" + }, + isFullWidth: { + control: "boolean" + }, + isDisabled: { + control: "boolean" + }, + as: { + table: { + disable: true + } + }, + children: { + table: { + disable: true + } + } + }, + args: { children: "Button", isPending: false, isDisabled: false, isFullWidth: false, size: "md" } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Variant: Default", + args: { + variant: "default", + children: <>Default + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other Button variants are not applicable or as the key when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Outline: Story = { + name: "Variant: Outline", + args: { + variant: "outline", + children: <>Outline + }, + parameters: { + docs: { + description: { + story: + "Use this variant when other Button variants are not applicable or as the value when displaying key-value pairs with ButtonGroup." + } + } + } +}; + +export const Neutral: Story = { + name: "Variant: Neutral", + args: { + variant: "neutral", + children: ( + <> + + Disabled + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating neutral or disabled states." + } + } + } +}; + +export const Ghost: Story = { + name: "Variant: Ghost", + args: { + variant: "ghost", + children: ( + <> + + User + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating a configuration or property value. Avoid using this variant as an interactive element as it is not intuitive to interact with." + } + } + } +}; + +export const Success: Story = { + name: "Variant: Success", + args: { + variant: "success", + children: ( + <> + + Success + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating successful or healthy states." + } + } + } +}; + +export const Info: Story = { + name: "Variant: Info", + args: { + variant: "info", + children: ( + <> + + Info + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating informational states or when linking to external documentation." + } + } + } +}; + +export const Warning: Story = { + name: "Variant: Warning", + args: { + variant: "warning", + children: ( + <> + + Warning + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating activity or attention warranting states." + } + } + } +}; + +export const Danger: Story = { + name: "Variant: Danger", + args: { + variant: "danger", + children: ( + <> + + Danger + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating destructive or error states." + } + } + } +}; + +export const Organization: Story = { + name: "Variant: Organization", + args: { + variant: "org", + children: ( + <> + + Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating organization scope or links." + } + } + } +}; + +export const SubOrganization: Story = { + name: "Variant: Sub-Organization", + args: { + variant: "sub-org", + children: ( + <> + + Sub-Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating sub-organization scope or links." + } + } + } +}; + +export const Project: Story = { + name: "Variant: Project", + args: { + variant: "project", + children: ( + <> + + Project + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating project scope or links." + } + } + } +}; + +export const AsExternalLink: Story = { + name: "Example: As External Link", + args: { + variant: "info", + as: "a", + href: "https://www.infisical.com", + children: ( + <> + Link + + ) + }, + parameters: { + docs: { + description: { + story: 'Use the `as="a"` prop to use a Button as an external `a` tag component.' + } + } + } +}; + +export const AsRouterLink: Story = { + name: "Example: As Router Link", + args: { + variant: "project", + as: "link", + children: ( + <> + + Secret Scanning + + ) + }, + parameters: { + docs: { + description: { + story: 'Use the `as="link"` prop to use a Button as an internal `Link` component.' + } + } + } +}; + +export const IsFullWidth: Story = { + name: "Example: isFullWidth", + args: { + variant: "neutral", + isFullWidth: true, + + children: ( + <> + + Secret Value + + ) + }, + parameters: { + docs: { + description: { + story: + "Use the `isFullWidth` prop to expand the Buttons width to fill it's parent container." + } + } + }, + decorators: (Story) => ( +
+ +
+ ) +}; diff --git a/frontend/src/components/v3/generic/Button/Button.tsx b/frontend/src/components/v3/generic/Button/Button.tsx new file mode 100644 index 0000000000..4c065b1d0d --- /dev/null +++ b/frontend/src/components/v3/generic/Button/Button.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import { forwardRef } from "react"; +import { Link, LinkProps } from "@tanstack/react-router"; +import { cva, type VariantProps } from "cva"; + +import { Lottie } from "@app/components/v2"; +import { cn } from "@app/components/v3/utils"; + +const buttonVariants = cva( + cn( + "inline-flex items-center active:scale-[0.95] justify-center border cursor-pointer whitespace-nowrap", + " text-sm transition-all disabled:pointer-events-none disabled:opacity-75 shrink-0", + "[&>svg]:pointer-events-none [&>svg]:shrink-0", + "focus-visible:ring-ring outline-0 focus-visible:ring-2 select-none" + ), + { + variants: { + variant: { + default: + "border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90", + neutral: + "border-neutral/10 bg-neutral/40 text-foreground hover:bg-neutral/50 hover:border-neutral/20", + outline: "text-foreground hover:bg-foreground/10 border-border hover:border-foreground/20", + ghost: "text-foreground hover:bg-foreground/10 border-transparent", + project: + "border-project/25 bg-project/15 text-foreground hover:bg-project/30 hover:border-project/30", + org: "border-org/25 bg-org/15 text-foreground hover:bg-org/30 hover:border-org/30", + "sub-org": + "border-sub-org/25 bg-sub-org/15 text-foreground hover:bg-sub-org/30 hover:border-sub-org/30", + success: + "border-success/25 bg-success/15 text-foreground hover:bg-success/30 hover:border-success/30", + info: "border-info/25 bg-info/15 text-foreground hover:bg-info/30 hover:border-info/30", + warning: + "border-warning/25 bg-warning/15 text-foreground hover:bg-warning/30 hover:border-warning/30", + danger: + "border-danger/25 bg-danger/15 text-foreground hover:bg-danger/30 hover:border-danger/30" + }, + size: { + xs: "h-7 px-2 rounded-[3px] text-xs [&>svg]:size-3 gap-1.5", + sm: "h-8 px-2.5 rounded-[4px] text-sm [&>svg]:size-3 gap-1.5", + md: "h-9 px-3 rounded-[5px] text-sm [&>svg]:size-3.5 gap-1.5", + lg: "h-10 px-3 rounded-[6px] text-sm [&>svg]:size-3.5 gap-1.5" + }, + isPending: { + true: "text-transparent" + }, + isFullWidth: { + true: "w-full", + false: "w-fit" + } + }, + defaultVariants: { + variant: "default", + size: "md" + } + } +); + +type UnstableButtonProps = (VariantProps & { + isPending?: boolean; + isFullWidth?: boolean; + isDisabled?: boolean; +}) & + ( + | ({ as?: "button" | undefined } & React.ComponentProps<"button">) + | ({ as: "link"; className?: string } & LinkProps) + | ({ as: "a" } & React.ComponentProps<"a">) + ); + +const UnstableButton = forwardRef( + ( + { + className, + variant = "default", + size = "md", + isPending = false, + isFullWidth = false, + isDisabled = false, + children, + ...props + }, + ref + ): JSX.Element => { + const sharedProps = { + "data-slot": "button", + className: cn(buttonVariants({ variant, size, isPending, isFullWidth }), className) + }; + + const child = ( + <> + {children} + {isPending && ( + + )} + + ); + + switch (props.as) { + case "a": + return ( + } + target="_blank" + rel="noopener noreferrer" + {...props} + {...sharedProps} + > + {child} + + ); + case "link": + return ( + } {...props} {...sharedProps}> + {child} + + ); + default: + return ( + + ); + } + } +); + +UnstableButton.displayName = "Button"; + +export { buttonVariants, UnstableButton, type UnstableButtonProps }; diff --git a/frontend/src/components/v3/generic/Button/index.ts b/frontend/src/components/v3/generic/Button/index.ts new file mode 100644 index 0000000000..e22c29adcf --- /dev/null +++ b/frontend/src/components/v3/generic/Button/index.ts @@ -0,0 +1 @@ +export * from "./Button"; diff --git a/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx b/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx new file mode 100644 index 0000000000..753d28a319 --- /dev/null +++ b/frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "cva"; + +import { cn } from "../../utils"; +import { UnstableSeparator } from "../Separator"; + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none" + } + }, + defaultVariants: { + orientation: "horizontal" + } + } +); + +function UnstableButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function UnstableButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : "div"; + + return ( + + ); +} + +function UnstableButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + buttonGroupVariants, + UnstableButtonGroup, + UnstableButtonGroupSeparator, + UnstableButtonGroupText +}; diff --git a/frontend/src/components/v3/generic/ButtonGroup/index.ts b/frontend/src/components/v3/generic/ButtonGroup/index.ts new file mode 100644 index 0000000000..d22eaf4c2e --- /dev/null +++ b/frontend/src/components/v3/generic/ButtonGroup/index.ts @@ -0,0 +1 @@ +export * from "./ButtonGroup"; diff --git a/frontend/src/components/v3/generic/Card/Card.tsx b/frontend/src/components/v3/generic/Card/Card.tsx new file mode 100644 index 0000000000..f389305263 --- /dev/null +++ b/frontend/src/components/v3/generic/Card/Card.tsx @@ -0,0 +1,82 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; + +import { cn } from "../../utils"; + +function UnstableCard({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableCardContent({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function UnstableCardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + UnstableCard, + UnstableCardAction, + UnstableCardContent, + CardDescription as UnstableCardDescription, + UnstableCardFooter, + UnstableCardHeader, + UnstableCardTitle +}; diff --git a/frontend/src/components/v3/generic/Card/index.ts b/frontend/src/components/v3/generic/Card/index.ts new file mode 100644 index 0000000000..24d3212465 --- /dev/null +++ b/frontend/src/components/v3/generic/Card/index.ts @@ -0,0 +1 @@ +export * from "./Card"; diff --git a/frontend/src/components/v3/generic/Detail/Detail.tsx b/frontend/src/components/v3/generic/Detail/Detail.tsx new file mode 100644 index 0000000000..16aa8d0d47 --- /dev/null +++ b/frontend/src/components/v3/generic/Detail/Detail.tsx @@ -0,0 +1,23 @@ +import { cn } from "../../utils"; + +function Detail({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DetailLabel({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DetailValue({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +function DetailGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Detail, DetailGroup, DetailLabel, DetailValue }; diff --git a/frontend/src/components/v3/generic/Detail/index.ts b/frontend/src/components/v3/generic/Detail/index.ts new file mode 100644 index 0000000000..f511dd353f --- /dev/null +++ b/frontend/src/components/v3/generic/Detail/index.ts @@ -0,0 +1 @@ +export * from "./Detail"; diff --git a/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..5a93c3fb62 --- /dev/null +++ b/frontend/src/components/v3/generic/Dropdown/Dropdown.tsx @@ -0,0 +1,255 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@app/components/v3/utils"; + +function UnstableDropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function UnstableDropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuItem({ + className, + inset, + variant = "default", + isDisabled, + ...props +}: Omit, "disabled"> & { + inset?: boolean; + variant?: "default" | "danger"; + isDisabled?: boolean; +}) { + return ( + + ); +} + +function UnstableDropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function UnstableDropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function UnstableDropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function UnstableDropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function UnstableDropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +function UnstableDropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function UnstableDropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function UnstableDropdownMenuSubContent({ + className, + sideOffset = 8, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +type UnstableDropdownMenuChecked = DropdownMenuPrimitive.DropdownMenuCheckboxItemProps["checked"]; + +export { + UnstableDropdownMenu, + UnstableDropdownMenuCheckboxItem, + type UnstableDropdownMenuChecked, + UnstableDropdownMenuContent, + UnstableDropdownMenuGroup, + UnstableDropdownMenuItem, + UnstableDropdownMenuLabel, + UnstableDropdownMenuPortal, + UnstableDropdownMenuRadioGroup, + UnstableDropdownMenuRadioItem, + UnstableDropdownMenuSeparator, + UnstableDropdownMenuShortcut, + UnstableDropdownMenuSub, + UnstableDropdownMenuSubContent, + UnstableDropdownMenuSubTrigger, + UnstableDropdownMenuTrigger +}; diff --git a/frontend/src/components/v3/generic/Dropdown/index.ts b/frontend/src/components/v3/generic/Dropdown/index.ts new file mode 100644 index 0000000000..f024a9e9a1 --- /dev/null +++ b/frontend/src/components/v3/generic/Dropdown/index.ts @@ -0,0 +1 @@ +export * from "./Dropdown"; diff --git a/frontend/src/components/v3/generic/Empty/Empty.tsx b/frontend/src/components/v3/generic/Empty/Empty.tsx new file mode 100644 index 0000000000..3b86bcc88a --- /dev/null +++ b/frontend/src/components/v3/generic/Empty/Empty.tsx @@ -0,0 +1,100 @@ +import { cn } from "../../utils"; + +function UnstableEmpty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableEmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +// scott: TODO + +// const emptyMediaVariants = cva( +// "flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0", +// { +// variants: { +// variant: { +// default: "bg-transparent", +// icon: "bg-bunker-900 rounded text-foreground flex size-10 shrink-0 items-center justify-center [&_svg:not([class*='size-'])]:size-6" +// } +// }, +// defaultVariants: { +// variant: "default" +// } +// } +// ); + +// function EmptyMedia({ +// className, +// variant = "default", +// ...props +// }: React.ComponentProps<"div"> & VariantProps) { +// return ( +//
+// ); +// } + +function UnstableEmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function UnstableEmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-project", + className + )} + {...props} + /> + ); +} + +function UnstableEmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + UnstableEmpty, + UnstableEmptyContent, + UnstableEmptyDescription, + UnstableEmptyHeader, + UnstableEmptyTitle +}; diff --git a/frontend/src/components/v3/generic/Empty/index.ts b/frontend/src/components/v3/generic/Empty/index.ts new file mode 100644 index 0000000000..7aa85b1b7d --- /dev/null +++ b/frontend/src/components/v3/generic/Empty/index.ts @@ -0,0 +1 @@ +export * from "./Empty"; diff --git a/frontend/src/components/v3/generic/IconButton/IconButton.tsx b/frontend/src/components/v3/generic/IconButton/IconButton.tsx new file mode 100644 index 0000000000..d6fdfb4ce7 --- /dev/null +++ b/frontend/src/components/v3/generic/IconButton/IconButton.tsx @@ -0,0 +1,111 @@ +import * as React from "react"; +import { forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "cva"; +import { twMerge } from "tailwind-merge"; + +import { Lottie } from "@app/components/v2"; +import { cn } from "@app/components/v3/utils"; + +const iconButtonVariants = cva( + cn( + "inline-flex items-center active:scale-[0.99] justify-center border cursor-pointer whitespace-nowrap rounded-[4px] text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-75 [&_svg]:pointer-events-none shrink-0 [&>svg]:shrink-0", + "focus-visible:ring-ring outline-0 focus-visible:ring-2" + ), + { + variants: { + variant: { + default: + "border-foreground bg-foreground text-background hover:bg-foreground/90 hover:border-foreground/90", + accent: + "border-accent/10 bg-accent/40 text-foreground hover:bg-accent/50 hover:border-accent/20", + outline: "text-foreground hover:bg-foreground/20 border-border hover:border-foreground/50", + ghost: "text-foreground hover:bg-foreground/40 border-transparent", + project: + "border-project/75 bg-project/40 text-foreground hover:bg-project/50 hover:border-kms", + org: "border-org/75 bg-org/40 text-foreground hover:bg-org/50 hover:border-org", + "sub-org": + "border-sub-org/75 bg-sub-org/40 text-foreground hover:bg-sub-org/50 hover:border-namespace", + success: + "border-success/75 bg-success/40 text-foreground hover:bg-success/50 hover:border-success", + info: "border-info/75 bg-info/40 text-foreground hover:bg-info/50 hover:border-info", + warning: + "border-warning/75 bg-warning/40 text-foreground hover:bg-warning/50 hover:border-warning", + danger: + "border-danger/75 bg-danger/40 text-foreground hover:bg-danger/50 hover:border-danger" + }, + size: { + xs: "h-7 w-7 [&>svg]:size-3.5 [&>svg]:stroke-[1.75]", + sm: "h-8 w-8 [&>svg]:size-4 [&>svg]:stroke-[1.5]", + md: "h-9 w-9 [&>svg]:size-6 [&>svg]:stroke-[1.5]", + lg: "h-10 w-10 [&>svg]:size-7 [&>svg]:stroke-[1.5]" + }, + isPending: { + true: "text-transparent" + }, + isFullWidth: { + true: "w-full", + false: "w-fit" + } + }, + defaultVariants: { + variant: "default", + size: "md" + } + } +); + +type UnstableIconButtonProps = React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + isPending?: boolean; + isDisabled?: boolean; + }; + +const UnstableIconButton = forwardRef( + ( + { + className, + variant = "default", + size = "md", + asChild = false, + isPending = false, + disabled = false, + isDisabled = false, + children, + ...props + }, + ref + ): JSX.Element => { + const Comp = asChild ? Slot : "button"; + + return ( + + {children} + {isPending && ( + + )} + + ); + } +); + +UnstableIconButton.displayName = "IconButton"; + +export { iconButtonVariants, UnstableIconButton, type UnstableIconButtonProps }; diff --git a/frontend/src/components/v3/generic/IconButton/index.ts b/frontend/src/components/v3/generic/IconButton/index.ts new file mode 100644 index 0000000000..53185101de --- /dev/null +++ b/frontend/src/components/v3/generic/IconButton/index.ts @@ -0,0 +1 @@ +export * from "./IconButton"; diff --git a/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx b/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx new file mode 100644 index 0000000000..346eead3af --- /dev/null +++ b/frontend/src/components/v3/generic/PageLoader/PageLoader.tsx @@ -0,0 +1,9 @@ +import { Lottie } from "@app/components/v2"; + +export function UnstablePageLoader() { + return ( +
+ +
+ ); +} diff --git a/frontend/src/components/v3/generic/PageLoader/index.ts b/frontend/src/components/v3/generic/PageLoader/index.ts new file mode 100644 index 0000000000..70c6707ce9 --- /dev/null +++ b/frontend/src/components/v3/generic/PageLoader/index.ts @@ -0,0 +1 @@ +export * from "./PageLoader"; diff --git a/frontend/src/components/v3/generic/Separator/Separator.tsx b/frontend/src/components/v3/generic/Separator/Separator.tsx new file mode 100644 index 0000000000..0ebfe67459 --- /dev/null +++ b/frontend/src/components/v3/generic/Separator/Separator.tsx @@ -0,0 +1,28 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "../../utils"; + +function UnstableSeparator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { UnstableSeparator }; diff --git a/frontend/src/components/v3/generic/Separator/index.ts b/frontend/src/components/v3/generic/Separator/index.ts new file mode 100644 index 0000000000..4060cb5ecd --- /dev/null +++ b/frontend/src/components/v3/generic/Separator/index.ts @@ -0,0 +1 @@ +export * from "./Separator"; diff --git a/frontend/src/components/v3/generic/Table/Table.stories.tsx b/frontend/src/components/v3/generic/Table/Table.stories.tsx new file mode 100644 index 0000000000..09098795bf --- /dev/null +++ b/frontend/src/components/v3/generic/Table/Table.stories.tsx @@ -0,0 +1,138 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CopyIcon, EditIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react"; + +import { + Badge, + UnstableDropdownMenu, + UnstableDropdownMenuContent, + UnstableDropdownMenuItem, + UnstableDropdownMenuTrigger, + UnstableIconButton +} from "@app/components/v3/generic"; +import { ProjectIcon } from "@app/components/v3/platform"; + +import { + UnstableTable, + UnstableTableBody, + UnstableTableCell, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +} from "./Table"; + +const identities: { + name: string; + role: string; + managedBy?: { scope: "org" | "namespace"; name: string }; +}[] = [ + { + name: "machine-one", + role: "Admin", + managedBy: { + scope: "org", + name: "infisical" + } + }, + { + name: "machine-two", + role: "Viewer", + managedBy: { + scope: "namespace", + name: "engineering" + } + }, + { + name: "machine-three", + role: "Developer" + }, + { + name: "machine-four", + role: "Admin", + managedBy: { + scope: "namespace", + name: "dev-ops" + } + }, + { + name: "machine-five", + role: "Viewer", + managedBy: { + scope: "org", + name: "infisical" + } + }, + { + name: "machine-six", + role: "Developer" + } +]; + +function TableDemo() { + return ( + + + + Name + Role + Managed By + + + + + {identities.map((identity) => ( + + {identity.name} + {identity.role} + + + + Project + + + + + + + + + + + + + Copy ID + + + + Edit Identity + + + + Delete Identity + + + + + + ))} + + + ); +} + +const meta = { + title: "Generic/Table", + component: TableDemo, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: {} +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const KitchenSInk: Story = { + name: "Example: Kitchen Sink", + args: {} +}; diff --git a/frontend/src/components/v3/generic/Table/Table.tsx b/frontend/src/components/v3/generic/Table/Table.tsx new file mode 100644 index 0000000000..ffd25b766c --- /dev/null +++ b/frontend/src/components/v3/generic/Table/Table.tsx @@ -0,0 +1,106 @@ +/* eslint-disable react/prop-types */ + +import * as React from "react"; + +import { cn } from "@app/components/v3/utils"; + +function UnstableTable({ className, ...props }: React.ComponentProps<"table">) { + return ( +
+ + + ); +} + +function UnstableTableHeader({ className, ...props }: React.ComponentProps<"thead">) { + return ( + + ); +} + +function UnstableTableBody({ className, ...props }: React.ComponentProps<"tbody">) { + return ( + tr]:last:border-b-0", className)} {...props} /> + ); +} + +function UnstableTableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { + return ( + tr]:last:border-b-0", className)} + {...props} + /> + ); +} + +function UnstableTableRow({ className, ...props }: React.ComponentProps<"tr">) { + return ( + + ); +} + +function UnstableTableHead({ className, ...props }: React.ComponentProps<"th">) { + return ( + + canReadCa && navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/ca/$caId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId", params: { orgId: currentOrg.id, projectId: currentProject.id, @@ -118,8 +133,10 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { {ca.status === CaStatus.PENDING_CERTIFICATE && ( {(isAllowed) => ( { )} {ca.status !== CaStatus.PENDING_CERTIFICATE && ( {(isAllowed) => ( { )} {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( {(isAllowed) => ( { )} {(isAllowed) => ( {

External Certificate Authorities

{(isAllowed) => ( diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx index 017bd45060..b8ba09d3e9 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faBan, faCertificate, @@ -26,7 +27,12 @@ import { Tr } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { + ProjectPermissionCertificateAuthorityActions, + ProjectPermissionSub, + useProject, + useProjectPermission +} from "@app/context"; import { CaStatus, CaType, useListExternalCasByProjectId } from "@app/hooks/api"; import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -45,6 +51,7 @@ type Props = { export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { const { currentProject } = useProject(); + const { permission } = useProjectPermission(); const { data, isPending } = useListExternalCasByProjectId(currentProject.id); return ( @@ -65,17 +72,29 @@ export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { data && data.length > 0 && data.map((ca) => { + const canEditCa = permission.can( + ProjectPermissionCertificateAuthorityActions.Edit, + subject(ProjectPermissionSub.CertificateAuthorities, { + name: ca.name + }) + ); + return (
{ + onClick={() => + canEditCa && handlePopUpOpen("ca", { caId: ca.id, name: ca.name, type: ca.type - }); - }} + }) + } > @@ -95,8 +114,10 @@ export const ExternalCaTable = ({ handlePopUpOpen }: Props) => { {(isAllowed) => ( { {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( {(isAllowed) => ( { )} {(isAllowed) => ( { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx index d16a1ac3f5..8fc59e0130 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx @@ -1,6 +1,9 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { faDownload } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; import { Button, @@ -41,55 +44,119 @@ export type ExportOptions = { }; }; -export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => { - const [selectedFormat, setSelectedFormat] = useState("pem"); - const [pkcs12Options, setPkcs12Options] = useState({ - password: "", - alias: "" - }); +const exportFormSchema = z + .object({ + format: z.enum(["pem", "pkcs12"]), + pkcs12Password: z.string().optional(), + pkcs12Alias: z.string().optional() + }) + .refine( + (data) => { + if (data.format === "pkcs12") { + return data.pkcs12Password && data.pkcs12Alias && data.pkcs12Alias.trim() !== ""; + } + return true; + }, + { + message: "PKCS12 password and alias are required when using PKCS12 format", + path: ["pkcs12Password"] + } + ) + .refine( + (data) => { + if (data.format === "pkcs12") { + return data.pkcs12Password && data.pkcs12Password.length >= 6; + } + return true; + }, + { + message: "PKCS12 password must be 6 characters or longer", + path: ["pkcs12Password"] + } + ) + .refine( + (data) => { + if (data.format === "pkcs12" && data.pkcs12Password) { + return data.pkcs12Password.length >= 6; + } + return true; + }, + { + message: "Password must be at least 6 characters long", + path: ["pkcs12Password"] + } + ) + .refine( + (data) => { + if (data.format === "pkcs12") { + return data.pkcs12Alias && data.pkcs12Alias.trim() !== ""; + } + return true; + }, + { + message: "Certificate alias is required", + path: ["pkcs12Alias"] + } + ); +type ExportFormData = z.infer; + +export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => { const { certificateId, serialNumber } = (popUp?.certificateExport?.data as { certificateId: string; serialNumber: string; }) || {}; + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(exportFormSchema), + defaultValues: { + format: "pem", + pkcs12Password: "", + pkcs12Alias: "" + } + }); + + const selectedFormat = watch("format"); + // Reset form whenever the modal opens useEffect(() => { if (popUp?.certificateExport?.isOpen) { - setSelectedFormat("pem"); - setPkcs12Options({ - password: "", - alias: "" + reset({ + format: "pem", + pkcs12Password: "", + pkcs12Alias: "" }); } - }, [popUp?.certificateExport?.isOpen]); + }, [popUp?.certificateExport?.isOpen, reset]); - const isFormValid = () => { - if (selectedFormat === "pkcs12") { - return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== ""; + const onFormSubmit = (data: ExportFormData) => { + if (!(certificateId || serialNumber)) return; + + const options: ExportOptions = {}; + + if (data.format === "pkcs12") { + options.pkcs12 = { + password: data.pkcs12Password!, + alias: data.pkcs12Alias! + }; } - return true; - }; - const handleExport = () => { - if ((certificateId || serialNumber) && isFormValid()) { - const options: ExportOptions = {}; - - if (selectedFormat === "pkcs12") { - options.pkcs12 = pkcs12Options; - } - - onFormatSelected( - selectedFormat, - { - certificateId, - serialNumber - }, - options - ); - handlePopUpToggle("certificateExport", false); - } + onFormatSelected( + data.format, + { + certificateId, + serialNumber + }, + options + ); + handlePopUpToggle("certificateExport", false); }; return ( @@ -100,79 +167,89 @@ export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelec }} > -
-

Choose the format for exporting your certificate

+
+
+

+ Choose the format for exporting your certificate +

- - - - - {selectedFormat === "pkcs12" && ( - <> - 0 && pkcs12Options.password.length < 6 - ? undefined - : "Password to protect the PKCS12 keystore (minimum 6 characters)" - } - isError={pkcs12Options.password.length > 0 && pkcs12Options.password.length < 6} - errorText="Password must be at least 6 characters long" - > - - setPkcs12Options((prev) => ({ ...prev, password: e.target.value })) + ( + - + isError={Boolean(error)} + errorText={error?.message} + > + + + )} + /> - + ( + + + + )} + /> + + ( + + + + )} + /> + + )} + +
+ - + Cancel + + +
-
+
); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 5f6462d2e9..fff9416817 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -20,9 +20,9 @@ import { } from "@app/components/v2"; import { useProject } from "@app/context"; import { useGetCert } from "@app/hooks/api"; -import { useCreateCertificateV3 } from "@app/hooks/api/ca"; import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles"; import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums"; +import { useUnifiedCertificateIssuance } from "@app/hooks/api/certificates/mutations"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; @@ -103,10 +103,11 @@ type Props = { }; type TCertificateDetails = { - serialNumber: string; - certificate: string; - certificateChain: string; - privateKey: string; + serialNumber?: string; + certificate?: string; + certificateChain?: string; + privateKey?: string; + issuingCaCertificate?: string; }; export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { @@ -122,12 +123,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { data: profilesData } = useListCertificateProfiles({ projectId: currentProject?.id || "", - enrollmentType: EnrollmentType.API + enrollmentType: EnrollmentType.API, + includeConfigs: true }); - const { mutateAsync: createCertificate } = useCreateCertificateV3({ - projectId: currentProject?.id - }); + const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance(); const formResolver = useMemo(() => { return zodResolver(createSchema(shouldShowSubjectSection)); @@ -243,7 +243,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } keyUsages, extendedKeyUsages }: FormData) => { - if (!currentProject?.slug) { + if (!currentProject?.slug || !currentProject?.id) { createNotification({ text: "Project not found. Please refresh and try again.", type: "error" @@ -275,44 +275,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } } } - const certificateRequest: any = { - profileId: formProfileId, - projectSlug: currentProject.slug, - ttl, - signatureAlgorithm, - keyAlgorithm, - keyUsages: filterUsages(keyUsages) as CertKeyUsage[], - extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] - }; + try { + // Prepare unified request + const request: any = { + profileId: formProfileId, + projectSlug: currentProject.slug, + projectId: currentProject.id, + attributes: { + ttl, + signatureAlgorithm: signatureAlgorithm || "", + keyAlgorithm: keyAlgorithm || "", + keyUsages: filterUsages(keyUsages) as CertKeyUsage[], + extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[] + } + }; - if (constraints.shouldShowSubjectSection && commonName) { - certificateRequest.commonName = commonName; - } - if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { - const formattedSans = formatSubjectAltNames(subjectAltNames); - if (formattedSans && formattedSans.length > 0) { - certificateRequest.altNames = formattedSans; + if (constraints.shouldShowSubjectSection && commonName) { + request.attributes.commonName = commonName; } + + if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) { + const formattedSans = formatSubjectAltNames(subjectAltNames); + if (formattedSans && formattedSans.length > 0) { + request.attributes.altNames = formattedSans; + } + } + + const response = await issueCertificate(request); + + // Handle certificate issuance response + + if ("certificate" in response && response.certificate) { + const certData = response.certificate; + const certificateDetailsToSet = { + serialNumber: certData.serialNumber || "", + certificate: certData.certificate || "", + certificateChain: certData.certificateChain || "", + privateKey: certData.privateKey || "", + issuingCaCertificate: certData.issuingCaCertificate || "" + }; + + setCertificateDetails(certificateDetailsToSet); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } else { + // Certificate request - async processing + createNotification({ + text: `Certificate request submitted successfully. This may take a few minutes to process. Certificate Request ID: ${response.certificateRequestId}`, + type: "success" + }); + handlePopUpToggle("issueCertificate", false); + } + } catch (error) { + createNotification({ + text: `Failed to request certificate: ${(error as Error)?.message || "Unknown error"}`, + type: "error" + }); } - - const { serialNumber, certificate, certificateChain, privateKey } = - await createCertificate(certificateRequest); - - setCertificateDetails({ - serialNumber, - certificate, - certificateChain, - privateKey - }); - - createNotification({ - text: "Successfully created certificate", - type: "success" - }); }, [ currentProject?.slug, - createCertificate, + issueCertificate, constraints.shouldShowSubjectSection, constraints.shouldShowSanSection ] @@ -321,13 +347,13 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const getModalTitle = () => { if (certificateDetails) return "Certificate Created Successfully"; if (cert) return "Certificate Details"; - return "Issue New Certificate"; + return "Request New Certificate"; }; const getModalSubTitle = () => { if (certificateDetails) return "Certificate has been successfully created and is ready for use"; if (cert) return "View certificate information"; - return "Issue a new certificate using a certificate profile"; + return "Request a new certificate using a certificate profile"; }; return ( @@ -343,10 +369,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } {certificateDetails && ( )} {cert && ( @@ -498,7 +524,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } isLoading={isSubmitting} isDisabled={isSubmitting || (!actualSelectedProfile && !profileId)} > - {cert ? "Update" : "Issue Certificate"} + {cert ? "Update" : "Request Certificate"} + )} +
+ + {(isAllowed) => ( - - )} - + )} + + diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index b4defeb3a9..2be9e35e3b 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { subject } from "@casl/ability"; import { faBan, faCertificate, @@ -40,8 +41,10 @@ import { import { Badge } from "@app/components/v3"; import { ProjectPermissionCertificateActions, + ProjectPermissionPkiSyncActions, ProjectPermissionSub, - useProject + useProject, + useProjectPermission } from "@app/context"; import { useUpdateRenewalConfig } from "@app/hooks/api"; import { caSupportsCapability } from "@app/hooks/api/ca/constants"; @@ -96,6 +99,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { const [perPage, setPerPage] = useState(PER_PAGE_INIT); const { currentProject } = useProject(); + const { permission } = useProjectPermission(); const { data, isPending } = useListWorkspaceCertificates({ projectId: currentProject?.id ?? "", offset: (page - 1) * perPage, @@ -207,55 +211,72 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { return "opacity-0 group-hover:opacity-100"; })()}`} > - {canShowAutoRenewalIcon && ( - { - if (hasFailed && certificate.renewalError) { - return `Auto-renewal failed: ${certificate.renewalError}`; - } - if (isAutoRenewalEnabled) { - const expiryDate = new Date(certificate.notAfter); - const now = new Date(); - const daysUntilExpiry = Math.ceil( - (expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) - ); - const daysUntilRenewal = Math.max( - 0, - daysUntilExpiry - (certificate.renewBeforeDays || 0) - ); - return `Auto-renews in ${daysUntilRenewal}d`; - } - return "Set auto renewal"; - })()} - > - - - )} + return ( + { + if (hasFailed && certificate.renewalError) { + return `Auto-renewal failed: ${certificate.renewalError}`; + } + if (isAutoRenewalEnabled) { + const expiryDate = new Date(certificate.notAfter); + const now = new Date(); + const daysUntilExpiry = Math.ceil( + (expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) + ); + const daysUntilRenewal = Math.max( + 0, + daysUntilExpiry - (certificate.renewBeforeDays || 0) + ); + return `Auto-renews in ${daysUntilRenewal}d`; + } + return "Set auto renewal"; + })()} + > + + + ); + })()} @@ -268,7 +289,12 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(isAllowed) => ( { {isLegacyTemplatesEnabled && ( {(isAllowed) => ( { return ( {(isAllowed) => { return ( @@ -391,7 +427,12 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { return ( {(isAllowed) => ( { return ( {(isAllowed) => ( { {certificate.status === CertStatus.ACTIVE && !certificate.renewedByCertificateId && ( {(isAllowed) => ( { )} )} - {/* Only show revoke button if CA supports revocation */} + {/* Only show revoke button if CA supports revocation and certificate is not already revoked */} {(() => { const caType = caCapabilityMap[certificate.caId]; const supportsRevocation = !caType || caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); - if (!supportsRevocation) { + if (!supportsRevocation || isRevoked) { return null; } return ( {(isAllowed) => ( { })()} {(isAllowed) => ( { - // console.log("PKI Sync navigation:", { syncId: id, projectId }); - navigate({ - to: ROUTE_PATHS.CertManager.PkiSyncDetailsByIDPage.path, - params: { - syncId: id, - projectId, - orgId: currentOrg.id - } - }); - }} - className={twMerge( - "group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700", - syncStatus === PkiSyncStatus.Failed && "bg-red/5 hover:bg-red/10" - )} - key={`sync-${id}`} - > -
- - - { + if (!isAllowed) { + return; } - > + // console.log("PKI Sync navigation:", { syncId: id, projectId }); + navigate({ + to: ROUTE_PATHS.CertManager.PkiSyncDetailsByIDPage.path, + params: { + syncId: id, + projectId, + orgId: currentOrg.id + } + }); + }} + className={twMerge( + "group h-10 transition-colors duration-100 hover:bg-mineshaft-700", + syncStatus === PkiSyncStatus.Failed && "bg-red/5 hover:bg-red/10", + isAllowed ? "cursor-pointer" : "cursor-not-allowed" + )} + key={`sync-${id}`} + > + + - + + + - + {(allowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerSyncCertificates(pkiSync); + }} + isDisabled={!allowed} + > + +
+ Trigger Sync + +
+
+
+ )} + + {syncOption?.canImportCertificates && ( + + {(allowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerImportCertificates(pkiSync); + }} + isDisabled={!allowed} + > + +
+ Import Certificates + +
+
+
+ )} +
+ )} + + {(allowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onTriggerRemoveCertificates(pkiSync); + }} + isDisabled={!allowed} + > + +
+ Remove Certificates + +
+
+
+ )} +
+ + {(allowed: boolean) => ( + + } + onClick={(e) => { + e.stopPropagation(); + onToggleEnable(pkiSync); + }} + > + {isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync + + )} + + + {(allowed: boolean) => ( + } + onClick={(e) => { + e.stopPropagation(); + onDelete(pkiSync); + }} + > + Delete Sync + + )} + + + + + + + ); + }} + ); }; diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx index faec782c72..0fba9b6b4a 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx @@ -15,7 +15,7 @@ const IntegrationsListPageQuerySchema = z.object({ }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/" )({ component: IntegrationsListPage, validateSearch: zodValidator(IntegrationsListPageQuerySchema), diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx index c8243b914d..88e03a2f31 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx @@ -64,7 +64,7 @@ export const PkiCollectionPage = () => { }); handlePopUpClose("deletePkiCollection"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/policies", + to: "/organizations/$orgId/projects/cert-manager/$projectId/policies", params: { orgId: currentOrg.id, projectId: params.projectId @@ -77,7 +77,7 @@ export const PkiCollectionPage = () => { {data && (
{ @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Collections", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/policies", + to: "/organizations/$orgId/projects/cert-manager/$projectId/policies", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx index acea4d4d0f..779afb0a42 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx @@ -64,7 +64,7 @@ const Page = () => { handlePopUpClose("deletePkiSubscriber"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", params: { orgId: currentOrg.id, projectId @@ -77,7 +77,7 @@ const Page = () => { {data && (
{ @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Subscribers", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx index 27c91851c2..054cbcc45b 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -213,7 +213,8 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { // Fetch Azure ADCS templates when Azure CA is selected const { data: azureTemplates } = useGetAzureAdcsTemplates({ caId: selectedCa?.type === CaType.AZURE_AD_CS ? selectedCaId : "", - projectId + projectId, + isAzureAdcsCa: true }); // Initialize form with ALL subscriber data including template diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx index 880f4af8f9..bf59d6665d 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx @@ -77,7 +77,7 @@ export const PkiSubscribersTable = ({ handlePopUpOpen }: Props) => { key={`pki-subscriber-${subscriber.id}`} onClick={() => navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName", + to: "/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName", params: { orgId: currentOrg.id, projectId: currentProject.id, diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx index df39a68a2e..68652dd0b6 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PkiSubscribersPage } from "./PkiSubscribersPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/" )({ component: PkiSubscribersPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx index 8277d33454..adb0541499 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx @@ -53,7 +53,7 @@ type Props = { }; export const PkiSyncActionTriggers = ({ pkiSync }: Props) => { - const { destination, subscriberId, projectId, id } = pkiSync; + const { destination, projectId, id } = pkiSync; const navigate = useNavigate(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ @@ -112,7 +112,8 @@ export const PkiSyncActionTriggers = ({ pkiSync }: Props) => { }, [updatePkiSyncMutation, id, projectId, pkiSync.isAutoSyncEnabled]); const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx index b453c4d798..761f52cddd 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx @@ -32,6 +32,7 @@ import { import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { useListPkiSyncCertificates, useRemoveCertificatesFromPkiSync } from "@app/hooks/api"; import { CertificateSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; @@ -84,8 +85,11 @@ export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => { const totalCount = data?.totalCount || 0; const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: pkiSync.subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); const handleRemoveCertificate = async (certificateId: string) => { diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx index c59d2df6dd..651b034035 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx @@ -31,7 +31,7 @@ type Props = { }; export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props) => { - const { destination, subscriberId } = pkiSync; + const { destination } = pkiSync; const destinationDetails = PKI_SYNC_MAP[destination]; @@ -55,7 +55,8 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props) } const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: subscriberId || "" + subscriberName: destinationDetails.name, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx index a3d95d5c3f..5ebfc659b5 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx @@ -10,6 +10,7 @@ import { PkiSyncStatusBadge } from "@app/components/pki-syncs"; import { IconButton } from "@app/components/v2"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; const GenericFieldLabel = ({ @@ -33,8 +34,7 @@ type Props = { }; export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => { - const { syncStatus, lastSyncMessage, lastSyncedAt, name, description, subscriberId, subscriber } = - pkiSync; + const { syncStatus, lastSyncMessage, lastSyncedAt, name, description, subscriber } = pkiSync; const failureMessage = useMemo(() => { if (syncStatus === PkiSyncStatus.Failed) { @@ -50,8 +50,11 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => { return null; }, [syncStatus, lastSyncMessage]); + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: subscriber?.id || subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncFieldMappingsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncFieldMappingsSection.tsx index 66f0ea0761..41745bc867 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncFieldMappingsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncFieldMappingsSection.tsx @@ -7,6 +7,7 @@ import { IconButton } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; const GenericFieldLabel = ({ @@ -35,9 +36,11 @@ export const PkiSyncFieldMappingsSection = ({ pkiSync, onEditMappings }: Props) } const fieldMappings = pkiSync.syncOptions?.fieldMappings; + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: pkiSync.subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx index e9a509ebbe..891b4c3f0b 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx @@ -7,6 +7,7 @@ import { IconButton } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { TPkiSync } from "@app/hooks/api/pkiSyncs"; const GenericFieldLabel = ({ @@ -34,8 +35,11 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => { syncOptions: { canRemoveCertificates } } = pkiSync; + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: pkiSync.subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx index c9dc041dfb..3abaee7343 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx @@ -9,6 +9,7 @@ import { IconButton, Tooltip } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { TPkiSync } from "@app/hooks/api/pkiSyncs"; const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( @@ -26,8 +27,11 @@ type Props = { export const PkiSyncSourceSection = ({ pkiSync, onEditSource }: Props) => { const { subscriberId, subscriber } = pkiSync; + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { - subscriberId: subscriberId || "" + subscriberName: destinationName, + name: pkiSync.name }); return ( diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx index 49bed6c75d..78f67cc3ac 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx @@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncDetailsByIDPage } from "./index"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId" )({ component: PkiSyncDetailsByIDPage, beforeLoad: ({ context, params }) => { @@ -15,7 +15,7 @@ export const Route = createFileRoute( { label: "Integrations", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/integrations", + to: "/organizations/$orgId/projects/cert-manager/$projectId/integrations", params, search: { selectedTab: IntegrationsListPageTabs.PkiSyncs diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx index c943ede97e..305039892e 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PkiTemplateListPage } from "./PkiTemplateListPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-templates/" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-templates/" )({ component: PkiTemplateListPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index 0653d44d04..31265b5fb2 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -2,8 +2,15 @@ import { useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { PermissionDeniedBanner } from "@app/components/permissions"; import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { useProject, useProjectPermission } from "@app/context"; +import { + ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { ProjectType } from "@app/hooks/api/projects/types"; import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; @@ -20,8 +27,22 @@ enum TabSections { export const PoliciesPage = () => { const { t } = useTranslation(); const { currentProject } = useProject(); + const { permission } = useProjectPermission(); const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles); + const canReadCertificateProfiles = permission.can( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + const canReadCertificateTemplates = permission.can( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + const canReadCertificates = permission.can( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + if (!currentProject) { return ; } @@ -29,12 +50,12 @@ export const PoliciesPage = () => { return (
- {t("common.head-title", { title: "Certificate Management" })} + {t("common.head-title", { title: "Certificate Manager" })}
@@ -56,15 +77,19 @@ export const PoliciesPage = () => { - + {canReadCertificateProfiles ? : } - + {canReadCertificateTemplates ? ( + + ) : ( + + )} - + {canReadCertificates ? : }
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx index 39bb3c4e9a..9807cb98fe 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -4,10 +4,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { useProjectPermission } from "@app/context"; import { - ProjectPermissionActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; @@ -21,8 +21,6 @@ import { ProfileList } from "./ProfileList"; import { RevealAcmeEabSecretModal } from "./RevealAcmeEabSecretModal"; export const CertificateProfilesTab = () => { - const { permission } = useProjectPermission(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -35,11 +33,6 @@ export const CertificateProfilesTab = () => { const deleteProfile = useDeleteCertificateProfile(); - const canCreateProfile = permission.can( - ProjectPermissionActions.Create, - ProjectPermissionSub.CertificateAuthorities - ); - const handleCreateProfile = () => { setIsCreateModalOpen(true); }; @@ -84,16 +77,22 @@ export const CertificateProfilesTab = () => {

- {canCreateProfile && ( - - )} + + {(isAllowed) => ( + + )} +
{ @@ -212,7 +223,16 @@ const editSchema = z renewBeforeDays: z.number().min(1).max(365).optional() }) .optional(), - acmeConfig: z.object({}).optional() + acmeConfig: z + .object({ + skipDnsOwnershipVerification: z.boolean().optional() + }) + .optional(), + externalConfigs: z + .object({ + template: z.string().optional() + }) + .optional() }) .refine( (data) => { @@ -339,7 +359,7 @@ export const CreateProfileModal = ({ const { currentProject } = useProject(); const { subscription } = useSubscription(); - const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: allCaData } = useListCasByProjectId(currentProject?.id || ""); const { data: templateData } = useListCertificateTemplatesV2({ projectId: currentProject?.id || "", limit: 100, @@ -351,9 +371,23 @@ export const CreateProfileModal = ({ const isEdit = mode === "edit" && profile; - const certificateAuthorities = caData || []; + const certificateAuthorities = (allCaData || []).map((ca) => ({ + ...ca, + groupType: ca.type === "internal" ? "internal" : "external" + })); const certificateTemplates = templateData?.certificateTemplates || []; + const getGroupHeaderLabel = (groupType: "internal" | "external") => { + switch (groupType) { + case "internal": + return "Internal CAs"; + case "external": + return "External CAs"; + default: + return ""; + } + }; + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(isEdit ? editSchema : createSchema), defaultValues: isEdit @@ -380,7 +414,23 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, + externalConfigs: profile.externalConfigs + ? { + template: + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ? profile.externalConfigs.template + : "" + } + : undefined } : { slug: "", @@ -393,15 +443,30 @@ export const CreateProfileModal = ({ autoRenew: false, renewBeforeDays: 30 }, - acmeConfig: {} + acmeConfig: { + skipDnsOwnershipVerification: false + }, + externalConfigs: undefined } }); const watchedEnrollmentType = watch("enrollmentType"); const watchedIssuerType = watch("issuerType"); + const watchedCertificateAuthorityId = watch("certificateAuthorityId"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedAutoRenew = watch("apiConfig.autoRenew"); + // Get the selected CA to check if it's Azure ADCS + const selectedCa = certificateAuthorities.find((ca) => ca.id === watchedCertificateAuthorityId); + const isAzureAdcsCa = selectedCa?.type === CaType.AZURE_AD_CS; + + // Fetch Azure ADCS templates if needed + const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({ + caId: watchedCertificateAuthorityId || "", + projectId: currentProject?.id || "", + isAzureAdcsCa + }); + useEffect(() => { if (isEdit && profile) { reset({ @@ -427,10 +492,44 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined + acmeConfig: + profile.enrollmentType === EnrollmentType.ACME + ? { + skipDnsOwnershipVerification: + profile.acmeConfig?.skipDnsOwnershipVerification || false + } + : undefined, + externalConfigs: profile.externalConfigs + ? { + template: + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ? profile.externalConfigs.template + : "" + } + : undefined }); } - }, [isEdit, profile, reset]); + }, [isEdit, profile, reset, allCaData]); + + // Additional effect to reset external configs when Azure ADCS templates are loaded + useEffect(() => { + if ( + isEdit && + profile && + isAzureAdcsCa && + azureAdcsTemplatesData?.templates && + profile.externalConfigs && + typeof profile.externalConfigs === "object" && + profile.externalConfigs !== null && + typeof profile.externalConfigs.template === "string" + ) { + // Re-set the external configs to ensure the template value is properly set + // after the Azure ADCS templates have been loaded + setValue("externalConfigs.template", profile.externalConfigs.template); + } + }, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]); const onFormSubmit = async (data: FormData) => { if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { @@ -444,6 +543,18 @@ export const CreateProfileModal = ({ if (!currentProject?.id && !isEdit) return; + // Validate Azure ADCS template requirement + if ( + isAzureAdcsCa && + (!data.externalConfigs?.template || data.externalConfigs.template.trim() === "") + ) { + createNotification({ + text: "Azure ADCS Certificate Authority requires a template to be specified", + type: "error" + }); + return; + } + if (isEdit) { const updateData: TUpdateCertificateProfileDTO = { profileId: profile.id, @@ -460,6 +571,11 @@ export const CreateProfileModal = ({ updateData.acmeConfig = data.acmeConfig; } + // Add external configs if present + if (data.externalConfigs) { + updateData.externalConfigs = data.externalConfigs; + } + await updateProfile.mutateAsync(updateData); } else { if (!currentProject?.id) { @@ -491,6 +607,11 @@ export const CreateProfileModal = ({ createData.acmeConfig = data.acmeConfig; } + // Add external configs if present + if (data.externalConfigs) { + createData.externalConfigs = data.externalConfigs; + } + await createProfile.mutateAsync(createData); } @@ -568,7 +689,9 @@ export const CreateProfileModal = ({ renewBeforeDays: 30 }); setValue("estConfig", undefined); - setValue("acmeConfig", undefined); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -587,30 +710,82 @@ export const CreateProfileModal = ({ ( + render={({ field: { onChange, value }, fieldState: { error } }) => ( - + className="w-full" + /> + + )} + /> + )} + + {/* Azure ADCS Template Selection */} + {isAzureAdcsCa && ( + ( + + template.id === value) || + null + } + onChange={(selectedTemplate) => { + if (Array.isArray(selectedTemplate)) { + onChange(selectedTemplate[0]?.id || ""); + } else if ( + selectedTemplate && + typeof selectedTemplate === "object" && + "id" in selectedTemplate + ) { + onChange(selectedTemplate.id || ""); + } else { + onChange(""); + } + }} + getOptionLabel={(template) => template.name} + getOptionValue={(template) => template.id} + options={azureAdcsTemplatesData?.templates || []} + placeholder="Select an Azure ADCS certificate template" + className="w-full" + /> )} /> @@ -646,7 +821,9 @@ export const CreateProfileModal = ({ } else if (watchedEnrollmentType === "acme") { setValue("estConfig", undefined); setValue("apiConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -695,7 +872,9 @@ export const CreateProfileModal = ({ } else if (value === "acme") { setValue("apiConfig", undefined); setValue("estConfig", undefined); - setValue("acmeConfig", {}); + setValue("acmeConfig", { + skipDnsOwnershipVerification: false + }); } onChange(value); }} @@ -824,10 +1003,24 @@ export const CreateProfileModal = ({
( + name="acmeConfig.skipDnsOwnershipVerification" + render={({ field: { value, onChange }, fieldState: { error } }) => ( -
{/* FIXME: ACME configuration */}
+
+ +
+ + Skip DNS Ownership Validation + +

+ Skip DNS ownership verification during ACME certificate issuance. +

+
+
)} /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index f8fc8090ba..5834feea35 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -1,4 +1,6 @@ +/* eslint-disable no-nested-ternary */ import { useCallback } from "react"; +import { subject } from "@casl/ability"; import { faCheck, faCircleInfo, @@ -12,6 +14,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { DropdownMenu, DropdownMenuContent, @@ -22,9 +25,7 @@ import { Tr } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { useProjectPermission } from "@app/context"; import { - ProjectPermissionActions, ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; @@ -47,8 +48,6 @@ export const ProfileRow = ({ onRevealProfileAcmeEabSecret, onDeleteProfile }: Props) => { - const { permission } = useProjectPermission(); - const { data: caData } = useGetInternalCaById(profile.caId ?? ""); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const); @@ -71,26 +70,6 @@ export const ProfileRow = ({ templateId: profile.certificateTemplateId }); - const canEditProfile = permission.can( - ProjectPermissionActions.Edit, - ProjectPermissionSub.CertificateAuthorities - ); - - const canRevealProfileAcmeEabSecret = permission.can( - ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, - ProjectPermissionSub.CertificateProfiles - ); - - const canIssueCertificate = permission.can( - ProjectPermissionCertificateProfileActions.IssueCert, - ProjectPermissionSub.CertificateProfiles - ); - - const canDeleteProfile = permission.can( - ProjectPermissionActions.Delete, - ProjectPermissionSub.CertificateAuthorities - ); - const getEnrollmentTypeBadge = (enrollmentType: string) => { const config = { api: { variant: "ghost" as const, label: "API" }, @@ -123,9 +102,11 @@ export const ProfileRow = ({ {profile.issuerType === IssuerType.SELF_SIGNED ? "Self-signed" - : caData?.configuration.friendlyName || - caData?.configuration.commonName || - profile.caId} + : profile.certificateAuthority?.isExternal + ? profile.certificateAuthority.name + : caData?.configuration.friendlyName || + caData?.configuration.commonName || + profile.caId}
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/route.tsx b/frontend/src/pages/cert-manager/PoliciesPage/route.tsx index 807d69bb9a..70794f2674 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/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/policies" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$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 81eb4e170f..0a6111cce3 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx @@ -18,7 +18,7 @@ const tabs = [ export const SettingsPage = () => { const { t } = useTranslation(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); return (
@@ -34,7 +34,8 @@ export const SettingsPage = () => { }} className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline" > - Looking for organization settings? + Looking for {isSubOrganization ? "sub-" : ""}organization + settings? diff --git a/frontend/src/pages/cert-manager/SettingsPage/route.tsx b/frontend/src/pages/cert-manager/SettingsPage/route.tsx index 59eccb028f..4f205481a3 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/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/settings" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$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 0462c230dc..463fc5c30e 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/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout" )({ component: PkiManagerLayout, beforeLoad: async ({ params, context }) => { diff --git a/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx b/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx index 3a0771302f..857f8d4461 100644 --- a/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/kms/SettingsPage/SettingsPage.tsx @@ -19,7 +19,7 @@ const tabs = [ export const SettingsPage = () => { const { t } = useTranslation(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); return (
@@ -39,7 +39,8 @@ export const SettingsPage = () => { }} className="flex items-center gap-x-1.5 text-xs whitespace-nowrap text-neutral hover:underline" > - Looking for organization settings? + Looking for {isSubOrganization ? "sub-" : ""}organization + settings? diff --git a/frontend/src/pages/middlewares/authenticate.tsx b/frontend/src/pages/middlewares/authenticate.tsx index 3998dde637..cfc531f0d5 100644 --- a/frontend/src/pages/middlewares/authenticate.tsx +++ b/frontend/src/pages/middlewares/authenticate.tsx @@ -73,6 +73,11 @@ export const Route = createFileRoute("/_authenticate")({ }); }); - return { organizationId: data.organizationId as string, isAuthenticated: true, user }; + const isSubOrganization = !!data.subOrganizationId; + return { + organizationId: isSubOrganization ? data.subOrganizationId : (data.organizationId as string), + isAuthenticated: true, + user + }; } }); diff --git a/frontend/src/pages/middlewares/inject-org-details.tsx b/frontend/src/pages/middlewares/inject-org-details.tsx index 35d60a5378..6987181847 100644 --- a/frontend/src/pages/middlewares/inject-org-details.tsx +++ b/frontend/src/pages/middlewares/inject-org-details.tsx @@ -1,7 +1,12 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, isRedirect, redirect } from "@tanstack/react-router"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { SessionStorageKeys } from "@app/const"; +import { authKeys, fetchAuthToken, selectOrganization } from "@app/hooks/api/auth/queries"; import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries"; +import { projectKeys } from "@app/hooks/api/projects"; import { fetchUserOrgPermissions, roleQueryKeys } from "@app/hooks/api/roles/queries"; +import { subOrganizationsQuery } from "@app/hooks/api/subOrganizations"; import { fetchOrgSubscription, subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries"; // Route context to fill in organization's data like details, subscription etc @@ -15,6 +20,44 @@ export const Route = createFileRoute("/_authenticate/_inject-org-details")({ organizationId = context.organizationId!; } + if ((params as { orgId?: string })?.orgId && context.organizationId) { + const urlOrgId = (params as { orgId: string }).orgId; + const currentTokenOrgId = context.organizationId; + + if (urlOrgId !== currentTokenOrgId) { + try { + const { token, isMfaEnabled } = await selectOrganization({ organizationId: urlOrgId }); + + if (isMfaEnabled) { + sessionStorage.setItem(SessionStorageKeys.MFA_TEMP_TOKEN, token); + throw redirect({ + to: "/login/select-organization", + search: { org_id: urlOrgId, mfa_pending: true } + }); + } + + if (!isMfaEnabled && token) { + SecurityClient.setToken(token); + SecurityClient.setProviderAuthToken(""); + + context.queryClient.removeQueries({ queryKey: authKeys.getAuthToken }); + context.queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); + context.queryClient.removeQueries({ queryKey: subOrganizationsQuery.allKey() }); + + await context.queryClient.fetchQuery({ + queryKey: authKeys.getAuthToken, + queryFn: fetchAuthToken + }); + } + } catch (error) { + if (isRedirect(error)) { + throw error; + } + console.warn("Failed to automatically exchange token for organization:", error); + } + } + } + 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 ec1958d14f..582b6331b7 100644 --- a/frontend/src/pages/middlewares/restrict-login-signup.tsx +++ b/frontend/src/pages/middlewares/restrict-login-signup.tsx @@ -117,9 +117,10 @@ export const Route = createFileRoute("/_restrict-login-signup")({ return; throw redirect({ to: "/login/select-organization" }); } + const orgId = data.subOrganizationId || data.organizationId; throw redirect({ to: "/organizations/$orgId/projects", - params: { orgId: data.organizationId } + params: { orgId } }); }, component: AuthConsentWrapper diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index 0d5456f6d6..cde27cb3c9 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -3,7 +3,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { OrgPermissionGuardBanner } from "@app/components/permissions/OrgPermissionCan"; import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; @@ -84,8 +85,20 @@ export const AccessManagementPage = () => { + description={`Manage fine-grained access for users, groups, roles, and machine identities within your ${isSubOrganization ? "sub-" : ""}organization resources.`} + > + {isSubOrganization && ( + + Looking for root organization access control? + + )} + {!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 2f66c6644f..4d6a45da3a 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 @@ -6,7 +6,12 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; import { DocumentationLinkBadge } from "@app/components/v3"; -import { OrgPermissionGroupActions, OrgPermissionSubjects, useSubscription } from "@app/context"; +import { + OrgPermissionGroupActions, + OrgPermissionSubjects, + useOrganization, + useSubscription +} from "@app/context"; import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -15,6 +20,7 @@ import { OrgGroupsTable } from "./OrgGroupsTable"; export const OrgGroupsSection = () => { const { subscription } = useSubscription(); + const { isSubOrganization } = useOrganization(); const { mutateAsync: deleteMutateAsync } = useDeleteGroup(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -51,7 +57,9 @@ export const OrgGroupsSection = () => {
-

Organization Groups

+

+ {isSubOrganization ? "Sub-" : ""}Organization Groups +

@@ -63,7 +71,7 @@ export const OrgGroupsSection = () => { onClick={() => handleAddGroupModal()} isDisabled={!isAllowed} > - Create Organization Group + Create {isSubOrganization ? "Sub-" : ""}Organization Group )} 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 e0cd09d83c..3f77466a3e 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 @@ -71,7 +71,7 @@ enum GroupsOrderBy { export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { const navigate = useNavigate(); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id || ""; const { isPending, data: groups = [] } = useGetOrganizationGroups(orgId); const { mutateAsync: updateMutateAsync } = useUpdateGroup(); @@ -159,7 +159,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search organization groups..." + placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization groups...`} />
[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ); +} + +function UnstableTableCell({ className, ...props }: React.ComponentProps<"td">) { + return ( + [role=checkbox]]:translate-y-[2px]", + className + )} + {...props} + /> + ); +} + +function UnstableTableCaption({ className, ...props }: React.ComponentProps<"caption">) { + return ( +
+ ); +} + +export { + UnstableTable, + UnstableTableBody, + UnstableTableCaption, + UnstableTableCell, + UnstableTableFooter, + UnstableTableHead, + UnstableTableHeader, + UnstableTableRow +}; diff --git a/frontend/src/components/v3/generic/Table/index.ts b/frontend/src/components/v3/generic/Table/index.ts new file mode 100644 index 0000000000..e40efa4761 --- /dev/null +++ b/frontend/src/components/v3/generic/Table/index.ts @@ -0,0 +1 @@ +export * from "./Table"; diff --git a/frontend/src/components/v3/generic/index.ts b/frontend/src/components/v3/generic/index.ts index ae21190ba6..43ddf08b0a 100644 --- a/frontend/src/components/v3/generic/index.ts +++ b/frontend/src/components/v3/generic/index.ts @@ -1 +1,13 @@ +export * from "./Accordion"; +export * from "./Alert"; export * from "./Badge"; +export * from "./Button"; +export * from "./ButtonGroup"; +export * from "./Card"; +export * from "./Detail"; +export * from "./Dropdown"; +export * from "./Empty"; +export * from "./IconButton"; +export * from "./PageLoader"; +export * from "./Separator"; +export * from "./Table"; diff --git a/frontend/src/components/v3/platform/ScopeIcons.tsx b/frontend/src/components/v3/platform/ScopeIcons.tsx index 8f87123197..9f21ce2502 100644 --- a/frontend/src/components/v3/platform/ScopeIcons.tsx +++ b/frontend/src/components/v3/platform/ScopeIcons.tsx @@ -1,8 +1,8 @@ import { BoxesIcon, BoxIcon, Building2Icon, ServerIcon } from "lucide-react"; -const InstanceIcon = ServerIcon; -const OrgIcon = Building2Icon; -const SubOrgIcon = BoxesIcon; -const ProjectIcon = BoxIcon; - -export { InstanceIcon, OrgIcon, ProjectIcon, SubOrgIcon }; +export { + ServerIcon as InstanceIcon, + Building2Icon as OrgIcon, + BoxIcon as ProjectIcon, + BoxesIcon as SubOrgIcon +}; diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index 16b8b4f45d..a37b38cb6b 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -24,8 +24,6 @@ apiRequest.interceptors.request.use((config) => { const token = getAuthToken(); const providerAuthToken = SecurityClient.getProviderAuthToken(); - const params = new URLSearchParams(window.location.search); - if (config.headers) { if (signupTempToken) { // eslint-disable-next-line no-param-reassign @@ -40,17 +38,6 @@ apiRequest.interceptors.request.use((config) => { // eslint-disable-next-line no-param-reassign config.headers.Authorization = `Bearer ${providerAuthToken}`; } - - const rootOrgHeader = config.headers.get("x-root-org"); - - if (rootOrgHeader) { - config.headers.delete("x-root-org"); - } else { - const subOrganization = params.get("subOrganization"); - if (subOrganization) { - config.headers.set("x-infisical-org", subOrganization); - } - } } return config; diff --git a/frontend/src/const.ts b/frontend/src/const.ts index af52b68af6..806755f380 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -61,7 +61,8 @@ export const leaveConfirmDefaultMessage = export enum SessionStorageKeys { CLI_TERMINAL_TOKEN = "CLI_TERMINAL_TOKEN", ORG_LOGIN_SUCCESS_REDIRECT_URL = "ORG_LOGIN_SUCCESS_REDIRECT_URL", - AUTH_CONSENT = "AUTH_CONSENT" + AUTH_CONSENT = "AUTH_CONSENT", + MFA_TEMP_TOKEN = "MFA_TEMP_TOKEN" } export const secretTagsColors = [ diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 6046c8fc8e..d10913c84f 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -294,36 +294,36 @@ export const ROUTE_PATHS = Object.freeze({ }, CertManager: { CertAuthDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/ca/$caId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId" + "/organizations/$orgId/projects/cert-manager/$projectId/ca/$caId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/ca/$caId" ), SubscribersPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/subscribers", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers" + "/organizations/$orgId/projects/cert-manager/$projectId/subscribers", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers" ), CertificateAuthoritiesPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities" + "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/certificate-authorities" ), AlertingPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/alerting", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting" + "/organizations/$orgId/projects/cert-manager/$projectId/alerting", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting" ), PkiCollectionDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/pki-collections/$collectionId" + "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/pki-collections/$collectionId" ), PkiSubscriberDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/subscribers/$subscriberName", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/subscribers/$subscriberName" + "/organizations/$orgId/projects/cert-manager/$projectId/subscribers/$subscriberName", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/subscribers/$subscriberName" ), IntegrationsListPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/integrations", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/" + "/organizations/$orgId/projects/cert-manager/$projectId/integrations", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/" ), PkiSyncDetailsByIDPage: setRoute( - "/organizations/$orgId/projects/cert-management/$projectId/integrations/$syncId", - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/integrations/$syncId" + "/organizations/$orgId/projects/cert-manager/$projectId/integrations/$syncId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/integrations/$syncId" ) }, Ssh: { @@ -359,6 +359,10 @@ export const ROUTE_PATHS = Object.freeze({ "/organizations/$orgId/projects/pam/$projectId/sessions", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/" ), + ApprovalRequestDetailPage: setRoute( + "/organizations/$orgId/projects/pam/$projectId/approval-requests/$approvalRequestId", + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/approval-requests/$approvalRequestId" + ), PamSessionByIDPage: setRoute( "/organizations/$orgId/projects/pam/$projectId/sessions/$sessionId", "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/pam/$projectId/_pam-layout/sessions/$sessionId" diff --git a/frontend/src/context/OrganizationContext/OrganizationContext.tsx b/frontend/src/context/OrganizationContext/OrganizationContext.tsx index bfc17d1430..ef5edcc0fe 100644 --- a/frontend/src/context/OrganizationContext/OrganizationContext.tsx +++ b/frontend/src/context/OrganizationContext/OrganizationContext.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useSuspenseQuery } from "@tanstack/react-query"; -import { useRouteContext, useSearch } from "@tanstack/react-router"; +import { useRouteContext } from "@tanstack/react-router"; import { fetchOrganizationById, organizationKeys } from "@app/hooks/api/organization/queries"; @@ -10,28 +10,23 @@ export const useOrganization = () => { select: (el) => el.organizationId }); - const subOrganization = useSearch({ - strict: false, - select: (el) => el?.subOrganization - }); - const { data: currentOrg } = useSuspenseQuery({ - queryKey: organizationKeys.getOrgById(organizationId, subOrganization || "root"), + queryKey: organizationKeys.getOrgById(organizationId), queryFn: () => fetchOrganizationById(organizationId), staleTime: Infinity }); + const isSubOrganization = currentOrg.id !== currentOrg.rootOrgId && Boolean(currentOrg.rootOrgId); const org = useMemo( () => ({ currentOrg: { ...currentOrg, - id: currentOrg?.subOrganization?.id || currentOrg?.id, - parentOrgId: currentOrg.id + parentOrgId: isSubOrganization ? currentOrg?.parentOrgId : null }, - isSubOrganization: Boolean(currentOrg.subOrganization), - isRootOrganization: !currentOrg.subOrganization + isSubOrganization, + isRootOrganization: !isSubOrganization }), - [currentOrg, subOrganization] + [currentOrg] ); return org; diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index 415a826417..9eb548c071 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -4,6 +4,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, @@ -12,6 +13,7 @@ export { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSshHostActions, ProjectPermissionSub diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index a35451cd3b..0838b0c36c 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -12,7 +12,9 @@ export enum ProjectPermissionCertificateActions { Create = "create", Edit = "edit", Delete = "delete", - ReadPrivateKey = "read-private-key" + List = "list", + ReadPrivateKey = "read-private-key", + Import = "import" } export enum ProjectPermissionSecretActions { @@ -67,6 +69,7 @@ export enum ProjectPermissionPkiSyncActions { Create = "create", Edit = "edit", Delete = "delete", + List = "list", SyncCertificates = "sync-certificates", ImportCertificates = "import-certificates", RemoveCertificates = "remove-certificates" @@ -128,13 +131,25 @@ export enum ProjectPermissionPkiTemplateActions { ListCerts = "list-certs" } -export enum ProjectPermissionCertificateProfileActions { +export enum ProjectPermissionCertificateAuthorityActions { Read = "read", Create = "create", Edit = "edit", Delete = "delete", + List = "list", + Renew = "renew", + SignIntermediate = "sign-intermediate" +} + +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + List = "list", + Create = "create", + Edit = "edit", + Delete = "delete", IssueCert = "issue-cert", - RevealAcmeEabSecret = "reveal-acme-eab-secret" + RevealAcmeEabSecret = "reveal-acme-eab-secret", + RotateAcmeEabSecret = "rotate-acme-eab-secret" } export enum ProjectPermissionSecretRotationActions { @@ -213,6 +228,16 @@ export enum ProjectPermissionPamSessionActions { // Terminate = "terminate" } +export enum ProjectPermissionApprovalRequestActions { + Read = "read", + Create = "create" +} + +export enum ProjectPermissionApprovalRequestGrantActions { + Read = "read", + Revoke = "revoke" +} + export type IdentityManagementSubjectFields = { identityId: string; }; @@ -230,6 +255,9 @@ export type ConditionalProjectPermissionSubject = | ProjectPermissionSub.SshHosts | ProjectPermissionSub.PkiSubscribers | ProjectPermissionSub.CertificateTemplates + | ProjectPermissionSub.CertificateAuthorities + | ProjectPermissionSub.Certificates + | ProjectPermissionSub.CertificateProfiles | ProjectPermissionSub.SecretFolders | ProjectPermissionSub.SecretImports | ProjectPermissionSub.SecretRotation @@ -321,7 +349,9 @@ export enum ProjectPermissionSub { PamFolders = "pam-folders", PamResources = "pam-resources", PamAccounts = "pam-accounts", - PamSessions = "pam-sessions" + PamSessions = "pam-sessions", + ApprovalRequests = "approval-requests", + ApprovalRequestGrants = "approval-request-grants" } export type SecretSubjectFields = { @@ -361,7 +391,22 @@ export type SecretSyncSubjectFields = { }; export type PkiSyncSubjectFields = { - subscriberId: string; + subscriberName: string; + name: string; +}; + +export type CertificateAuthoritySubjectFields = { + name: string; +}; + +export type CertificateSubjectFields = { + commonName?: string; + altNames?: string; + serialNumber?: string; +}; + +export type CertificateProfileSubjectFields = { + slug: string; }; export type SecretRotationSubjectFields = { @@ -457,8 +502,21 @@ export type ProjectPermissionSet = | (ForcedSubject & IdentityManagementSubjectFields) ) ] - | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] - | [ProjectPermissionCertificateActions, ProjectPermissionSub.Certificates] + | [ + ProjectPermissionCertificateAuthorityActions, + ( + | ProjectPermissionSub.CertificateAuthorities + | (ForcedSubject & + CertificateAuthoritySubjectFields) + ) + ] + | [ + ProjectPermissionCertificateActions, + ( + | ProjectPermissionSub.Certificates + | (ForcedSubject & CertificateSubjectFields) + ) + ] | [ ProjectPermissionPkiTemplateActions, ( @@ -484,7 +542,14 @@ export type ProjectPermissionSet = | (ForcedSubject & PkiSubscriberSubjectFields) ) ] - | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles] + | [ + ProjectPermissionCertificateProfileActions, + ( + | ProjectPermissionSub.CertificateProfiles + | (ForcedSubject & + CertificateProfileSubjectFields) + ) + ] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] @@ -524,6 +589,8 @@ export type ProjectPermissionSet = | (ForcedSubject & PamAccountSubjectFields) ) ] - | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] + | [ProjectPermissionApprovalRequestActions, ProjectPermissionSub.ApprovalRequests] + | [ProjectPermissionApprovalRequestGrantActions, ProjectPermissionSub.ApprovalRequestGrants]; export type TProjectPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 3e0f958072..81feaf8e58 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -15,6 +15,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, @@ -23,6 +24,7 @@ export { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSshHostActions, ProjectPermissionSub, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 7f02bd64eb..e8857e0225 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -31,6 +31,7 @@ import { HCVaultConnectionMethod, HumanitecConnectionMethod, LdapConnectionMethod, + MongoDBConnectionMethod, MsSqlConnectionMethod, MySqlConnectionMethod, OktaConnectionMethod, @@ -129,6 +130,7 @@ export const APP_CONNECTION_MAP: Record< [AppConnection.Northflank]: { name: "Northflank", image: "Northflank.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, + [AppConnection.MongoDB]: { name: "MongoDB", image: "MongoDB.png" }, [AppConnection.LaravelForge]: { name: "Laravel Forge", image: "Laravel Forge.png", @@ -181,6 +183,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case OracleDBConnectionMethod.UsernameAndPassword: case AzureADCSConnectionMethod.UsernamePassword: case RedisConnectionMethod.UsernameAndPassword: + case MongoDBConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 128dd31cea..554d09655b 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -61,7 +61,7 @@ export const getProjectBaseURL = (type: ProjectType) => { case ProjectType.SecretManager: return "/organizations/$orgId/projects/secret-management/$projectId"; case ProjectType.CertificateManager: - return "/organizations/$orgId/projects/cert-management/$projectId"; + return "/organizations/$orgId/projects/cert-manager/$projectId"; default: return `/organizations/$orgId/projects/${type}/$projectId` as const; } @@ -74,7 +74,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] case ProjectType.SecretManager: return "/organizations/$orgId/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/organizations/$orgId/projects/cert-management/$projectId/policies" as const; + return "/organizations/$orgId/projects/cert-manager/$projectId/policies" as const; case ProjectType.SecretScanning: return `/organizations/$orgId/projects/${type}/$projectId/data-sources` as const; case ProjectType.PAM: @@ -88,7 +88,7 @@ export const getProjectTitle = (type: ProjectType) => { const titleConvert = { [ProjectType.SecretManager]: "Secrets Management", [ProjectType.KMS]: "Key Management", - [ProjectType.CertificateManager]: "Cert Management", + [ProjectType.CertificateManager]: "Certificate Manager", [ProjectType.SSH]: "SSH", [ProjectType.SecretScanning]: "Secret Scanning", [ProjectType.PAM]: "PAM" diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index d3bb83f19d..187177c68e 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -54,6 +54,11 @@ export const SECRET_ROTATION_MAP: Record< name: "Redis Credentials", image: "Redis.png", size: 50 + }, + [SecretRotation.MongoDBCredentials]: { + name: "MongoDB Credentials", + image: "MongoDB.png", + size: 50 } }; @@ -67,7 +72,8 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.LdapPassword]: false, [SecretRotation.AwsIamUserSecret]: true, [SecretRotation.OktaClientSecret]: true, - [SecretRotation.RedisCredentials]: true + [SecretRotation.RedisCredentials]: true, + [SecretRotation.MongoDBCredentials]: true }; export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => { diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 9535e8348f..dbe6c73675 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -40,6 +40,7 @@ export enum AppConnection { Northflank = "northflank", Okta = "okta", Redis = "redis", + MongoDB = "mongodb", LaravelForge = "laravel-forge", Chef = "chef" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index d1c991f348..9c0f78a0c0 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -184,6 +184,10 @@ export type TRedisConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Redis; }; +export type TMongoDBConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.MongoDB; +}; + export type TDNSMadeEasyConnectionOption = TAppConnectionOptionBase & { app: AppConnection.DNSMadeEasy; }; @@ -229,6 +233,8 @@ export type TAppConnectionOption = | TOktaConnectionOption | TAzureAdCsConnectionOption | TLaravelForgeConnectionOption + | TRedisConnectionOption + | TMongoDBConnectionOption | TChefConnectionOption | TDNSMadeEasyConnectionOption; @@ -274,6 +280,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; + [AppConnection.MongoDB]: TMongoDBConnectionOption; [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; [AppConnection.Chef]: TChefConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index a272b48cd8..c78d2aed2c 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -26,6 +26,7 @@ import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TLaravelForgeConnection } from "./laravel-forge-connection"; import { TLdapConnection } from "./ldap-connection"; +import { TMongoDBConnection } from "./mongodb-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; import { TNetlifyConnection } from "./netlify-connection"; @@ -69,6 +70,7 @@ export * from "./heroku-connection"; export * from "./humanitec-connection"; export * from "./laravel-forge-connection"; export * from "./ldap-connection"; +export * from "./mongodb-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; export * from "./netlify-connection"; @@ -129,6 +131,7 @@ export type TAppConnection = | TNorthflankConnection | TOktaConnection | TRedisConnection + | TMongoDBConnection | TChefConnection | TDNSMadeEasyConnection; diff --git a/frontend/src/hooks/api/appConnections/types/mongodb-connection.ts b/frontend/src/hooks/api/appConnections/types/mongodb-connection.ts new file mode 100644 index 0000000000..5c8e72cb52 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/mongodb-connection.ts @@ -0,0 +1,22 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum MongoDBConnectionMethod { + UsernameAndPassword = "username-and-password" +} + +export type TMongoDBConnectionCredentials = { + host: string; + port: number; + username: string; + password: string; + database: string; + tlsEnabled: boolean; + tlsRejectUnauthorized: boolean; + tlsCertificate?: string; +}; + +export type TMongoDBConnection = TRootAppConnection & { app: AppConnection.MongoDB } & { + method: MongoDBConnectionMethod.UsernameAndPassword; + credentials: TMongoDBConnectionCredentials; +}; diff --git a/frontend/src/hooks/api/approvalGrants/index.tsx b/frontend/src/hooks/api/approvalGrants/index.tsx new file mode 100644 index 0000000000..140fb4bace --- /dev/null +++ b/frontend/src/hooks/api/approvalGrants/index.tsx @@ -0,0 +1,10 @@ +export { useRevokeApprovalGrant } from "./mutations"; +export { approvalGrantQuery } from "./queries"; +export { + ApprovalGrantStatus, + type PamAccessGrantAttributes, + type TApprovalGrant, + type TGetApprovalGrantByIdDTO, + type TListApprovalGrantsDTO, + type TRevokeApprovalGrantDTO +} from "./types"; diff --git a/frontend/src/hooks/api/approvalGrants/mutations.tsx b/frontend/src/hooks/api/approvalGrants/mutations.tsx new file mode 100644 index 0000000000..b28ff45f9a --- /dev/null +++ b/frontend/src/hooks/api/approvalGrants/mutations.tsx @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { approvalGrantQuery } from "./queries"; +import { TApprovalGrant, TRevokeApprovalGrantDTO } from "./types"; + +export const useRevokeApprovalGrant = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ policyType, grantId, revocationReason }: TRevokeApprovalGrantDTO) => { + const { data } = await apiRequest.post<{ grant: TApprovalGrant }>( + `/api/v1/approval-policies/${policyType}/grants/${grantId}/revoke`, + { revocationReason } + ); + return data.grant; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalGrantQuery.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/approvalGrants/queries.tsx b/frontend/src/hooks/api/approvalGrants/queries.tsx new file mode 100644 index 0000000000..ca9334ca71 --- /dev/null +++ b/frontend/src/hooks/api/approvalGrants/queries.tsx @@ -0,0 +1,37 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TApprovalGrant, TGetApprovalGrantByIdDTO, TListApprovalGrantsDTO } from "./types"; + +export const approvalGrantQuery = { + allKey: () => ["approval-grants"] as const, + getByIdKey: (params: TGetApprovalGrantByIdDTO) => + [...approvalGrantQuery.allKey(), "by-id", params] as const, + listKey: (params: TListApprovalGrantsDTO) => + [...approvalGrantQuery.allKey(), "list", params] as const, + getById: (params: TGetApprovalGrantByIdDTO) => + queryOptions({ + queryKey: approvalGrantQuery.getByIdKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ grant: TApprovalGrant }>( + `/api/v1/approval-policies/${params.policyType}/grants/${params.grantId}` + ); + return data.grant; + } + }), + list: (params: TListApprovalGrantsDTO) => + queryOptions({ + queryKey: approvalGrantQuery.listKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ + grants: TApprovalGrant[]; + }>(`/api/v1/approval-policies/${params.policyType}/grants`, { + params: { + projectId: params.projectId + } + }); + return data.grants; + } + }) +}; diff --git a/frontend/src/hooks/api/approvalGrants/types.ts b/frontend/src/hooks/api/approvalGrants/types.ts new file mode 100644 index 0000000000..8525292594 --- /dev/null +++ b/frontend/src/hooks/api/approvalGrants/types.ts @@ -0,0 +1,46 @@ +import { ApprovalPolicyType } from "../approvalPolicies"; + +export enum ApprovalGrantStatus { + Active = "active", + Expired = "expired", + Revoked = "revoked" +} + +// PAM Access Grant Attributes +export type PamAccessGrantAttributes = { + accountPath: string; + accessDuration: string; +}; + +// Base Grant Type +export type TApprovalGrant = { + id: string; + projectId: string; + requestId: string | null; + granteeUserId: string | null; + revokedByUserId: string | null; + revocationReason: string | null; + status: ApprovalGrantStatus; + type: ApprovalPolicyType; + attributes: PamAccessGrantAttributes; + createdAt: string; + expiresAt: string | null; + revokedAt: string | null; +}; + +// DTOs +export type TListApprovalGrantsDTO = { + policyType: ApprovalPolicyType; + projectId: string; +}; + +export type TGetApprovalGrantByIdDTO = { + policyType: ApprovalPolicyType; + grantId: string; +}; + +export type TRevokeApprovalGrantDTO = { + policyType: ApprovalPolicyType; + grantId: string; + revocationReason?: string; +}; diff --git a/frontend/src/hooks/api/approvalPolicies/index.tsx b/frontend/src/hooks/api/approvalPolicies/index.tsx new file mode 100644 index 0000000000..303a09844c --- /dev/null +++ b/frontend/src/hooks/api/approvalPolicies/index.tsx @@ -0,0 +1,19 @@ +export { + useCreateApprovalPolicy, + useDeleteApprovalPolicy, + useUpdateApprovalPolicy +} from "./mutations"; +export { approvalPolicyQuery } from "./queries"; +export { + type ApprovalPolicyStep, + ApprovalPolicyType, + ApproverType, + type PamAccessPolicyConditions, + type PamAccessPolicyConstraints, + type TApprovalPolicy, + type TCreateApprovalPolicyDTO, + type TDeleteApprovalPolicyDTO, + type TGetApprovalPolicyByIdDTO, + type TListApprovalPoliciesDTO, + type TUpdateApprovalPolicyDTO +} from "./types"; diff --git a/frontend/src/hooks/api/approvalPolicies/mutations.tsx b/frontend/src/hooks/api/approvalPolicies/mutations.tsx new file mode 100644 index 0000000000..ca9aae0fcf --- /dev/null +++ b/frontend/src/hooks/api/approvalPolicies/mutations.tsx @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { approvalPolicyQuery } from "./queries"; +import { + TApprovalPolicy, + TCreateApprovalPolicyDTO, + TDeleteApprovalPolicyDTO, + TUpdateApprovalPolicyDTO +} from "./types"; + +export const useCreateApprovalPolicy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, ...dto }: TCreateApprovalPolicyDTO) => { + const { data } = await apiRequest.post<{ policy: TApprovalPolicy }>( + `/api/v1/approval-policies/${policyType}`, + dto + ); + return data.policy; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() }); + } + }); +}; + +export const useUpdateApprovalPolicy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, policyId, ...updates }: TUpdateApprovalPolicyDTO) => { + const { data } = await apiRequest.patch<{ policy: TApprovalPolicy }>( + `/api/v1/approval-policies/${policyType}/${policyId}`, + updates + ); + return data.policy; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() }); + } + }); +}; + +export const useDeleteApprovalPolicy = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, policyId }: TDeleteApprovalPolicyDTO) => { + const { data } = await apiRequest.delete<{ policyId: string }>( + `/api/v1/approval-policies/${policyType}/${policyId}` + ); + return data.policyId; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalPolicyQuery.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/approvalPolicies/queries.tsx b/frontend/src/hooks/api/approvalPolicies/queries.tsx new file mode 100644 index 0000000000..1c524ddac9 --- /dev/null +++ b/frontend/src/hooks/api/approvalPolicies/queries.tsx @@ -0,0 +1,37 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TApprovalPolicy, TGetApprovalPolicyByIdDTO, TListApprovalPoliciesDTO } from "./types"; + +export const approvalPolicyQuery = { + allKey: () => ["approval-policies"] as const, + getByIdKey: (params: TGetApprovalPolicyByIdDTO) => + [...approvalPolicyQuery.allKey(), "by-id", params] as const, + listKey: (params: TListApprovalPoliciesDTO) => + [...approvalPolicyQuery.allKey(), "list", params] as const, + getById: (params: TGetApprovalPolicyByIdDTO) => + queryOptions({ + queryKey: approvalPolicyQuery.getByIdKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ policy: TApprovalPolicy }>( + `/api/v1/approval-policies/${params.policyType}/${params.policyId}` + ); + return data.policy; + } + }), + list: (params: TListApprovalPoliciesDTO) => + queryOptions({ + queryKey: approvalPolicyQuery.listKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ + policies: TApprovalPolicy[]; + }>(`/api/v1/approval-policies/${params.policyType}`, { + params: { + projectId: params.projectId + } + }); + return data.policies; + } + }) +}; diff --git a/frontend/src/hooks/api/approvalPolicies/types.ts b/frontend/src/hooks/api/approvalPolicies/types.ts new file mode 100644 index 0000000000..8fb461814e --- /dev/null +++ b/frontend/src/hooks/api/approvalPolicies/types.ts @@ -0,0 +1,83 @@ +export enum ApprovalPolicyType { + PamAccess = "pam-access" +} + +export enum ApproverType { + Group = "group", + User = "user" +} + +export type ApprovalPolicyStep = { + name?: string | null; + requiredApprovals: number; + notifyApprovers?: boolean; + approvers: { + type: ApproverType; + id: string; + }[]; +}; + +export type PamAccessPolicyConditions = { + accountPaths: string[]; +}[]; + +export type PamAccessPolicyConstraints = { + accessDuration: { + min: string; + max: string; + }; +}; + +export type TApprovalPolicy = { + id: string; + projectId: string; + name: string; + maxRequestTtl?: string | null; + type: ApprovalPolicyType; + conditions: { + version: number; + conditions: PamAccessPolicyConditions; + }; + constraints: { + version: number; + constraints: PamAccessPolicyConstraints; + }; + steps: ApprovalPolicyStep[]; + createdAt: string; + updatedAt: string; +}; + +export type TCreateApprovalPolicyDTO = { + policyType: ApprovalPolicyType; + projectId: string; + name: string; + maxRequestTtl?: string | null; + conditions: PamAccessPolicyConditions; + constraints: PamAccessPolicyConstraints; + steps: ApprovalPolicyStep[]; +}; + +export type TUpdateApprovalPolicyDTO = { + policyType: ApprovalPolicyType; + policyId: string; + name?: string; + maxRequestTtl?: string | null; + conditions?: PamAccessPolicyConditions; + constraints?: PamAccessPolicyConstraints; + steps?: ApprovalPolicyStep[]; +}; + +export type TGetApprovalPolicyByIdDTO = { + policyType: ApprovalPolicyType; + policyId: string; +}; + +export type TListApprovalPoliciesDTO = { + policyType: ApprovalPolicyType; + projectId: string; +}; + +export type TDeleteApprovalPolicyDTO = { + policyType: ApprovalPolicyType; + policyId: string; +}; diff --git a/frontend/src/hooks/api/approvalRequests/index.tsx b/frontend/src/hooks/api/approvalRequests/index.tsx new file mode 100644 index 0000000000..2b146836a3 --- /dev/null +++ b/frontend/src/hooks/api/approvalRequests/index.tsx @@ -0,0 +1,20 @@ +export { + useApproveApprovalRequest, + useCancelApprovalRequest, + useCreateApprovalRequest, + useRejectApprovalRequest +} from "./mutations"; +export { approvalRequestQuery } from "./queries"; +export { + type ApprovalRequestApproval, + ApprovalRequestStatus, + type ApprovalRequestStep, + ApprovalRequestStepStatus, + type PamAccessRequestData, + type TApprovalRequest, + type TApproveApprovalRequestDTO, + type TCreateApprovalRequestDTO, + type TGetApprovalRequestByIdDTO, + type TListApprovalRequestsDTO, + type TRejectApprovalRequestDTO +} from "./types"; diff --git a/frontend/src/hooks/api/approvalRequests/mutations.tsx b/frontend/src/hooks/api/approvalRequests/mutations.tsx new file mode 100644 index 0000000000..81e14b3119 --- /dev/null +++ b/frontend/src/hooks/api/approvalRequests/mutations.tsx @@ -0,0 +1,75 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { approvalRequestQuery } from "./queries"; +import { + TApprovalRequest, + TApproveApprovalRequestDTO, + TCancelApprovalRequestDTO, + TCreateApprovalRequestDTO, + TRejectApprovalRequestDTO +} from "./types"; + +export const useCreateApprovalRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, ...dto }: TCreateApprovalRequestDTO) => { + const { data } = await apiRequest.post<{ request: TApprovalRequest }>( + `/api/v1/approval-policies/${policyType}/requests`, + dto + ); + return data.request; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() }); + } + }); +}; + +export const useApproveApprovalRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, requestId, comment }: TApproveApprovalRequestDTO) => { + const { data } = await apiRequest.post<{ request: TApprovalRequest }>( + `/api/v1/approval-policies/${policyType}/requests/${requestId}/approve`, + { comment } + ); + return data.request; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() }); + } + }); +}; + +export const useRejectApprovalRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, requestId, comment }: TRejectApprovalRequestDTO) => { + const { data } = await apiRequest.post<{ request: TApprovalRequest }>( + `/api/v1/approval-policies/${policyType}/requests/${requestId}/reject`, + { comment } + ); + return data.request; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() }); + } + }); +}; + +export const useCancelApprovalRequest = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ policyType, requestId }: TCancelApprovalRequestDTO) => { + const { data } = await apiRequest.post<{ request: TApprovalRequest }>( + `/api/v1/approval-policies/${policyType}/requests/${requestId}/cancel` + ); + return data.request; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: approvalRequestQuery.allKey() }); + } + }); +}; diff --git a/frontend/src/hooks/api/approvalRequests/queries.tsx b/frontend/src/hooks/api/approvalRequests/queries.tsx new file mode 100644 index 0000000000..3b4abddcc9 --- /dev/null +++ b/frontend/src/hooks/api/approvalRequests/queries.tsx @@ -0,0 +1,37 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TApprovalRequest, TGetApprovalRequestByIdDTO, TListApprovalRequestsDTO } from "./types"; + +export const approvalRequestQuery = { + allKey: () => ["approval-requests"] as const, + getByIdKey: (params: TGetApprovalRequestByIdDTO) => + [...approvalRequestQuery.allKey(), "by-id", params] as const, + listKey: (params: TListApprovalRequestsDTO) => + [...approvalRequestQuery.allKey(), "list", params] as const, + getById: (params: TGetApprovalRequestByIdDTO) => + queryOptions({ + queryKey: approvalRequestQuery.getByIdKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ request: TApprovalRequest }>( + `/api/v1/approval-policies/${params.policyType}/requests/${params.requestId}` + ); + return data.request; + } + }), + list: (params: TListApprovalRequestsDTO) => + queryOptions({ + queryKey: approvalRequestQuery.listKey(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ + requests: TApprovalRequest[]; + }>(`/api/v1/approval-policies/${params.policyType}/requests`, { + params: { + projectId: params.projectId + } + }); + return data.requests; + } + }) +}; diff --git a/frontend/src/hooks/api/approvalRequests/types.ts b/frontend/src/hooks/api/approvalRequests/types.ts new file mode 100644 index 0000000000..2270d4988e --- /dev/null +++ b/frontend/src/hooks/api/approvalRequests/types.ts @@ -0,0 +1,110 @@ +import { ApprovalPolicyType, ApproverType } from "../approvalPolicies"; + +export enum ApprovalRequestStatus { + Pending = "pending", + Approved = "approved", + Rejected = "rejected", + Expired = "expired", + Cancelled = "cancelled" +} + +export enum ApprovalRequestStepStatus { + Pending = "pending", + InProgress = "in-progress", + Approved = "approved", + Rejected = "rejected" +} + +export enum ApprovalRequestApprovalDecision { + Approved = "approved", + Rejected = "rejected" +} + +export type ApprovalRequestApproval = { + id: string; + stepId: string; + approverUserId: string; + decision: ApprovalRequestApprovalDecision.Approved; + comment?: string | null; + createdAt: string; + updatedAt: string; +}; + +export type ApprovalRequestStep = { + id: string; + requestId: string; + name?: string | null; + requiredApprovals: number; + notifyApprovers?: boolean | null; + stepNumber: number; + status: ApprovalRequestStepStatus; + startedAt?: string | null; + completedAt?: string | null; + approvers: { + type: ApproverType; + id: string; + }[]; + approvals: ApprovalRequestApproval[]; + createdAt: string; + updatedAt: string; +}; + +export type PamAccessRequestData = { + accountPath: string; + accessDuration: string; +}; + +export type TApprovalRequest = { + id: string; + projectId: string; + policyId: string; + type: ApprovalPolicyType; + status: ApprovalRequestStatus; + requesterId: string; + requesterName: string; + requesterEmail: string; + justification?: string | null; + expiresAt?: string | null; + requestData: { + version: number; + requestData: PamAccessRequestData; + }; + steps: ApprovalRequestStep[]; + createdAt: string; + updatedAt: string; +}; + +export type TCreateApprovalRequestDTO = { + policyType: ApprovalPolicyType; + projectId: string; + justification?: string | null; + requestDuration?: string | null; + requestData: PamAccessRequestData; +}; + +export type TGetApprovalRequestByIdDTO = { + policyType: ApprovalPolicyType; + requestId: string; +}; + +export type TListApprovalRequestsDTO = { + policyType: ApprovalPolicyType; + projectId: string; +}; + +export type TApproveApprovalRequestDTO = { + policyType: ApprovalPolicyType; + requestId: string; + comment?: string; +}; + +export type TRejectApprovalRequestDTO = { + policyType: ApprovalPolicyType; + requestId: string; + comment?: string; +}; + +export type TCancelApprovalRequestDTO = { + policyType: ApprovalPolicyType; + requestId: string; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 74a1ade310..3ace3f841b 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -72,7 +72,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.SIGN_INTERMEDIATE]: "Sign intermediate", [EventType.IMPORT_CA_CERT]: "Import CA certificate", [EventType.GET_CA_CRL]: "Get CA CRL", - [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.ISSUE_CERT]: "Request certificate", [EventType.IMPORT_CERT]: "Import certificate", [EventType.GET_CERT]: "Get certificate", [EventType.DELETE_CERT]: "Delete certificate", @@ -225,7 +225,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_PKI_SUBSCRIBER]: "Update PKI subscriber", [EventType.DELETE_PKI_SUBSCRIBER]: "Delete PKI subscriber", [EventType.GET_PKI_SUBSCRIBER]: "Get PKI subscriber", - [EventType.ISSUE_PKI_SUBSCRIBER_CERT]: "Issue PKI subscriber certificate", + [EventType.ISSUE_PKI_SUBSCRIBER_CERT]: "Request PKI subscriber certificate", [EventType.SIGN_PKI_SUBSCRIBER_CERT]: "Sign PKI subscriber certificate", [EventType.AUTOMATED_RENEW_SUBSCRIBER_CERT]: "Automated renew PKI subscriber certificate", [EventType.LIST_PKI_SUBSCRIBER_CERTS]: "List PKI subscriber certificates", @@ -287,11 +287,26 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.DELETE_CERTIFICATE_PROFILE]: "Delete Certificate Profile", [EventType.GET_CERTIFICATE_PROFILE]: "Get Certificate Profile", [EventType.LIST_CERTIFICATE_PROFILES]: "List Certificate Profiles", - [EventType.ISSUE_CERTIFICATE_FROM_PROFILE]: "Issue Certificate From Profile", + [EventType.ISSUE_CERTIFICATE_FROM_PROFILE]: "Request Certificate From Profile", [EventType.SIGN_CERTIFICATE_FROM_PROFILE]: "Sign Certificate From Profile", [EventType.ORDER_CERTIFICATE_FROM_PROFILE]: "Order Certificate From Profile", [EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE]: - "Get Certificate Profile Latest Active Bundle" + "Get Certificate Profile Latest Active Bundle", + + [EventType.APPROVAL_POLICY_CREATE]: "Create Approval Policy", + [EventType.APPROVAL_POLICY_UPDATE]: "Update Approval Policy", + [EventType.APPROVAL_POLICY_DELETE]: "Delete Approval Policy", + [EventType.APPROVAL_POLICY_LIST]: "List Approval Policies", + [EventType.APPROVAL_POLICY_GET]: "Get Approval Policy", + [EventType.APPROVAL_REQUEST_GET]: "Get Approval Request", + [EventType.APPROVAL_REQUEST_LIST]: "List Approval Requests", + [EventType.APPROVAL_REQUEST_CREATE]: "Create Approval Request", + [EventType.APPROVAL_REQUEST_APPROVE]: "Approve Approval Request", + [EventType.APPROVAL_REQUEST_REJECT]: "Reject Approval Request", + [EventType.APPROVAL_REQUEST_CANCEL]: "Cancel Approval Request", + [EventType.APPROVAL_REQUEST_GRANT_LIST]: "List Approval Request Grants", + [EventType.APPROVAL_REQUEST_GRANT_GET]: "Get Approval Request Grant", + [EventType.APPROVAL_REQUEST_GRANT_REVOKE]: "Revoke Approval Request Grant" }; export const userAgentTypeToNameMap: { [K in UserAgentType]: string } = { @@ -309,7 +324,21 @@ const sharedProjectEvents = [ EventType.REMOVE_PROJECT_MEMBER, EventType.CREATE_PROJECT_ROLE, EventType.UPDATE_PROJECT_ROLE, - EventType.DELETE_PROJECT_ROLE + EventType.DELETE_PROJECT_ROLE, + EventType.APPROVAL_POLICY_CREATE, + EventType.APPROVAL_POLICY_UPDATE, + EventType.APPROVAL_POLICY_DELETE, + EventType.APPROVAL_POLICY_LIST, + EventType.APPROVAL_POLICY_GET, + EventType.APPROVAL_REQUEST_GET, + EventType.APPROVAL_REQUEST_LIST, + EventType.APPROVAL_REQUEST_CREATE, + EventType.APPROVAL_REQUEST_APPROVE, + EventType.APPROVAL_REQUEST_REJECT, + EventType.APPROVAL_REQUEST_CANCEL, + EventType.APPROVAL_REQUEST_GRANT_LIST, + EventType.APPROVAL_REQUEST_GRANT_GET, + EventType.APPROVAL_REQUEST_GRANT_REVOKE ]; export const projectToEventsMap: Partial> = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index bde306450c..72b57d1598 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -4,6 +4,8 @@ export enum ActorType { USER = "user", SERVICE = "service", IDENTITY = "identity", + ACME_PROFILE = "acmeProfile", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } @@ -282,5 +284,20 @@ export enum EventType { ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", - GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle" + GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle", + + APPROVAL_POLICY_CREATE = "approval-policy-create", + APPROVAL_POLICY_UPDATE = "approval-policy-update", + APPROVAL_POLICY_DELETE = "approval-policy-delete", + APPROVAL_POLICY_LIST = "approval-policy-list", + APPROVAL_POLICY_GET = "approval-policy-get", + APPROVAL_REQUEST_GET = "approval-request-get", + APPROVAL_REQUEST_LIST = "approval-request-list", + APPROVAL_REQUEST_CREATE = "approval-request-create", + APPROVAL_REQUEST_APPROVE = "approval-request-approve", + APPROVAL_REQUEST_REJECT = "approval-request-reject", + APPROVAL_REQUEST_CANCEL = "approval-request-cancel", + APPROVAL_REQUEST_GRANT_LIST = "approval-request-grant-list", + APPROVAL_REQUEST_GRANT_GET = "approval-request-grant-get", + APPROVAL_REQUEST_GRANT_REVOKE = "approval-request-grant-revoke" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index fc15b5064a..6e0eb22f3e 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -38,6 +38,13 @@ interface KmipClientActorMetadata { name: string; } +interface AcmeAccountActorMetadata { + profileId: string; + accountId: string; +} +interface AcmeProfileActorMetadata { + profileId: string; +} interface UserActor { type: ActorType.USER; metadata: UserActorMetadata; @@ -67,13 +74,25 @@ export interface UnknownUserActor { type: ActorType.UNKNOWN_USER; } +export interface AcmeProfileActor { + type: ActorType.ACME_PROFILE; + metadata: AcmeProfileActorMetadata; +} + +export interface AcmeAccountActor { + type: ActorType.ACME_ACCOUNT; + metadata: AcmeAccountActorMetadata; +} + export type Actor = | UserActor | ServiceActor | IdentityActor | PlatformActor | UnknownUserActor - | KmipClientActor; + | KmipClientActor + | AcmeProfileActor + | AcmeAccountActor; interface GetSecretsEvent { type: EventType.GET_SECRETS; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 207980017c..e4355ddc71 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -58,10 +58,12 @@ export const loginLDAPRedirect = async (loginLDAPDetails: LoginLDAPDTO) => { return data; }; -export const selectOrganization = async (data: { +export type SelectOrganizationParams = { organizationId: string; userAgent?: UserAgentType; -}) => { +}; + +export const selectOrganization = async (data: SelectOrganizationParams) => { const { data: res } = await apiRequest.post<{ token: string; isMfaEnabled: boolean; @@ -73,7 +75,7 @@ export const selectOrganization = async (data: { export const useSelectOrganization = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (details: { organizationId: string; userAgent?: UserAgentType }) => { + mutationFn: async (details: SelectOrganizationParams) => { const data = await selectOrganization(details); // If a custom user agent is set, then this session is meant for another consuming application, not the web application. diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index fd9d6fb5cb..d0ad865265 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -1,6 +1,7 @@ export type GetAuthTokenAPI = { token: string; organizationId?: string; + subOrganizationId?: string; }; export enum UserEncryptionVersion { diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 09e4c0f3b7..d2e1836bb8 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -189,10 +189,12 @@ export const useGetCaCertTemplates = (caId: string) => { export const useGetAzureAdcsTemplates = ({ caId, - projectId + projectId, + isAzureAdcsCa }: { caId: string; projectId: string; + isAzureAdcsCa: boolean; }) => { return useQuery({ queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), @@ -202,6 +204,6 @@ export const useGetAzureAdcsTemplates = ({ }>(`/api/v1/cert-manager/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); return data; }, - enabled: Boolean(caId && projectId) + enabled: Boolean(caId && projectId && isAzureAdcsCa) }); }; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index 176a5dd9e6..e79c384d31 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -22,14 +22,25 @@ export type TCertificateProfile = { apiConfigId?: string; createdAt: string; updatedAt: string; + externalConfigs?: Record | null; + certificateAuthority?: { + id: string; + projectId?: string; + status: string; + name: string; + isExternal?: boolean; + externalType?: string | null; + }; }; export type TCertificateProfileWithDetails = TCertificateProfile & { certificateAuthority?: { id: string; - projectId: string; + projectId?: string; status: string; name: string; + isExternal?: boolean; + externalType?: string | null; }; certificateTemplate?: { id: string; @@ -51,6 +62,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { acmeConfig?: { id: string; directoryUrl: string; + skipDnsOwnershipVerification?: boolean; }; }; @@ -72,6 +84,7 @@ export type TCreateCertificateProfileDTO = { renewBeforeDays?: number; }; acmeConfig?: unknown; + externalConfigs?: Record | null; }; export type TUpdateCertificateProfileDTO = { @@ -90,6 +103,7 @@ export type TUpdateCertificateProfileDTO = { renewBeforeDays?: number; }; acmeConfig?: unknown; + externalConfigs?: Record | null; }; export type TDeleteCertificateProfileDTO = { diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 54aa3c3b46..03605ac90e 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -13,6 +13,8 @@ import { TRenewCertificateDTO, TRenewCertificateResponse, TRevokeCertDTO, + TUnifiedCertificateIssuanceDTO, + TUnifiedCertificateIssuanceResponse, TUpdateRenewalConfigDTO } from "./types"; @@ -185,3 +187,31 @@ export const useDownloadCertPkcs12 = () => { } }); }; + +export const useUnifiedCertificateIssuance = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { projectSlug, ...requestData } = body; + const { data } = await apiRequest.post( + "/api/v1/cert-manager/certificates", + requestData + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); + queryClient.invalidateQueries({ + queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx index 50f2836edf..13245894b5 100644 --- a/frontend/src/hooks/api/certificates/queries.tsx +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -7,7 +7,11 @@ import { TCertificate } from "./types"; export const certKeys = { getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"], - getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"] + getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"], + getCertificateRequest: (requestId: string, projectSlug: string) => [ + { requestId, projectSlug }, + "certificateRequest" + ] }; export const useGetCert = (serialNumber: string) => { diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 2e9b70cae5..4d34822b32 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -64,6 +64,7 @@ export type TRenewCertificateResponse = { serialNumber: string; certificateId: string; projectId: string; + certificateRequestId?: string; }; export type TUpdateRenewalConfigDTO = { @@ -79,3 +80,59 @@ export type TDownloadPkcs12DTO = { password: string; alias: string; }; + +export type TUnifiedCertificateIssuanceDTO = { + projectSlug: string; + profileId: string; + projectId: string; + csr?: string; + attributes?: { + commonName?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + altNames?: Array<{ + type: string; + value: string; + }>; + signatureAlgorithm: string; + keyAlgorithm: string; + subjectAlternativeNames?: Array<{ + type: string; + value: string; + }>; + ttl: string; + notBefore?: string; + notAfter?: string; + }; + removeRootsFromChain?: boolean; +}; + +export type TUnifiedCertificateResponse = { + certificate: { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + }; + certificateRequestId: string; +}; + +export type TCertificateRequestResponse = { + certificateRequestId: string; + status: "pending" | "issued" | "failed"; + projectId: string; +}; + +export type TUnifiedCertificateIssuanceResponse = + | TUnifiedCertificateResponse + | TCertificateRequestResponse; + +export type TCertificateRequestDetails = { + status: "pending" | "issued" | "failed"; + certificate: TCertificate | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index 1e070e2bb7..dbdbe7c3b9 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -5,7 +5,6 @@ export enum IdentityProjectAdditionalPrivilegeTemporaryMode { } export type TIdentityProjectPrivilege = { - projectMembershipId: string; slug: string; id: string; createdAt: Date; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 33eacd18d6..acbf1d9f54 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,6 +1,9 @@ export * from "./accessApproval"; export * from "./admin"; export * from "./apiKeys"; +export * from "./approvalGrants"; +export * from "./approvalPolicies"; +export * from "./approvalRequests"; export * from "./assumePrivileges"; export * from "./auditLogs"; export * from "./auditLogStreams"; diff --git a/frontend/src/hooks/api/kms/mutations.tsx b/frontend/src/hooks/api/kms/mutations.tsx index 4fb0a5af5b..534e9fd5dd 100644 --- a/frontend/src/hooks/api/kms/mutations.tsx +++ b/frontend/src/hooks/api/kms/mutations.tsx @@ -6,6 +6,7 @@ import { kmsKeys } from "./queries"; import { AddExternalKmsType, ExternalKmsGcpSchemaType, + ExternalKmsProvider, KmsGcpKeyFetchAuthType, KmsType, UpdateExternalKmsType @@ -14,11 +15,12 @@ import { export const useAddExternalKms = (orgId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ name, description, provider }: AddExternalKmsType) => { - const { data } = await apiRequest.post("/api/v1/external-kms", { + mutationFn: async ({ name, description, configuration }: AddExternalKmsType) => { + const providerPath = configuration.type === ExternalKmsProvider.Aws ? "aws" : "gcp"; + const { data } = await apiRequest.post(`/api/v1/external-kms/${providerPath}`, { name, description, - provider + configuration: configuration.inputs }); return data; @@ -29,21 +31,21 @@ export const useAddExternalKms = (orgId: string) => { }); }; -export const useUpdateExternalKms = (orgId: string) => { +export const useUpdateExternalKms = (orgId: string, provider: ExternalKmsProvider) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async ({ kmsId, name, description, - provider + configuration }: { kmsId: string; } & UpdateExternalKmsType) => { - const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, { + const { data } = await apiRequest.patch(`/api/v1/external-kms/${provider}/${kmsId}`, { name, description, - provider + configuration: configuration?.inputs }); return data; @@ -58,8 +60,8 @@ export const useUpdateExternalKms = (orgId: string) => { export const useRemoveExternalKms = (orgId: string) => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async (kmsId: string) => { - const { data } = await apiRequest.delete(`/api/v1/external-kms/${kmsId}`); + mutationFn: async ({ kmsId, provider }: { kmsId: string; provider: ExternalKmsProvider }) => { + const { data } = await apiRequest.delete(`/api/v1/external-kms/${provider}/${kmsId}`); return data; }, @@ -130,11 +132,19 @@ export const useExternalKmsFetchGcpKeys = (orgId: string) => { ); } - const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", { - authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms, - region: gcpRegion, - ...rest - }); + const requestBody = credential + ? { + authMethod: KmsGcpKeyFetchAuthType.Credential, + region: gcpRegion, + credential + } + : { + authMethod: KmsGcpKeyFetchAuthType.Kms, + region: gcpRegion, + kmsId + }; + + const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", requestBody); return data; }, diff --git a/frontend/src/hooks/api/kms/queries.tsx b/frontend/src/hooks/api/kms/queries.tsx index 97d25376ce..4342c18817 100644 --- a/frontend/src/hooks/api/kms/queries.tsx +++ b/frontend/src/hooks/api/kms/queries.tsx @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { Kms, KmsListEntry } from "./types"; +import { ExternalKmsProvider, Kms, KmsListEntry } from "./types"; export const kmsKeys = { getExternalKmsList: (orgId: string) => ["get-all-external-kms", { orgId }], @@ -23,15 +23,19 @@ export const useGetExternalKmsList = (orgId: string, { enabled }: { enabled?: bo }); }; -export const useGetExternalKmsById = (kmsId: string) => { +export const useGetExternalKmsById = ({ + kmsId, + provider +}: { + kmsId: string; + provider: ExternalKmsProvider; +}) => { return useQuery({ queryKey: kmsKeys.getExternalKmsById(kmsId), enabled: Boolean(kmsId), queryFn: async () => { - const { - data: { externalKms } - } = await apiRequest.get<{ externalKms: Kms }>(`/api/v1/external-kms/${kmsId}`); - return externalKms; + const { data } = await apiRequest.get(`/api/v1/external-kms/${provider}/${kmsId}`); + return data; } }); }; diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 73b821b1a2..32c2fa32ab 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -8,12 +8,13 @@ export type Kms = { description: string; orgId: string; name: string; - external: { + externalKms: { id: string; status: string; statusDetails: string; provider: string; - providerInput: Record; + configuration: Record; + credentialsHash?: string; }; }; @@ -123,14 +124,14 @@ export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ export const AddExternalKmsSchema = z.object({ name: slugSchema({ min: 1, field: "Alias" }), description: z.string().trim().optional(), - provider: ExternalKmsInputSchema + configuration: ExternalKmsInputSchema }); export type AddExternalKmsType = z.infer; // we need separate schema for update because the credential field is not required on GCP export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema.partial() }), z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true }) @@ -144,9 +145,10 @@ export const UpdateExternalKmsSchema = z.object({ .min(1) .refine((v) => slugify(v) === v, { message: "Alias must be a valid slug" - }), + }) + .optional(), description: z.string().trim().optional(), - provider: ExternalKmsUpdateInputSchema + configuration: ExternalKmsUpdateInputSchema.optional() }); export type UpdateExternalKmsType = z.infer; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 9e2413b28f..857358ffea 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -42,7 +42,7 @@ export const organizationKeys = { [...organizationKeys.getOrgIdentityMemberships(orgId), params] as const, getOrgGroups: (orgId: string) => [{ orgId }, "organization-groups"] as const, getOrgIntegrationAuths: (orgId: string) => [{ orgId }, "integration-auths"] as const, - getOrgById: (orgId: string, subOrg?: string) => ["organization", { orgId, subOrg }], + getOrgById: (orgId: string) => ["organization", { orgId }], getAvailableIdentities: () => ["available-identities"], getAvailableUsers: () => ["available-users"] }; @@ -67,7 +67,7 @@ export const fetchOrganizationById = async (id: string) => { const { data: { organization } } = await apiRequest.get<{ - organization: Organization & { subOrganization?: { id: string; name: string } }; + organization: Organization; }>(`/api/v1/organization/${id}`); return organization; }; diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index b366277f14..45870176fc 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -30,6 +30,8 @@ export type Organization = { maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; blockDuplicateSecretSyncDestinations: boolean; + parentOrgId: string | null; + rootOrgId: string | null; }; export type UpdateOrgDTO = { diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index 2c86d99218..c6dfcd70c1 100644 --- a/frontend/src/hooks/api/pam/enums.ts +++ b/frontend/src/hooks/api/pam/enums.ts @@ -16,7 +16,8 @@ export enum PamResourceType { CockroachDB = "cockroachdb", Elasticsearch = "elasticsearch", Snowflake = "snowflake", - DynamoDB = "dynamodb" + DynamoDB = "dynamodb", + AwsIam = "aws-iam" } export enum PamResourceOrderBy { diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts index 90286a05d5..e42eb77488 100644 --- a/frontend/src/hooks/api/pam/maps.ts +++ b/frontend/src/hooks/api/pam/maps.ts @@ -20,5 +20,6 @@ export const PAM_RESOURCE_TYPE_MAP: Record< [PamResourceType.CockroachDB]: { name: "CockroachDB", image: "CockroachDB.png" }, [PamResourceType.Elasticsearch]: { name: "Elasticsearch", image: "Elastic.png" }, [PamResourceType.Snowflake]: { name: "Snowflake", image: "Snowflake.png" }, - [PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 } + [PamResourceType.DynamoDB]: { name: "DynamoDB", image: "DynamoDB.png", size: 55 }, + [PamResourceType.AwsIam]: { name: "AWS IAM", image: "Amazon Web Services.png" } }; diff --git a/frontend/src/hooks/api/pam/mutations.tsx b/frontend/src/hooks/api/pam/mutations.tsx index c5d6ff05b6..ce92de0fe8 100644 --- a/frontend/src/hooks/api/pam/mutations.tsx +++ b/frontend/src/hooks/api/pam/mutations.tsx @@ -120,6 +120,45 @@ export const useDeletePamAccount = () => { }); }; +export type TAccessPamAccountDTO = { + accountId: string; + accountPath: string; + projectId: string; + duration: string; +}; + +export type TAccessPamAccountResponse = { + sessionId: string; + resourceType: string; + consoleUrl?: string; + metadata?: Record; + relayClientCertificate?: string; + relayClientPrivateKey?: string; + relayServerCertificateChain?: string; + gatewayClientCertificate?: string; + gatewayClientPrivateKey?: string; + gatewayServerCertificateChain?: string; + relayHost?: string; +}; + +export const useAccessPamAccount = () => { + return useMutation({ + mutationFn: async ({ accountId, accountPath, projectId, duration }: TAccessPamAccountDTO) => { + const { data } = await apiRequest.post( + "/api/v1/pam/accounts/access", + { + accountId, + accountPath, + projectId, + duration + } + ); + + return data; + } + }); +}; + // Folders export const useCreatePamFolder = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/pam/types/aws-iam-resource.ts b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts new file mode 100644 index 0000000000..8cb51a0ec0 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/aws-iam-resource.ts @@ -0,0 +1,25 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export type TAwsIamConnectionDetails = { + roleArn: string; +}; + +export type TAwsIamCredentials = { + targetRoleArn: string; + defaultSessionDuration: number; +}; + +export type TAwsIamResource = Omit & { + resourceType: PamResourceType.AwsIam; + gatewayId?: string | null; + connectionDetails: TAwsIamConnectionDetails; +}; + +export type TAwsIamAccount = Omit< + TBasePamAccount, + "rotationEnabled" | "rotationIntervalSeconds" | "lastRotatedAt" +> & { + credentials: TAwsIamCredentials; +}; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 01b87c2822..332c985a95 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -6,17 +6,31 @@ import { PamResourceType, PamSessionStatus } from "../enums"; +import { TAwsIamAccount, TAwsIamResource } from "./aws-iam-resource"; +import { TKubernetesAccount, TKubernetesResource } from "./kubernetes-resource"; import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; import { TSSHAccount, TSSHResource } from "./ssh-resource"; +export * from "./aws-iam-resource"; +export * from "./kubernetes-resource"; export * from "./mysql-resource"; export * from "./postgres-resource"; export * from "./ssh-resource"; -export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResource = + | TPostgresResource + | TMySQLResource + | TSSHResource + | TAwsIamResource + | TKubernetesResource; -export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; +export type TPamAccount = + | TPostgresAccount + | TMySQLAccount + | TSSHAccount + | TAwsIamAccount + | TKubernetesAccount; export type TPamFolder = { id: string; @@ -42,7 +56,28 @@ export type TTerminalEvent = { elapsedTime: number; // Seconds since session start (for replay) }; -export type TPamSessionLog = TPamCommandLog | TTerminalEvent; +export type THttpRequestEvent = { + timestamp: string; + requestId: string; + eventType: "request"; + headers: Record; + method: string; + url: string; + body?: string; +}; + +export type THttpResponseEvent = { + timestamp: string; + requestId: string; + eventType: "response"; + headers: Record; + status: string; + body?: string; +}; + +export type THttpEvent = THttpRequestEvent | THttpResponseEvent; + +export type TPamSessionLog = TPamCommandLog | TTerminalEvent | THttpEvent; export type TPamSession = { id: string; diff --git a/frontend/src/hooks/api/pam/types/kubernetes-resource.ts b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts new file mode 100644 index 0000000000..b7e1501d5f --- /dev/null +++ b/frontend/src/hooks/api/pam/types/kubernetes-resource.ts @@ -0,0 +1,33 @@ +import { PamResourceType } from "../enums"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +export enum KubernetesAuthMethod { + ServiceAccountToken = "service-account-token" +} + +export type TKubernetesConnectionDetails = { + url: string; + sslRejectUnauthorized: boolean; + sslCertificate?: string; +}; + +export type TKubernetesServiceAccountTokenCredentials = { + authMethod: KubernetesAuthMethod.ServiceAccountToken; + serviceAccountToken: string; +}; + +export type TKubernetesCredentials = TKubernetesServiceAccountTokenCredentials; + +// Resources +export type TKubernetesResource = TBasePamResource & { + resourceType: PamResourceType.Kubernetes; +} & { + connectionDetails: TKubernetesConnectionDetails; + rotationAccountCredentials?: TKubernetesCredentials | null; +}; + +// Accounts +export type TKubernetesAccount = TBasePamAccount & { + credentials: TKubernetesCredentials; +}; diff --git a/frontend/src/hooks/api/projects/queries.tsx b/frontend/src/hooks/api/projects/queries.tsx index 8c07ca6914..b9748950b7 100644 --- a/frontend/src/hooks/api/projects/queries.tsx +++ b/frontend/src/hooks/api/projects/queries.tsx @@ -191,12 +191,15 @@ export const fetchWorkspaceIntegrations = async (projectId: string) => { return data.integrations; }; -export const useGetWorkspaceIntegrations = (projectId: string) => +export const useGetWorkspaceIntegrations = ( + projectId: string, + options?: { enabled?: boolean; refetchInterval?: number | false } +) => useQuery({ queryKey: projectKeys.getProjectIntegrations(projectId), queryFn: () => fetchWorkspaceIntegrations(projectId), - enabled: Boolean(projectId), - refetchInterval: 4000 + enabled: Boolean(projectId) && (options?.enabled ?? true), + refetchInterval: options?.refetchInterval ?? 4000 }); export const createWorkspace = ( diff --git a/frontend/src/hooks/api/projects/query-keys.tsx b/frontend/src/hooks/api/projects/query-keys.tsx index 48830f9e2e..6f74bf6f3f 100644 --- a/frontend/src/hooks/api/projects/query-keys.tsx +++ b/frontend/src/hooks/api/projects/query-keys.tsx @@ -3,14 +3,16 @@ import { WorkflowIntegrationPlatform } from "../workflowIntegrations/types"; import { TListProjectIdentitiesDTO, TSearchProjectsDTO } from "./types"; export const projectKeys = { - getProjectById: (projectId: string) => ["projects", { projectId }] as const, + allProjectQueries: () => ["projects"] as const, + getProjectById: (projectId: string) => + [...projectKeys.allProjectQueries(), { projectId }] as const, getProjectSecrets: (projectId: string) => [{ projectId }, "project-secrets"] as const, getProjectIndexStatus: (projectId: string) => [{ projectId }, "project-index-status"] as const, getProjectUpgradeStatus: (projectId: string) => [{ projectId }, "project-upgrade-status"], getProjectMemberships: (orgId: string) => [{ orgId }, "project-memberships"], getProjectAuthorization: (projectId: string) => [{ projectId }, "project-authorizations"], getProjectIntegrations: (projectId: string) => [{ projectId }, "project-integrations"], - getAllUserProjects: () => ["projects"] as const, + getAllUserProjects: () => [...projectKeys.allProjectQueries()] as const, getProjectAuditLogs: (projectId: string) => [{ projectId }, "project-audit-logs"] as const, getProjectUsers: ( projectId: string, @@ -28,7 +30,8 @@ export const projectKeys = { // allows invalidation using above key without knowing params getProjectIdentityMembershipsWithParams: ({ projectId, ...params }: TListProjectIdentitiesDTO) => [...projectKeys.getProjectIdentityMemberships(projectId), params] as const, - searchProject: (dto: TSearchProjectsDTO) => ["search-projects", dto] as const, + searchProject: (dto: TSearchProjectsDTO) => + [...projectKeys.allProjectQueries(), "search-projects", dto] as const, getProjectGroupMemberships: (projectId: string) => [{ projectId }, "project-groups"] as const, getProjectGroupMembershipDetails: (projectId: string, groupId: string) => [{ projectId, groupId }, "project-group-membership-details"] as const, diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index 264a6a4a4e..d52de16fc9 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -8,7 +8,8 @@ export enum SecretRotation { LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret", OktaClientSecret = "okta-client-secret", - RedisCredentials = "redis-credentials" + RedisCredentials = "redis-credentials", + MongoDBCredentials = "mongodb-credentials" } export enum SecretRotationStatus { diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index a04b0e020c..cc938ae053 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -31,6 +31,11 @@ import { TSqlCredentialsRotationOption } from "@app/hooks/api/secretRotationsV2/ import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { DiscriminativePick } from "@app/types"; +import { + TMongoDBCredentialsRotation, + TMongoDBCredentialsRotationGeneratedCredentialsResponse, + TMongoDBCredentialsRotationOption +} from "./mongodb-credentials-rotation"; import { TMySqlCredentialsRotation, TMySqlCredentialsRotationGeneratedCredentialsResponse @@ -61,6 +66,7 @@ export type TSecretRotationV2 = ( | TAwsIamUserSecretRotation | TOktaClientSecretRotation | TRedisCredentialsRotation + | TMongoDBCredentialsRotation ) & { secrets: (SecretV3RawSanitized | null)[]; }; @@ -72,7 +78,8 @@ export type TSecretRotationV2Option = | TLdapPasswordRotationOption | TAwsIamUserSecretRotationOption | TOktaClientSecretRotationOption - | TRedisCredentialsRotationOption; + | TRedisCredentialsRotationOption + | TMongoDBCredentialsRotationOption; export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] }; @@ -88,7 +95,8 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TLdapPasswordRotationGeneratedCredentialsResponse | TAwsIamUserSecretRotationGeneratedCredentialsResponse | TOktaClientSecretRotationGeneratedCredentialsResponse - | TRedisCredentialsRotationGeneratedCredentialsResponse; + | TRedisCredentialsRotationGeneratedCredentialsResponse + | TMongoDBCredentialsRotationGeneratedCredentialsResponse; export type TCreateSecretRotationV2DTO = DiscriminativePick< TSecretRotationV2, @@ -142,6 +150,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; [SecretRotation.OktaClientSecret]: TOktaClientSecretRotationOption; [SecretRotation.RedisCredentials]: TRedisCredentialsRotationOption; + [SecretRotation.MongoDBCredentials]: TMongoDBCredentialsRotationOption; }; export type TSecretRotationGeneratedCredentialsResponseMap = { @@ -155,4 +164,5 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; [SecretRotation.OktaClientSecret]: TOktaClientSecretRotationGeneratedCredentialsResponse; [SecretRotation.RedisCredentials]: TRedisCredentialsRotationGeneratedCredentialsResponse; + [SecretRotation.MongoDBCredentials]: TMongoDBCredentialsRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/mongodb-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/mongodb-credentials-rotation.ts new file mode 100644 index 0000000000..425357d540 --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/mongodb-credentials-rotation.ts @@ -0,0 +1,28 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase, + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationProperties +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TMongoDBCredentialsRotation = TSecretRotationV2Base & { + type: SecretRotation.MongoDBCredentials; +} & TSqlCredentialsRotationProperties; + +export type TMongoDBCredentialsRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.MongoDBCredentials, + TSqlCredentialsRotationGeneratedCredentials + >; + +export type TMongoDBCredentialsRotationOption = { + name: string; + type: SecretRotation.MongoDBCredentials; + connection: AppConnection.MongoDB; + template: { + createUserStatement: string; + secretsMapping: TMongoDBCredentialsRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/hooks/api/shared/types.ts b/frontend/src/hooks/api/shared/types.ts index c57daf3fd0..8a07ff4e66 100644 --- a/frontend/src/hooks/api/shared/types.ts +++ b/frontend/src/hooks/api/shared/types.ts @@ -18,7 +18,7 @@ export type TIdentity = { updatedAt: string; hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; - activeLockoutAuthMethods: string[]; + activeLockoutAuthMethods: IdentityAuthMethod[]; metadata?: Array; }; diff --git a/frontend/src/hooks/api/subOrganizations/mutations.tsx b/frontend/src/hooks/api/subOrganizations/mutations.tsx index 81369a62e8..f2b9ac7a8c 100644 --- a/frontend/src/hooks/api/subOrganizations/mutations.tsx +++ b/frontend/src/hooks/api/subOrganizations/mutations.tsx @@ -11,10 +11,7 @@ export const useCreateSubOrganization = () => { mutationFn: async (dto: TCreateSubOrganizationDTO) => { const { data } = await apiRequest.post<{ organization: TSubOrganization }>( "/api/v1/sub-organizations", - dto, - { - headers: { "x-root-org": "discard" } // akhi/scott: this just tells the request to use the root org ID header - } + dto ); return data; }, diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index b7ceb4a1bf..727fafbf69 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -78,6 +78,7 @@ export type TUserMembership = { scope: string; scopeOrgId: string; actorUserId: string; + actorGroupId: string; }; export type TProjectMembership = { diff --git a/frontend/src/index.css b/frontend/src/index.css index baa613b364..c1e564d92a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,5 @@ @import "tailwindcss"; +@import "tw-animate-css"; @source not "../public"; @@ -39,7 +40,7 @@ /* Colors v2 */ --color-background: #19191c; - --color-foreground: white; + --color-foreground: #ebebeb; --color-success: #2ecc71; --color-info: #63b0bd; --color-warning: #f1c40f; @@ -48,6 +49,14 @@ --color-sub-org: #96ff59; --color-project: #e0ed34; --color-neutral: #adaeb0; + --color-border: #2b2c30; + --color-label: #adaeb0; + --color-muted: #707174; + --color-popover: #141617; + --color-ring: #2d2f33; + --color-card: #16181a; + --color-accent: #7d7f80; + --color-container: #1a1c1e; /*legacy color schema */ --color-org-v1: #30b3ff; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 4610546cb9..519e4265a5 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -80,7 +80,11 @@ const getPlan = (subscription: SubscriptionPlan) => { return "Free"; }; -const getFormattedSupportEmailLink = (variables: { org_id: string; domain: string }) => { +const getFormattedSupportEmailLink = (variables: { + org_id: string; + domain: string; + root_org_id?: string; +}) => { const email = "support@infisical.com"; const body = `Hello Infisical Support Team, @@ -94,6 +98,7 @@ Issue Details: Account Info: - Organization ID: ${variables.org_id} +${variables.root_org_id ? `- Root Organization ID: ${variables.root_org_id}` : ""} - Domain: ${variables.domain} Thank you, @@ -165,10 +170,14 @@ export const Navbar = () => { const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); - const isBillingPage = location.pathname === "/organization/billing"; + const isBillingPage = location.pathname === `/organizations/${currentOrg.id}/billing`; const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days); + const rootOrg = isSubOrganization + ? orgs?.find((org) => org.id === currentOrg.rootOrgId) || currentOrg + : currentOrg; + useEffect(() => { if (isModalIntrusive) { setShowCardDeclinedModal(true); @@ -182,13 +191,20 @@ export const Navbar = () => { } }, [subscription, isBillingPage, isModalIntrusive]); - const handleOrgChange = async (orgId: string) => { - queryClient.removeQueries({ queryKey: authKeys.getAuthToken }); - queryClient.removeQueries({ queryKey: projectKeys.getAllUserProjects() }); + const handleOrgSelection = async ({ + organizationId, + navigateTo, + onSuccess + }: { + organizationId?: string; + navigateTo?: string; + onSuccess?: () => void | Promise; + }) => { + if (!organizationId) return; - const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ - organizationId: orgId - }); + if (organizationId === currentOrg.id) return; + + const { token, isMfaEnabled, mfaMethod } = await selectOrganization({ organizationId }); if (isMfaEnabled) { SecurityClient.setMfaToken(token); @@ -196,12 +212,58 @@ export const Navbar = () => { setRequiredMfaMethod(mfaMethod); } toggleShowMfa.on(); - setMfaSuccessCallback(() => () => handleOrgChange(orgId)); + setMfaSuccessCallback(() => async () => { + await handleOrgSelection({ organizationId, onSuccess }); + }); return; } - await router.invalidate(); - await navigateUserToOrg(navigate, orgId); + + SecurityClient.setToken(token); + SecurityClient.setProviderAuthToken(""); + queryClient.removeQueries({ queryKey: authKeys.getAuthToken }); queryClient.removeQueries({ queryKey: subOrgQuery.queryKey }); + + await queryClient.refetchQueries({ queryKey: authKeys.getAuthToken }); + + await navigateUserToOrg({ navigate, organizationId, navigateTo }); + queryClient.removeQueries({ queryKey: projectKeys.allProjectQueries() }); + + if (onSuccess) { + await onSuccess(); + } + }; + + const handleNavigateToRootOrgBilling = async () => { + const navigateToBilling = () => { + navigate({ + to: "/organizations/$orgId/billing", + params: { orgId: rootOrg.id } + }); + }; + + const onSuccess = () => { + setShowCardDeclinedModal(false); + }; + + if (isSubOrganization) { + await handleOrgSelection({ organizationId: rootOrg.id, onSuccess }); + } else { + await navigateToBilling(); + } + }; + + const handleNavigateToAdminConsole = async () => { + const navigateToAdminConsole = () => { + navigate({ + to: "/admin" + }); + }; + + if (isSubOrganization) { + await handleOrgSelection({ organizationId: rootOrg.id, navigateTo: "/admin" }); + } else { + navigateToAdminConsole(); + } }; const { mutateAsync } = useGetOrgTrialUrl(); @@ -273,7 +335,7 @@ export const Navbar = () => { return; } - handleOrgChange(org?.id); + handleOrgSelection({ organizationId: org?.id }); }; return ( @@ -315,17 +377,20 @@ export const Navbar = () => { className="flex cursor-pointer items-center gap-x-2 truncate whitespace-nowrap" type="button" onClick={async () => { - navigate({ - to: "/organizations/$orgId/projects", - params: { orgId: currentOrg.id } - }); if (isSubOrganization) { - await router.invalidate({ sync: true }).catch(() => null); + await handleOrgSelection({ + organizationId: currentOrg.rootOrgId as string + }); + } else { + navigate({ + to: "/organizations/$orgId/projects", + params: { orgId: currentOrg.id } + }); } }} > - {currentOrg?.name} + {rootOrg?.name} Organization @@ -398,13 +463,7 @@ export const Navbar = () => { {subOrganizations.map((subOrg) => ( { - navigate({ - to: "/organizations/$orgId/projects", - params: { orgId: subOrg.id } - }); - await router.invalidate({ sync: true }).catch(() => null); - }} + onClick={() => handleOrgSelection({ organizationId: subOrg.id })} className="cursor-pointer font-normal" key={subOrg.id} > @@ -459,79 +518,93 @@ export const Navbar = () => { - {currentOrg.subOrganization && ( + {isSubOrganization && ( <> -

/

- - svg]:!text-sub-org" - )} - > - - - {currentOrg.subOrganization.name} - - - -
- - - + +
+ {/* scott: the below is used to hide the top border from the org nav bar */} + {!isProjectScope && isSubOrganization && ( +
+
- - -
- Sub-Organizations -
- {subOrganizations.map((subOrg) => ( - +
+ +
+ +
+ + + +
+
+ - New Sub-Organization -
-
- +
+ Sub-Organizations +
+ {subOrganizations.map((subOrg) => ( + handleOrgSelection({ organizationId: subOrg.id })} + className="cursor-pointer font-normal" + key={subOrg.id} + > +
+ {currentOrg?.id === subOrg.id && ( + + )} +

{subOrg.name}

+
+
+ ))} + {Boolean(subOrganizations.length) && ( +
+ )} + } + onClick={() => setShowSubOrgForm(true)} + > + New Sub-Organization + + + +
)} {isProjectScope && ( @@ -551,11 +624,11 @@ export const Navbar = () => { className="mr-2 border-mineshaft-500 px-2.5 py-1.5 whitespace-nowrap text-mineshaft-200 hover:bg-mineshaft-600" leftIcon={} onClick={async () => { - if (!subscription || !currentOrg) return; + if (!subscription || !rootOrg) return; // direct user to start pro trial const url = await mutateAsync({ - orgId: currentOrg.id, + orgId: rootOrg.id, success_url: window.location.href }); @@ -576,6 +649,7 @@ export const Navbar = () => { Server Console @@ -613,7 +687,8 @@ export const Navbar = () => { text === "Email Support" ? getUrl({ org_id: currentOrg.id, - domain: window.location.origin + domain: window.location.origin, + ...(isSubOrganization && { root_org_id: rootOrg.id }) }) : getUrl(); @@ -773,19 +848,13 @@ export const Navbar = () => {
- - - + Update Payment Method + {!isModalIntrusive && (
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx index dbaa6d8335..0b803bd536 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/NewSubOrganizationForm.tsx @@ -1,15 +1,18 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useNavigate, useRouter } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; +import SecurityClient from "@app/components/utilities/SecurityClient"; import { Button, FormControl, Input } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { useCreateSubOrganization } from "@app/hooks/api"; +import { selectOrganization } from "@app/hooks/api/auth/queries"; import { slugSchema } from "@app/lib/schemas"; type ContentProps = { onClose: () => void; + handleOrgSelection: (params: { organizationId: string }) => void; }; const AddOrgSchema = z.object({ @@ -18,7 +21,8 @@ const AddOrgSchema = z.object({ type FormData = z.infer; -export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { +export const NewSubOrganizationForm = ({ onClose, handleOrgSelection }: ContentProps) => { + const { currentOrg, isSubOrganization } = useOrganization(); const createSubOrg = useCreateSubOrganization(); const { @@ -32,10 +36,16 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { resolver: zodResolver(AddOrgSchema) }); - const navigate = useNavigate(); - const router = useRouter(); - const onSubmit = async ({ name }: FormData) => { + if (isSubOrganization && currentOrg.rootOrgId) { + const { token } = await selectOrganization({ + organizationId: currentOrg.rootOrgId + }); + + SecurityClient.setToken(token); + SecurityClient.setProviderAuthToken(""); + } + const { organization } = await createSubOrg.mutateAsync({ name }); @@ -46,11 +56,7 @@ export const NewSubOrganizationForm = ({ onClose }: ContentProps) => { }); onClose(); - navigate({ - to: "/organizations/$orgId/projects", - params: { orgId: organization.id } - }); - await router.invalidate({ sync: true }).catch(() => null); + await handleOrgSelection({ organizationId: organization.id }); }; return ( diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx index e56c02cee4..09859b10f4 100644 --- a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx @@ -1,5 +1,6 @@ import { Link, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { twMerge } from "tailwind-merge"; import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; import { Tab, TabList, Tabs } from "@app/components/v2"; @@ -21,7 +22,14 @@ export const OrgNavBar = ({ isHidden }: Props) => { return (
{!isHidden && ( -
+
{ > {({ isActive }) => Sessions} + + {({ isActive }) => ( + + Approvals + + )} + { { {({ isActive }) => Certificates} { )} { {({ isActive }) => Alerting} { {({ isActive }) => Integrations} { <> {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( { )} {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( { )} { )} { {({ isActive }) => Audit Logs} { - const { isSubOrganization, currentOrg } = useOrganization(); + const { currentOrg } = useOrganization(); const { currentProject } = useProject(); const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege(); const { assumedPrivilegeDetails } = useProjectPermission(); @@ -37,7 +37,7 @@ export const AssumePrivilegeModeBanner = () => { }, { onSuccess: () => { - const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`; + const url = getProjectHomePage(currentProject.type, currentProject.environments); window.location.assign( url.replace("$orgId", currentOrg.id).replace("$projectId", currentProject.id) ); diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index e701863eba..2febc18873 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -157,20 +157,11 @@ const ProjectSelectInner = () => { params: { projectId: workspace.id, orgId: workspace.orgId - }, - search: { - subOrganization: currentOrg?.subOrganization?.name } }); const urlInstance = new URL( `${window.location.origin}${url.to.replaceAll("$orgId", url.params.orgId).replaceAll("$projectId", url.params.projectId)}` ); - if (currentOrg?.subOrganization) { - urlInstance.searchParams.set( - "subOrganization", - currentOrg.subOrganization.name - ); - } window.location.assign(urlInstance); }} icon={ diff --git a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx index 81959a647f..52c8526f51 100644 --- a/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/pages/admin/SignUpPage/SignUpPage.tsx @@ -62,7 +62,13 @@ export const SignUpPage = () => { navigate({ to: "/admin" }); }; - if (config?.initialized) return ; + if (config?.initialized) { + return ( +
+ +
+ ); + } return (
diff --git a/frontend/src/pages/auth/LoginPage/Login.utils.tsx b/frontend/src/pages/auth/LoginPage/Login.utils.tsx index 06a897b5a9..8f2c63f832 100644 --- a/frontend/src/pages/auth/LoginPage/Login.utils.tsx +++ b/frontend/src/pages/auth/LoginPage/Login.utils.tsx @@ -5,7 +5,17 @@ import { fetchOrganizations } from "@app/hooks/api/organization/queries"; import { queryClient } from "@app/hooks/api/reactQuery"; import { userKeys } from "@app/hooks/api/users"; -export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: string) => { +type NavigateUserToOrgParams = { + navigate: NavigateFn; + organizationId?: string; + navigateTo?: string; +}; + +export const navigateUserToOrg = async ({ + navigate, + organizationId, + navigateTo +}: NavigateUserToOrgParams) => { const userOrgs = await fetchOrganizations(); const nonAuthEnforcedOrgs = userOrgs.filter((org) => !org.authEnforced); @@ -13,7 +23,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s if (organizationId) { localStorage.setItem("orgData.id", organizationId); navigate({ - to: "/organizations/$orgId/projects", + to: navigateTo || "/organizations/$orgId/projects", params: { orgId: organizationId } }); return; @@ -24,7 +34,7 @@ export const navigateUserToOrg = async (navigate: NavigateFn, organizationId?: s const userOrg = nonAuthEnforcedOrgs[0] && nonAuthEnforcedOrgs[0].id; localStorage.setItem("orgData.id", userOrg); navigate({ - to: "/organizations/$orgId/projects", + to: navigateTo || "/organizations/$orgId/projects", params: { orgId: userOrg } }); } else { diff --git a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx index 3e13d6d089..4e8f2ba2c6 100644 --- a/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/pages/auth/LoginPage/components/PasswordStep/PasswordStep.tsx @@ -115,7 +115,7 @@ export const PasswordStep = ({ return; } - await navigateUserToOrg(navigate, organizationId); + await navigateUserToOrg({ navigate, organizationId }); }; await finishWithOrgWorkflow(); @@ -131,7 +131,7 @@ export const PasswordStep = ({ } // case: no orgs found, so we navigate the user to create an org else { - await navigateUserToOrg(navigate); + await navigateUserToOrg({ navigate }); } } } catch (err: any) { @@ -233,7 +233,7 @@ export const PasswordStep = ({ } // case: no orgs found, so we navigate the user to create an org else { - await navigateUserToOrg(navigate); + await navigateUserToOrg({ navigate }); } } } else { @@ -254,7 +254,7 @@ export const PasswordStep = ({ // case: organization ID is present from the provider auth token -- navigate directly to the org if (organizationId) { - await navigateUserToOrg(navigate, organizationId); + await navigateUserToOrg({ navigate, organizationId }); } // case: no organization ID is present -- navigate to the select org page IF the user has any orgs // if the user has no orgs, navigate to the create org page @@ -264,7 +264,7 @@ export const PasswordStep = ({ if (userOrgs.length > 0) { navigateToSelectOrganization(undefined, isAdminLogin); } else { - await navigateUserToOrg(navigate); + await navigateUserToOrg({ navigate }); } } } @@ -316,7 +316,7 @@ export const PasswordStep = ({ return ( - navigateUserToOrg(navigate, organizationId).catch(() => + navigateUserToOrg({ navigate, organizationId }).catch(() => createNotification({ text: "Failed to navigate user", type: "error" }) ) } diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 2a3e9c5543..98633e390a 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -47,6 +47,7 @@ export const SelectOrganizationSection = () => { const orgId = queryParams.get("org_id"); const callbackPort = queryParams.get("callback_port"); const isAdminLogin = queryParams.get("is_admin_login") === "true"; + const mfaPending = queryParams.get("mfa_pending") === "true"; const defaultSelectedOrg = organizations.data?.find((org) => org.id === orgId); const logout = useLogoutUser(true); @@ -188,7 +189,7 @@ export const SelectOrganizationSection = () => { navigate({ to: "/cli-redirect" }); // cli page } else { - navigateUserToOrg(navigate, organization.id); + navigateUserToOrg({ navigate, organizationId: organization.id }); } }, [selectOrg] @@ -201,7 +202,7 @@ export const SelectOrganizationSection = () => { const decodedJwt = jwtDecode(authToken) as any; if (decodedJwt?.organizationId) { - navigateUserToOrg(navigate, decodedJwt.organizationId); + navigateUserToOrg({ navigate, organizationId: decodedJwt.organizationId }); } } @@ -236,10 +237,22 @@ export const SelectOrganizationSection = () => { }, [organizations.isPending, organizations.data]); useEffect(() => { + if (mfaPending && defaultSelectedOrg) { + const storedMfaToken = sessionStorage.getItem(SessionStorageKeys.MFA_TEMP_TOKEN); + if (storedMfaToken) { + sessionStorage.removeItem(SessionStorageKeys.MFA_TEMP_TOKEN); + SecurityClient.setMfaToken(storedMfaToken); + setIsInitialOrgCheckLoading(false); + toggleShowMfa.on(); + setMfaSuccessCallback(() => () => handleSelectOrganization(defaultSelectedOrg)); + return; + } + } + if (defaultSelectedOrg) { handleSelectOrganization(defaultSelectedOrg); } - }, [defaultSelectedOrg]); + }, [defaultSelectedOrg, mfaPending]); if ( userLoading || diff --git a/frontend/src/pages/auth/SelectOrgPage/route.tsx b/frontend/src/pages/auth/SelectOrgPage/route.tsx index 27ad4bb948..f20fda1f3b 100644 --- a/frontend/src/pages/auth/SelectOrgPage/route.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/route.tsx @@ -8,7 +8,8 @@ export const SelectOrganizationPageQueryParams = z.object({ org_id: z.string().optional().catch(""), callback_port: z.coerce.number().optional().catch(undefined), is_admin_login: z.boolean().optional().catch(false), - force: z.boolean().optional() + force: z.boolean().optional(), + mfa_pending: z.boolean().optional().catch(false) }); export const Route = createFileRoute("/_restrict-login-signup/login/select-organization")({ @@ -16,7 +17,12 @@ export const Route = createFileRoute("/_restrict-login-signup/login/select-organ validateSearch: zodValidator(SelectOrganizationPageQueryParams), search: { middlewares: [ - stripSearchParams({ org_id: "", callback_port: undefined, is_admin_login: false }) + stripSearchParams({ + org_id: "", + callback_port: undefined, + is_admin_login: false, + mfa_pending: false + }) ] } }); diff --git a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx index cd75f917af..e996f191c1 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionModal.tsx @@ -82,7 +82,7 @@ export const PkiCollectionModal = ({ popUp, handlePopUpToggle }: Props) => { }); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", params: { orgId: currentOrg.id, projectId, diff --git a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx index 71b57796de..601df6fafa 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/components/PkiCollectionTable.tsx @@ -66,7 +66,7 @@ export const PkiCollectionTable = ({ handlePopUpOpen }: Props) => { key={`pki-collection-${pkiCollection.id}`} onClick={() => navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/pki-collections/$collectionId", + to: "/organizations/$orgId/projects/cert-manager/$projectId/pki-collections/$collectionId", params: { orgId: currentOrg.id, projectId, diff --git a/frontend/src/pages/cert-manager/AlertingPage/route.tsx b/frontend/src/pages/cert-manager/AlertingPage/route.tsx index 5dc2f3de7d..038e5cb9a4 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/route.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { AlertingPage } from "./AlertingPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/alerting" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/alerting" )({ component: AlertingPage, beforeLoad: ({ context }) => { diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx index b2cd5e6966..4df47af75e 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/CertAuthDetailsByIDPage.tsx @@ -1,4 +1,5 @@ import { Helmet } from "react-helmet"; +import { subject } from "@casl/ability"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; @@ -7,6 +8,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + AccessRestrictedBanner, Button, DeleteActionModal, DropdownMenu, @@ -18,7 +20,7 @@ import { } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { - ProjectPermissionActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionSub, useOrganization, useProject @@ -77,7 +79,7 @@ const Page = () => { handlePopUpClose("deleteCa"); navigate({ - to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", + to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", params: { orgId: currentOrg.id, projectId @@ -88,63 +90,80 @@ const Page = () => { return (
{data && ( -
- - - Certificate Authorities - - - - -
- - - -
-
- - + {(isAllowed) => + isAllowed ? ( +
+ - {(isAllowed) => ( - handlePopUpOpen("deleteCa")} - disabled={!isAllowed} - > - Delete CA - - )} - - - - -
-
- -
-
- - -
-
-
+ + Certificate Authorities + + + + +
+ + + +
+
+ + + {(canDelete) => ( + handlePopUpOpen("deleteCa")} + disabled={!canDelete} + > + Delete CA + + )} + + +
+
+
+
+ +
+
+ + +
+
+
+ ) : ( +
+ +
+ ) + } + )} diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx index 8309a9e174..4502138e0e 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesSection.tsx @@ -2,9 +2,10 @@ import { CaCertificatesTable } from "./CaCertificatesTable"; type Props = { caId: string; + caName: string; }; -export const CaCertificatesSection = ({ caId }: Props) => { +export const CaCertificatesSection = ({ caId, caName }: Props) => { return (
@@ -21,7 +22,7 @@ export const CaCertificatesSection = ({ caId }: Props) => { */}
- +
); diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx index c04b8525af..1146ec0a3f 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faCertificate, faEllipsis } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as x509 from "@peculiar/x509"; @@ -22,14 +23,15 @@ import { Tr } from "@app/components/v2"; import { Badge } from "@app/components/v3"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionCertificateAuthorityActions, ProjectPermissionSub } from "@app/context"; import { useGetCaCerts } from "@app/hooks/api"; type Props = { caId: string; + caName: string; }; -export const CaCertificatesTable = ({ caId }: Props) => { +export const CaCertificatesTable = ({ caId, caName }: Props) => { const { data: caCerts, isPending } = useGetCaCerts(caId); const downloadTxtFile = (filename: string, content: string) => { @@ -77,8 +79,10 @@ export const CaCertificatesTable = ({ caId }: Props) => { {(isAllowed) => ( { )} {(isAllowed) => ( {

CA Details

- + {(isAllowed) => { return ( @@ -154,8 +158,8 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
{ca.status === CaStatus.ACTIVE && ( {(isAllowed) => { return ( @@ -190,8 +194,8 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => { )} {ca.status === CaStatus.PENDING_CERTIFICATE && ( {(isAllowed) => { return ( diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx index e0a84e5a9f..a1053bfc44 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/route.tsx @@ -3,7 +3,7 @@ import { createFileRoute, linkOptions } from "@tanstack/react-router"; import { CertAuthDetailsByIDPage } from "./CertAuthDetailsByIDPage"; export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-management/$projectId/_cert-manager-layout/ca/$caId" + "/_authenticate/_inject-org-details/_org-layout/organizations/$orgId/projects/cert-manager/$projectId/_cert-manager-layout/ca/$caId" )({ component: CertAuthDetailsByIDPage, beforeLoad: ({ context, params }) => { @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Authorities", link: linkOptions({ - to: "/organizations/$orgId/projects/cert-management/$projectId/certificate-authorities", + to: "/organizations/$orgId/projects/cert-manager/$projectId/certificate-authorities", params: { orgId: params.orgId, projectId: params.projectId diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx index 7e121eec93..2870f390d4 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaSection.tsx @@ -4,7 +4,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { + ProjectPermissionCertificateAuthorityActions, + ProjectPermissionSub, + useProject +} from "@app/context"; import { CaStatus, CaType, useDeleteCa, useUpdateCa } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -57,7 +61,7 @@ export const CaSection = () => {

Internal Certificate Authorities

{(isAllowed) => ( @@ -100,6 +104,7 @@ export const CaSection = () => { : "This action will prevent the CA from issuing new certificates." } onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)} + buttonText="Confirm" deleteKey="confirm" onDeleteApproved={() => onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx index 27de9315ae..41634e128f 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx @@ -1,3 +1,4 @@ +import { subject } from "@casl/ability"; import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; @@ -23,10 +24,11 @@ import { } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { - ProjectPermissionActions, + ProjectPermissionCertificateAuthorityActions, ProjectPermissionSub, useOrganization, - useProject + useProject, + useProjectPermission } from "@app/context"; import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api"; import { @@ -53,6 +55,7 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { const navigate = useNavigate(); const { currentOrg } = useOrganization(); const { currentProject } = useProject(); + const { permission } = useProjectPermission(); const { data, isPending } = useListCasByTypeAndProjectId(CaType.INTERNAL, currentProject.id); const cas = data as TInternalCertificateAuthority[]; @@ -75,13 +78,25 @@ export const CaTable = ({ handlePopUpOpen }: Props) => { cas && cas.length > 0 && cas.map((ca) => { + const canReadCa = permission.can( + ProjectPermissionCertificateAuthorityActions.Read, + subject(ProjectPermissionSub.CertificateAuthorities, { + name: ca.name + }) + ); + return (
{ca.name} {ca.type} - {`${destinationDetails.name} - -
-
-

{name}

- {description && ( - - - - )} -
-

{destinationDetails.name}

-
-
-
- {syncStatus && ( - - {lastSyncedAt && ( -
-
- -
Last Synced
-
-
- {format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")} -
-
- )} - {failureMessage && ( -
-
- -
Failure Reason
-
-
- {failureMessage} -
-
- )} -
- ) : undefined + + {(isAllowed: boolean) => { + return ( +
+ {`${destinationDetails.name} +
- -
- - )} - {!isAutoSyncEnabled && ( - - - - {!syncStatus && "Auto-Sync Disabled"} - - - )} - {syncOption?.canImportCertificates && } - - -
- - - - - - - - - } - onClick={(e) => { - e.stopPropagation(); - handleCopyId(); - }} - > - Copy Sync ID - - - {(isAllowed: boolean) => ( - } - onClick={(e) => { - e.stopPropagation(); - onTriggerSyncCertificates(pkiSync); - }} - isDisabled={!isAllowed} - > - -
- Trigger Sync - -
+
+

{name}

+ {description && ( + + - + )} +
+

+ {destinationDetails.name} +

+ +
+
+ {syncStatus && ( + + {lastSyncedAt && ( +
+
+ +
Last Synced
+
+
+ {format(new Date(lastSyncedAt), "yyyy-MM-dd, hh:mm aaa")} +
+
+ )} + {failureMessage && ( +
+
+ +
Failure Reason
+
+
+ {failureMessage} +
+
+ )} +
+ ) : undefined + } + > +
+ +
+ )} - - {syncOption?.canImportCertificates && ( - - {(isAllowed: boolean) => ( + {!isAutoSyncEnabled && ( + + + + {!syncStatus && "Auto-Sync Disabled"} + + + )} + {syncOption?.canImportCertificates && ( + + )} + + +
+ + + + + + + + } + icon={} onClick={(e) => { e.stopPropagation(); - onTriggerImportCertificates(pkiSync); + handleCopyId(); }} - isDisabled={!isAllowed} > - -
- Import Certificates - -
-
+ Copy Sync ID
- )} - - )} - - {(isAllowed: boolean) => ( - } - onClick={(e) => { - e.stopPropagation(); - onTriggerRemoveCertificates(pkiSync); - }} - isDisabled={!isAllowed} - > - -
- Remove Certificates - -
-
-
- )} -
- - {(isAllowed: boolean) => ( - } - onClick={(e) => { - e.stopPropagation(); - onToggleEnable(pkiSync); - }} - > - {isAutoSyncEnabled ? "Disable" : "Enable"} Auto-Sync - - )} - - - {(isAllowed: boolean) => ( - } - onClick={(e) => { - e.stopPropagation(); - onDelete(pkiSync); - }} - > - Delete Sync - - )} - -
-
-
-
@@ -149,50 +130,82 @@ export const ProfileRow = ({ > Copy Profile ID - {canEditProfile && ( - { - e.stopPropagation(); - onEditProfile(profile); - }} - icon={} + + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + onEditProfile(profile); + }} + icon={} + > + Edit Profile + + ) + } + + {profile.enrollmentType === "acme" && ( + - Edit Profile - + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + onRevealProfileAcmeEabSecret(profile); + }} + icon={} + > + Reveal ACME EAB + + ) + } + )} - {canRevealProfileAcmeEabSecret && profile.enrollmentType === "acme" && ( - { - e.stopPropagation(); - onRevealProfileAcmeEabSecret(profile); - }} - icon={} + {profile.enrollmentType === "api" && ( + - Reveal ACME EAB - - )} - {canIssueCertificate && profile.enrollmentType === "api" && ( - { - e.stopPropagation(); - handlePopUpToggle("issueCertificate"); - }} - icon={} - > - Issue Certificate - - )} - {canDeleteProfile && ( - { - e.stopPropagation(); - onDeleteProfile(profile); - }} - icon={} - > - Delete Profile - + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + handlePopUpToggle("issueCertificate"); + }} + icon={} + > + Request Certificate + + ) + } + )} + + {(isAllowed) => + isAllowed && ( + { + e.stopPropagation(); + onDeleteProfile(profile); + }} + icon={} + > + Delete Profile + + ) + } + { - const { permission } = useProjectPermission(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -26,11 +24,6 @@ export const CertificateTemplatesV2Tab = () => { const deleteTemplateV2 = useDeleteCertificateTemplateV2WithPolicies(); - const canCreateTemplate = permission.can( - ProjectPermissionPkiTemplateActions.Create, - ProjectPermissionSub.CertificateTemplates - ); - const handleCreateTemplate = () => { setIsCreateModalOpen(true); }; @@ -70,16 +63,22 @@ export const CertificateTemplatesV2Tab = () => {

- {canCreateTemplate && ( - - )} + + {(isAllowed) => ( + + )} + 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 d55c1b5ef7..1da9e8ebe2 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 108fe966c2..a2aa5f78c9 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 + + ) + } +
@@ -205,7 +205,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
- Organization Role + {isSubOrganization ? "Sub-" : ""}Organization Role { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index c71dc80552..6b6086b0bb 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { AnimatePresence, motion } from "framer-motion"; -import { LinkIcon, PlusIcon } from "lucide-react"; +import { InfoIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; -import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2"; +import { Button, DeleteActionModal, Modal, ModalContent, Tooltip } from "@app/components/v2"; import { DocumentationLinkBadge } from "@app/components/v3"; import { OrgPermissionIdentityActions, @@ -30,9 +30,8 @@ import { OrgIdentityLinkForm } from "./OrgIdentityLinkForm"; import { OrgIdentityModal } from "./OrgIdentityModal"; enum IdentityWizardSteps { - SelectAction = "select-action", - LinkIdentity = "link-identity", - OrganizationIdentity = "project-identity" + CreateIdentity = "create-identity", + LinkIdentity = "link-identity" } export const IdentitySection = withPermission( @@ -41,7 +40,7 @@ export const IdentitySection = withPermission( const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id || ""; - const [wizardStep, setWizardStep] = useState(IdentityWizardSteps.SelectAction); + const [wizardStep, setWizardStep] = useState(IdentityWizardSteps.CreateIdentity); const { mutateAsync: deleteMutateAsync } = useDeleteOrgIdentity(); const { mutateAsync: deleteTemplateMutateAsync } = useDeleteIdentityAuthTemplate(); @@ -100,7 +99,7 @@ export const IdentitySection = withPermission(

- Organization Machine Identities + {isSubOrganization ? "Sub-" : ""}Organization Machine Identities

@@ -124,7 +123,7 @@ export const IdentitySection = withPermission( } if (!isSubOrganization) { - setWizardStep(IdentityWizardSteps.OrganizationIdentity); + setWizardStep(IdentityWizardSteps.CreateIdentity); } handlePopUpOpen("identity"); @@ -197,7 +196,7 @@ export const IdentitySection = withPermission( onOpenChange={(open) => { handlePopUpToggle("identity", open); if (!open) { - setWizardStep(IdentityWizardSteps.SelectAction); + setWizardStep(IdentityWizardSteps.CreateIdentity); } }} > @@ -214,80 +213,84 @@ export const IdentitySection = withPermission( : undefined } > - - {wizardStep === IdentityWizardSteps.SelectAction && ( - -
setWizardStep(IdentityWizardSteps.OrganizationIdentity)} - onKeyDown={(e) => { - if (e.key === "Enter") { - setWizardStep(IdentityWizardSteps.OrganizationIdentity); - } + {isSubOrganization && ( +
+
+
-
setWizardStep(IdentityWizardSteps.LinkIdentity)} - onKeyDown={(e) => { - if (e.key === "Enter") { - setWizardStep(IdentityWizardSteps.LinkIdentity); - } + Create New + +
- - )} - {wizardStep === IdentityWizardSteps.OrganizationIdentity && ( - +
+ +

+ You can add machine identities to your sub-organization in one of two ways: +

+
    +
  • + Create New - + Create a new machine identity specifically for this sub-organization. This + machine identity will be managed at the sub-organization level. +

    + This method is recommended for autonomous teams that need to manage + machine identity authentication. +

    +
  • +
  • + + Assign Existing + {" "} + Assign an existing machine identity from your parent organization. The + machine identity will continue to be managed at its original scope. +

    + This method is recommended for organizations that need to maintain + centralized control. +

    +
  • +
+ + } > - - - )} - {wizardStep === IdentityWizardSteps.LinkIdentity && ( - - handlePopUpClose("identity")} /> - - )} - + +
+
+ )} + {wizardStep === IdentityWizardSteps.CreateIdentity && ( + + )} + {wizardStep === IdentityWizardSteps.LinkIdentity && ( + handlePopUpClose("identity")} /> + )} { - Filter Organization Machine Identities by Role + Filter {isSubOrganization ? "Sub-" : ""}Organization Machine Identities by Role {roles?.map(({ id, slug, name }) => ( { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search machine identities by name..." + placeholder={`Search ${isSubOrganization ? "sub-organization" : "organization"} machine identities by name...`} />
@@ -258,7 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
- Organization Role + {isSubOrganization ? "Sub-" : ""}Organization Role { to: "/organizations/$orgId/identities/$identityId", params: { identityId: id, - orgId + orgId: currentOrg.id } }) } @@ -455,8 +455,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { 0 || filter.roles?.length > 0 - ? "No machine identities match search filter" - : "No machine identities have been created in this organization" + ? `No ${isSubOrganization ? "sub-" : ""}organization machine identities match search filter` + : `No machine identities have been created in this ${isSubOrganization ? "sub-" : ""}organization` } icon={faServer} /> 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 9f7141802a..b8d857f919 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 @@ -84,6 +84,7 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => { onChange={onChange} placeholder="Select machine identity..." // onInputChange={setSearchValue} + autoFocus options={rootOrgIdentities} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} 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 737c454d1f..732a22cce7 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 @@ -185,7 +185,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { isError={Boolean(error)} errorText={error?.message} > - + )} /> 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 6a4d931418..926032ecce 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 @@ -205,7 +205,9 @@ export const OrgMembersSection = () => {
-

Organization Users

+

+ {isSubOrganization ? "Sub-" : ""}Organization Users +

@@ -242,7 +244,7 @@ export const OrgMembersSection = () => { isOpen={popUp.addMemberToSubOrg.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("addMemberToSubOrg", isOpen)} > - + handlePopUpClose("addMemberToSubOrg")} /> 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 55b593ffe5..6238e1308e 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 = ({ - Filter Organization Users by Role + Filter {isSubOrganization ? "Sub-" : ""}Organization Users by Role {roles?.map(({ id, slug, name }) => ( setSearch(e.target.value)} leftIcon={} - placeholder="Search organization users..." + placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization users...`} />
@@ -434,7 +434,7 @@ export const OrgMembersTable = ({
- Organization Role + {isSubOrganization ? "Sub-" : ""}Organization Role diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx index 63b4284f11..890196348c 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -207,7 +207,7 @@ export const OrgRoleTable = () => { }} isDisabled={!isAllowed} > - Add Organization Role + Add {isSubOrganization ? "Sub-" : ""}Organization Role )} @@ -216,7 +216,7 @@ export const OrgRoleTable = () => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search organization roles..." + placeholder={`Search ${isSubOrganization ? "sub-" : ""}organization roles...`} className="flex-1" containerClassName="mb-4" /> 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 af26a940db..6fa3d88542 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -35,6 +35,7 @@ import { HerokuConnectionForm } from "./HerokuAppConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { LaravelForgeConnectionForm } from "./LaravelForgeConnectionForm"; import { LdapConnectionForm } from "./LdapConnectionForm"; +import { MongoDBConnectionForm } from "./MongoDBConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { NetlifyConnectionForm } from "./NetlifyConnectionForm"; @@ -173,6 +174,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Redis: return ; + case AppConnection.MongoDB: + return ; default: throw new Error(`Unhandled App ${app}`); } @@ -331,6 +334,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Redis: return ; + case AppConnection.MongoDB: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MongoDBConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MongoDBConnectionForm.tsx new file mode 100644 index 0000000000..72e359e0a5 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/MongoDBConnectionForm.tsx @@ -0,0 +1,326 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { MongoDBConnectionMethod, TMongoDBConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TMongoDBConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.MongoDB) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(MongoDBConnectionMethod.UsernameAndPassword), + credentials: z.object({ + host: z.string().trim().min(1, "Host required"), + port: z.coerce.number().default(27017), + username: z.string().trim().min(1, "Username required"), + password: z.string().trim().min(1, "Password required"), + database: z.string().trim().min(1, "Database required"), + tlsEnabled: z.boolean().default(false), + tlsRejectUnauthorized: z.boolean().default(true), + tlsCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const MongoDBConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.MongoDB, + method: MongoDBConnectionMethod.UsernameAndPassword, + credentials: { + host: "", + port: 27017, + username: "", + password: "", + database: "", + tlsEnabled: false, + tlsRejectUnauthorized: true, + tlsCertificate: undefined + } + } + }); + + const { + handleSubmit, + watch, + control, + formState: { isSubmitting, isDirty } + } = form; + + const tlsEnabled = watch("credentials.tlsEnabled"); + + return ( + + + {!isUpdate && } + ( + + + + )} + /> + + + + + `-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + Configuration + + + `-mb-[0.14rem] px-4 py-2 text-sm font-medium whitespace-nowrap outline-hidden disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + TLS ({tlsEnabled ? "Enabled" : "Disabled"}) + + + + +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+
+ + ( + + + Enable TLS + + + )} + /> + ( + +