diff --git a/.env.example b/.env.example index 5220d5a03..f67488c23 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,7 @@ # Keys # Required key for platform encryption/decryption ops # THIS IS A SAMPLE ENCRYPTION KEY AND SHOULD NEVER BE USED FOR PRODUCTION -ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 +ENCRYPTION_KEY=VVHnGZ0w98WLgISK4XSJcagezuG6EWRFTk48KE4Y5Mw= # JWT # Required secrets to sign JWT tokens diff --git a/.env.migration.example b/.env.migration.example index 2c5f5b957..dfc54d171 100644 --- a/.env.migration.example +++ b/.env.migration.example @@ -1,2 +1,2 @@ -DB_CONNECTION_URI= +DB_CONNECTION_URI=postgres://infisical:infisical@localhost:5432/infisical AUDIT_LOGS_DB_CONNECTION_URI= diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 16a828578..a8a64e7b4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -20,6 +20,4 @@ --- -- [ ] I have read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview), agreed and acknowledged the [code of conduct](https://infisical.com/docs/contributing/getting-started/code-of-conduct). 📝 - - \ No newline at end of file +- [ ] I have read the [contributing guide](https://infisical.com/docs/contributing/getting-started/overview), agreed and acknowledged the [code of conduct](https://infisical.com/docs/contributing/getting-started/code-of-conduct). 📝 \ No newline at end of file diff --git a/.github/workflows/run-backend-bdd-tests.yml b/.github/workflows/run-backend-bdd-tests.yml new file mode 100644 index 000000000..7b54aa44e --- /dev/null +++ b/.github/workflows/run-backend-bdd-tests.yml @@ -0,0 +1,109 @@ +name: "Run backend BDD tests" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/**" + - "!backend/README.md" + - "!backend/.*" + - "backend/.eslintrc.js" + workflow_call: + +jobs: + run-backend-bdd-tests: + name: Run BDD tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Free up disk space + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /opt/ghc + sudo rm -rf "/usr/local/share/boost" + sudo rm -rf "$AGENT_TOOLSDIRECTORY" + docker system prune -af + + - name: ☁️ Checkout source + uses: actions/checkout@v3 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install Python + run: uv python install + - uses: KengoTODA/actions-setup-docker-compose@v1 + if: ${{ env.ACT }} + name: Install `docker compose` for local simulations + with: + version: "2.14.2" + - name: 🔧 Setup Node 20 + uses: actions/setup-node@v3 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: backend/package-lock.json + - name: Install dependencies + run: npm install + working-directory: backend + + - name: Output .env file and enable feature flags for BDD tests + run: | + cp .env.example .env + echo "ACME_DEVELOPMENT_MODE=true" >> .env + echo "ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES={\"localhost\": \"host.docker.internal:8087\", \"infisical.com\": \"host.docker.internal:8087\", \"example.com\": \"host.docker.internal:8087\"}" >> .env + echo "BDD_NOCK_API_ENABLED=true" >> .env + # Skip upstream validation, otherwise the ACME client for the upstream will try to + # validate the DNS records, which will fail because the DNS records are not actually created. + echo "ACME_SKIP_UPSTREAM_VALIDATION=true" >> .env + # We are not using FIPS mode, need a different encryption key for BDD tests + NEW_ENCRYPTION_KEY=6c1fe4e407b8911c104518103505b218 + sed -i "s#ENCRYPTION_KEY=.*#ENCRYPTION_KEY=$NEW_ENCRYPTION_KEY#" .env + # Enable ACME feature in license for BDD tests + sed -i 's/pkiAcme: .*/pkiAcme: true,/g' backend/src/ee/services/license/license-fns.ts + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + driver-opts: | + image=moby/buildkit:latest + - name: Build Infisical backend Docker image with caching + uses: docker/bake-action@v5 + timeout-minutes: 30 + with: + files: docker-compose.bdd.yml + targets: backend + load: true + # Uncomment this to force a rebuild of the image + # no-cache: true + set: | + *.cache-from=type=gha,scope=infisical-backend-bdd-tests + *.cache-to=type=gha,mode=max,scope=infisical-backend-bdd-tests + - name: Start Infisical + run: docker compose -f docker-compose.bdd.yml up -d + - name: Wait for API to be ready + uses: nick-fields/retry@v3 + with: + timeout_seconds: 60 + max_attempts: 30 + command: | + curl -f -X GET http://localhost:8080/api/v1/admin/config + - name: Run bdd tests + run: npm run test:bdd + working-directory: backend + env: + INFISICAL_API_URL: http://localhost:8080 + BOOTSTRAP_INFISICAL: "1" + - name: cleanup + run: | + docker compose -f "docker-compose.bdd.yml" down + - name: Dump backend logs + if: always() # Ensures this runs even if previous steps fail + run: | + mkdir -p logs + docker compose -f docker-compose.bdd.yml logs backend > logs/backend.log 2>&1 || true + - name: Upload backend logs as artifact + if: always() # Always upload, even on failure/cancellation + uses: actions/upload-artifact@v4 + with: + name: backend-logs-${{ github.run_id }} + path: logs/backend.log + retention-days: 7 + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index f2a23324b..b4e9a07c2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,6 @@ frontend-build cli/infisical-merge cli/test/infisical-merge /backend/binary +backend/bdd/.bdd-infisical-bootstrap-result.json /npm/bin diff --git a/README.md b/README.md index 8c7c0e82b..1a44117a4 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ We're on a mission to make security tooling more accessible to everyone, not jus ## Getting started -Check out the [Quickstart Guides](https://infisical.com/docs/getting-started/introduction) +Check out the [Quickstart Guides](https://infisical.com/docs/documentation/getting-started/overview) | Use Infisical Cloud | Deploy Infisical on premise | | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py index b355f98ae..9a2e9f90b 100644 --- a/backend/bdd/features/environment.py +++ b/backend/bdd/features/environment.py @@ -1,26 +1,214 @@ +import json import os +import pathlib +import typing + import httpx from behave.runner import Context from dotenv import load_dotenv +from faker import Faker +import logging + +from features.steps.utils import clean_all_nock, restore_nock load_dotenv() +logger = logging.getLogger(__name__) BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") +PEBBLE_URL = os.environ.get("PEBBLE_URL", "https://pebble:14000/dir") PROJECT_ID = os.environ.get("PROJECT_ID") CERT_CA_ID = os.environ.get("CERT_CA_ID") CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID") AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") +BOOTSTRAP_INFISICAL = int(os.environ.get("BOOTSTRAP_INFISICAL", 0)) + + +# Called mostly from a CI to setup the new Infisical instance to get it ready for BDD tests +def bootstrap_infisical(context: Context): + bootstrap_result_file = pathlib.Path.cwd() / ".bdd-infisical-bootstrap-result.json" + if bootstrap_result_file.exists(): + logger.info( + "Bootstrap result file exists at %s, loading it now", bootstrap_result_file + ) + return json.loads(bootstrap_result_file.read_text()) + + faker = Faker() + with httpx.Client(base_url=BASE_URL) as client: + resp = client.post( + "/api/v1/admin/signup", + json={ + "email": f"{faker.user_name()}@infisical.com", + "password": faker.password(), + "firstName": faker.first_name(), + "lastName": faker.last_name(), + }, + ) + resp.raise_for_status() + body = resp.json() + org = body["organization"] + user = body["user"] + temp_token = body["token"] + + resp = client.post( + "/api/v3/auth/select-organization", + headers={"Authorization": f"Bearer {temp_token}"}, + json={"organizationId": org["id"]}, + ) + resp.raise_for_status() + body = resp.json() + temp_token = body["token"] + + resp = client.post( + "/api/v1/auth/token", + headers={"Authorization": f"Bearer {temp_token}"}, + json={}, + ) + resp.raise_for_status() + body = resp.json() + auth_token = body["token"] + headers = dict(authorization=f"Bearer {auth_token}") + + project_slug = faker.slug() + resp = client.post( + "/api/v1/projects", + headers=headers, + json={ + "projectName": project_slug, + "projectDescription": faker.paragraph(), + "template": "default", + "type": "cert-manager", + }, + ) + resp.raise_for_status() + body = resp.json() + project = body["project"] + + ca_slug = faker.slug() + resp = client.post( + "/api/v1/pki/ca/internal", + headers=headers, + json={ + "projectId": project["id"], + "name": ca_slug, + "type": "internal", + "status": "active", + "enableDirectIssuance": True, + "configuration": { + "type": "root", + "organization": "Infisican Inc", + "ou": "", + "country": "", + "province": "", + "locality": "", + "commonName": "", + "notAfter": "2035-11-07", + "maxPathLength": -1, + "keyAlgorithm": "RSA_2048", + }, + }, + ) + resp.raise_for_status() + body = resp.json() + ca = body + + cert_template_slug = faker.slug() + resp = client.post( + "/api/v2/certificate-templates", + headers=headers, + json={ + "projectId": project["id"], + "name": cert_template_slug, + "description": "", + "subject": [{"type": "common_name", "allowed": ["*"]}], + "sans": [{"type": "dns_name", "allowed": ["*"]}], + "keyUsages": { + "required": [], + "allowed": [ + "digital_signature", + "non_repudiation", + "key_encipherment", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + "encipher_only", + "decipher_only", + ], + }, + "extendedKeyUsages": { + "required": [], + "allowed": [ + "client_auth", + "server_auth", + "code_signing", + "email_protection", + "ocsp_signing", + "time_stamping", + ], + }, + "algorithms": { + "signature": [ + "SHA256-RSA", + "SHA512-RSA", + "SHA384-ECDSA", + "SHA384-RSA", + "SHA256-ECDSA", + "SHA512-ECDSA", + ], + "keyAlgorithm": [ + "RSA-2048", + "RSA-4096", + "ECDSA-P384", + "RSA-3072", + "ECDSA-P256", + "ECDSA-P521", + ], + }, + "validity": {"max": "365d"}, + }, + ) + resp.raise_for_status() + body = resp.json() + cert_template = body["certificateTemplate"] + + bootstrap_result = dict( + org=org, + user=user, + project=project, + ca=ca, + cert_template=cert_template, + auth_token=auth_token, + ) + bootstrap_result_file.write_text(json.dumps(bootstrap_result)) + return bootstrap_result def before_all(context: Context): - context.vars = { - "BASE_URL": BASE_URL, - "PROJECT_ID": PROJECT_ID, - "CERT_CA_ID": CERT_CA_ID, - "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, - "AUTH_TOKEN": AUTH_TOKEN, - } - context.http_client = httpx.Client( - base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} - ) + if BOOTSTRAP_INFISICAL: + details = bootstrap_infisical(context) + context.vars = { + "BASE_URL": BASE_URL, + "PEBBLE_URL": PEBBLE_URL, + "PROJECT_ID": details["project"]["id"], + "CERT_CA_ID": details["ca"]["id"], + "CERT_TEMPLATE_ID": details["cert_template"]["id"], + "AUTH_TOKEN": details["auth_token"], + } + else: + context.vars = { + "BASE_URL": BASE_URL, + "PEBBLE_URL": PEBBLE_URL, + "PROJECT_ID": PROJECT_ID, + "CERT_CA_ID": CERT_CA_ID, + "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, + "AUTH_TOKEN": AUTH_TOKEN, + } + context.http_client = httpx.Client(base_url=BASE_URL) + + +def after_scenario(context: Context, scenario: typing.Any): + if hasattr(context, "web_server"): + context.web_server.shutdown_and_server_close() + clean_all_nock(context) + restore_nock(context) diff --git a/backend/bdd/features/pki/acme/access-control.feature b/backend/bdd/features/pki/acme/access-control.feature new file mode 100644 index 000000000..6615d00f8 --- /dev/null +++ b/backend/bdd/features/pki/acme/access-control.feature @@ -0,0 +1,273 @@ +Feature: Access Control + + Scenario Outline: Access resources across different account + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account, the invalid payload is going to trigger other errors instead of 404, this is to make sure + # that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_payload": "" + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + Scenario Outline: Access resources across a different profiles + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account under their profile, the invalid payload is going to trigger other errors instead of + # 404, this is to make sure that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + Then I memorize response with jq ".certificateProfile.id" as profile_id + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + + Scenario Outline: Access resources across a different profile with the same key pair + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account0 + Then I memorize acme_account0.uri with jq "capture("/(?[^/]+)$") | .id" as account0_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account0.uri}" + }, + "payload": {"invalid": "payload"} + } + """ + # With original owner account under their profile, the invalid payload is going to trigger other errors instead of + # 404, this is to make sure that our URLs are actually correct + Then the value response.status_code should not be equal to 404 + And I put away current ACME client as client0 + + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + Then I memorize response with jq ".certificateProfile.id" as profile_id + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" with the key pair from client0 + Then I register a new ACME account with email maidu@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account1 + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account1.uri}" + }, + "raw_payload": "" + } + """ + Then the value response.status_code should be equal to 404 + + Examples: Endpoints + | src_var | jq | dest_var | url | payload | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account0_id}/orders | | + | order | . | not_used | {order.uri} | | + | order | . | not_used | {order.uri}/finalize | {\"csr\": \"\"} | + | order | . | not_used | {order.uri}/certificate | | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | {} | + + + Scenario Outline: URL mismatch + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + Then I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + + Then I peak and memorize the next nonce as nonce + Then I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + Then the value response with jq ".status" should be equal to 400 + Then the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:malformed" + Then the value response with jq ".detail" should be equal to "" + + Examples: Endpoints + | src_var | jq | dest_var | actual_url | bad_url | error_detail | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | BAD | Invalid URL in the protected header | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | https://evil.com/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | URL mismatch in the protected header | + | order | . | not_used | {order.uri} | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri} | https://example.com/acmes/orders/FOOBAR | URL mismatch in the protected header | + | order | . | not_used | {order.uri}/finalize | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri}/finalize | https://example.com/acmes/orders/FOOBAR/finalize | URL mismatch in the protected header | + | order | . | not_used | {order.uri}/certificate | BAD | Invalid URL in the protected header | + | order | . | not_used | {order.uri}/certificate | https://example.com/acmes/orders/FOOBAR/certificate | URL mismatch in the protected header | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | BAD | Invalid URL in the protected header | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | https://example.com/acmes/auths/FOOBAR | URL mismatch in the protected header | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | BAD | Invalid URL in the protected header | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | https://example.com/acmes/challenges/FOOBAR | URL mismatch in the protected header | diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature index 7e1d67a93..589c5ab24 100644 --- a/backend/bdd/features/pki/acme/account.feature +++ b/backend/bdd/features/pki/acme/account.feature @@ -2,5 +2,53 @@ Feature: Account Scenario: Create a new account Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value acme_account.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/(.+) + + Scenario: Find an existing account + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri as account_uri + And I find the existing ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value acme_account.uri should be equal to "{account_uri}" + + Scenario: Create a new account without EAB + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com without EAB + And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired" + + Scenario Outline: Scenario: Create a new account with bad EAB credentials + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "" with secret "" as acme_account + And the value error with jq ".type" should be equal to "" + And the value error with jq ".detail" should be equal to "" + + Examples: Bad Credentials + | eab_kid | eab_secret | error_type | error_msg | + | bad | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | YmFkLXNjcmV0Cg== | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | {acme_profile.eab_kid} | ABC{acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | Invalid external account binding JWS signature | + | bad | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch | + | 4bc7959c-fe2d-4447-ae91-0cd893667af6 | {acme_profile.eab_secret} | urn:ietf:params:acme:error:externalAccountRequired | External account binding KID mismatch | + + Scenario Outline: Scenario: Create a new account with bad EAB url + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + And I use a different new-account URL "" for EAB signature + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And the value error with jq ".type" should be equal to "urn:ietf:params:acme:error:externalAccountRequired" + And the value error with jq ".detail" should be equal to "External account binding URL mismatch" + + Examples: Bad URLs + | url | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account?foo=bar | + | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account#foobar | + | {BASE_URL}/acme/new-account | + | https://example.com/api/v1/pki/acme/profiles/{acme_profile.id}/new-account-bad | + | bad | diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature index 4605e2eef..46cc9d4e2 100644 --- a/backend/bdd/features/pki/acme/auth.feature +++ b/backend/bdd/features/pki/acme/auth.feature @@ -2,8 +2,7 @@ Feature: Authorization Scenario: Get authorization Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -13,11 +12,11 @@ Feature: Authorization } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) - Then the value order.authorizations[0].body with jq ".status" should be equal to "pending" - Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + And the value order.authorizations[0].body with jq ".status" should be equal to "pending" + And the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json """ [ { @@ -26,8 +25,8 @@ Feature: Authorization } ] """ - Then the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] - Then the value order.authorizations[0].body with jq ".identifier" should be equal to json + And the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] + And the value order.authorizations[0].body with jq ".identifier" should be equal to json """ { "type": "dns", diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature index 7ce1ecc8c..3c292e8ba 100644 --- a/backend/bdd/features/pki/acme/cert-profile.feature +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -2,8 +2,8 @@ Feature: ACME Cert Profile Scenario: Create a cert profile Given I make a random slug as profile_slug - Given I use AUTH_TOKEN for authentication - When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + And I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -16,16 +16,16 @@ Feature: ACME Cert Profile } """ Then the value response.status_code should be equal to 200 - Then the value response with jq ".certificateProfile.id" should be present - Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" - Then the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" - Then the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" - Then the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" + And the value response with jq ".certificateProfile.id" should be present + And the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" + And the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" + And the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" + And the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" Scenario: Reveal EAB secret Given I make a random slug as profile_slug - Given I use AUTH_TOKEN for authentication - When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + And I use AUTH_TOKEN for authentication + When I send a "POST" request to "/api/v1/pki/certificate-profiles" with JSON payload """ { "projectId": "{PROJECT_ID}", @@ -39,11 +39,11 @@ Feature: ACME Cert Profile """ Then the value response.status_code should be equal to 200 And I memorize response with jq ".certificateProfile.id" as profile_id - When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + When I send a "GET" request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" Then the value response.status_code should be equal to 200 - Then the value response with jq ".eabKid" should be equal to "{profile_id}" - Then the value response with jq ".eabSecret" should be present + And the value response with jq ".eabKid" should be equal to "{profile_id}" + And the value response with jq ".eabSecret" should be present And I memorize response with jq ".eabKid" as eab_kid And I memorize response with jq ".eabSecret" as eab_secret - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature index ba9970e43..67f73aab2 100644 --- a/backend/bdd/features/pki/acme/challenge.feature +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -2,8 +2,7 @@ Feature: Challenge Scenario: Validate challenge Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -12,12 +11,198 @@ Feature: Challenge "COMMON_NAME": "localhost" } """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I select challenge with type http-01 for domain localhost from order at order as challenge - Then I serve challenge response for challenge at localhost - Then I tell ACME server that challenge is ready to be verified - Then I poll and finalize the ACME order order as finalized_order - Then the value finalized_order.body with jq ".status" should be equal to "valid" - # TODO: check the fullchain pem content of the order + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" + + Scenario: Validate challenges for multiple domains + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + And I add subject alternative name to certificate signing request csr + """ + [ + "infisical.com", + "example.com" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I pass all challenges with type http-01 for order in order + And I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq ".subject.common_name" should be equal to "localhost" + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "example.com", + "infisical.com" + ] + """ + + Scenario: Did not finish all challenges + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + And I add subject alternative name to certificate signing request csr + """ + [ + "infisical.com" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + + # the localhost auth should be valid + And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "localhost")) | first | .uri" as localhost_auth + And I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{localhost_auth}" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{localhost_auth}", + "kid": "{acme_account.uri}" + } + } + """ + Then the value response.status_code should be equal to 200 + And the value response with jq ".status" should be equal to "valid" + + # the infisical.com auth should still be pending + And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "infisical.com")) | first | .uri" as infisical_auth + And I memorize response.headers with jq ".["replay-nonce"]" as nonce + When I send a raw ACME request to "{infisical_auth}" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{infisical_auth}", + "kid": "{acme_account.uri}" + } + } + """ + Then the value response.status_code should be equal to 200 + And the value response with jq ".status" should be equal to "pending" + + # the order should be pending as well + And I memorize response.headers with jq ".["replay-nonce"]" as nonce + When I send a raw ACME request to "{order.uri}" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{order.uri}", + "kid": "{acme_account.uri}" + } + } + """ + Then the value response.status_code should be equal to 200 + And the value response with jq ".status" should be equal to "pending" + + # finalize should not be allowed when all auths are not valid yet + And I memorize response.headers with jq ".["replay-nonce"]" as nonce + When I send a raw ACME request to "{order.body.finalize}" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{order.body.finalize}", + "kid": "{acme_account.uri}" + }, + "payload": { + "csr": "{csr_pem}" + } + } + """ + Then the value response.status_code should be equal to 400 + Then the value response with jq ".status" should be equal to 400 + Then the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:orderNotReady" + Then the value response with jq ".detail" should be equal to "ACME order is not ready" + + Scenario: CSR names mismatch with order identifier + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "example.com" + } + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order", + "kid": "{acme_account.uri}" + }, + "payload": { + "identifiers": [ + { "type": "dns", "value": "localhost" }, + { "type": "dns", "value": "infisical.com" } + ] + } + } + """ + Then the value response.status_code should be equal to 201 + And I memorize response with jq ".finalize" as finalize_url + And I memorize response.headers with jq ".["replay-nonce"]" as nonce + And I memorize response as order + And I pass all challenges with type http-01 for order in order + And I encode CSR csr_pem as JOSE Base-64 DER as base64_csr_der + When I send a raw ACME request to "{finalize_url}" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{finalize_url}", + "kid": "{acme_account.uri}" + }, + "payload": { + "csr": "{base64_csr_der}" + } + } + """ + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badCSR" + And the value response with jq ".detail" should be equal to "Invalid CSR: Common name + SANs mismatch with order identifiers" diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature index 481a3337a..664ff7457 100644 --- a/backend/bdd/features/pki/acme/dicrectory.feature +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -2,13 +2,13 @@ Feature: Directory Scenario: Get the directory of ACME service urls Given I have an ACME cert profile as "acme_profile" - When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + When I send a "GET" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then the response status code should be "200" - Then the response body should match JSON value - """ - { - "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", - "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", - "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" - } - """ + And the response body should match JSON value + """ + { + "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", + "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", + "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + } + """ diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature new file mode 100644 index 000000000..26bfd84ad --- /dev/null +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -0,0 +1,180 @@ +Feature: External CA + + Scenario: Issue a certificate from an external CA + Given I create a Cloudflare connection as cloudflare + Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id + Given I create a external ACME CA with the following config as ext_ca + """ + { + "dnsProviderConfig": { + "provider": "cloudflare", + "hostedZoneId": "MOCK_ZONE_ID" + }, + "directoryUrl": "{PEBBLE_URL}", + "accountEmail": "fangpen@infisical.com", + "dnsAppConnectionId": "{app_conn_id}", + "eabKid": "", + "eabHmacKey": "" + } + """ + Then I memorize ext_ca with jq ".id" as ext_ca_id + Given I create a certificate template with the following config as cert_template + """ + { + "subject": [ + { + "type": "common_name", + "allowed": [ + "*" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*" + ] + } + ], + "keyUsages": { + "required": [], + "allowed": [ + "digital_signature", + "key_encipherment", + "non_repudiation", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + "encipher_only", + "decipher_only" + ] + }, + "extendedKeyUsages": { + "required": [], + "allowed": [ + "client_auth", + "server_auth", + "code_signing", + "email_protection", + "ocsp_signing", + "time_stamping" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA", + "SHA512-RSA", + "SHA384-ECDSA", + "SHA384-RSA", + "SHA256-ECDSA", + "SHA512-ECDSA" + ], + "keyAlgorithm": [ + "RSA-2048", + "RSA-4096", + "ECDSA-P384", + "RSA-3072", + "ECDSA-P256", + "ECDSA-P521" + ] + }, + "validity": { + "max": "365d" + } + } + """ + Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id + Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + # Pebble has a strict rule to only takes SANs + Then I add subject alternative name to certificate signing request csr + """ + [ + "localhost" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + Given I intercept outgoing requests + """ + [ + { + "scope": "https://api.cloudflare.com:443", + "method": "POST", + "path": "/client/v4/zones/MOCK_ZONE_ID/dns_records", + "status": 200, + "response": { + "result": { + "id": "A2A6347F-88B5-442D-9798-95E408BC7701", + "name": "Mock Account", + "type": "standard", + "settings": { + "enforce_twofactor": false, + "api_access_enabled": null, + "access_approval_expiry": null, + "abuse_contact_email": null, + "user_groups_ui_beta": false + }, + "legacy_flags": { + "enterprise_zone_quota": { + "maximum": 0, + "current": 0, + "available": 0 + } + }, + "created_on": "2013-04-18T00:41:02.215243Z" + }, + "success": true, + "errors": [], + "messages": [] + }, + "responseIsBinary": false + }, + { + "scope": "https://api.cloudflare.com:443", + "method": "GET", + "path": { + "regex": "/client/v4/zones/[^/]+/dns_records\\?" + }, + "status": 200, + "response": { + "result": [], + "success": true, + "errors": [], + "messages": [], + "result_info": { + "page": 1, + "per_page": 100, + "count": 0, + "total_count": 0, + "total_pages": 1 + } + }, + "responseIsBinary": false + } + ] + """ + Then I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + # Note: somehow Pebble is issuing a cert without common name but just SANs + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "localhost" + ] + """ \ No newline at end of file diff --git a/backend/bdd/features/pki/acme/nonce.feature b/backend/bdd/features/pki/acme/nonce.feature index 7bbeb3b9d..9a55ae284 100644 --- a/backend/bdd/features/pki/acme/nonce.feature +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -2,6 +2,106 @@ Feature: Nonce Scenario: Generate a new nonce Given I have an ACME cert profile as "acme_profile" - When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" + When I send a "HEAD" request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" Then the response status code should be "200" - Then the response header "Replay-Nonce" should contains non-empty value + And the response header "Replay-Nonce" should contains non-empty value + + Scenario Outline: Send a bad nonce to account endpoints + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "oFvnlFP1wIhRlYS2jTaXbA", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce" + And the value response with jq ".detail" should be equal to "Invalid nonce" + + Examples: Endpoints + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order | + | order | . | not_used | {order.uri} | + | order | . | not_used | {order.uri}/finalize | + | order | . | not_used | {order.uri}/certificate | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | + + Scenario Outline: Send the same nonce twice + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I memorize acme_account.uri with jq "capture("/(?[^/]+)$") | .id" as account_id + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I peak and memorize the next nonce as nonce_value + When I send a raw ACME request to "/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce_value}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 200 + And I memorize with jq "" as + When I send a raw ACME request to "" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce_value}", + "url": "", + "kid": "{acme_account.uri}" + }, + "payload": {} + } + """ + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:badNonce" + And the value response with jq ".detail" should be equal to "Invalid nonce" + + Examples: Endpoints + | src_var | jq | dest_var | url | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/accounts/{account_id}/orders | + | order | . | not_used | {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order | + | order | . | not_used | {order.uri} | + | order | . | not_used | {order.uri}/finalize | + | order | . | not_used | {order.uri}/certificate | + | order | .authorizations[0].uri | auth_uri | {auth_uri} | + | order | .authorizations[0].body.challenges[0].url | challenge_uri | {challenge_uri} | diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature index 046dcda55..19f467f00 100644 --- a/backend/bdd/features/pki/acme/order.feature +++ b/backend/bdd/features/pki/acme/order.feature @@ -2,8 +2,7 @@ Feature: Order Scenario: Create a new order Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -13,18 +12,17 @@ Feature: Order } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) - Then the value order.body with jq ".status" should be equal to "pending" - Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] - Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) + And the value order.body with jq ".status" should be equal to "pending" + And the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + And the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true Scenario: Create a new order with SANs Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -33,17 +31,17 @@ Feature: Order "COMMON_NAME": "localhost" } """ - Then I add subject alternative name to certificate signing request csr + And I add subject alternative name to certificate signing request csr """ [ "example.com", "infisical.com" ] """ - Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json """ [ {"type": "dns", "value": "example.com"}, @@ -54,8 +52,7 @@ Feature: Order Scenario: Fetch an order Given I have an ACME cert profile as "acme_profile" - When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory -# # TODO: make it I have an account already instead? + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account When I create certificate signing request as csr Then I add names to certificate signing request csr @@ -65,10 +62,81 @@ Feature: Order } """ Then I create a RSA private key pair as cert_key - Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format - Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order - Then I send an ACME post-as-get to order.uri as fetched_order - Then the value fetched_order with jq ".status" should be equal to "pending" - Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] - Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize - Then the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I send an ACME post-as-get to order.uri as fetched_order + And the value fetched_order with jq ".status" should be equal to "pending" + And the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + And the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + And the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + + Scenario Outline: Create an order with invalid identifier types + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order", + "kid": "{acme_account.uri}" + }, + "payload": { + "identifiers": [ + { "type": "", "value": "www.example.org" } + ] + } + } + """ + + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier" + And the value response with jq ".detail" should be equal to "Only DNS identifiers are supported" + + Examples: Bad Identifier Types + | identifier_type | + | bad | + | ip | + | email | + + Scenario Outline: Create an order with invalid identifier values + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + And I peak and memorize the next nonce as nonce + When I send a raw ACME request to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + """ + { + "protected": { + "alg": "RS256", + "nonce": "{nonce}", + "url": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order", + "kid": "{acme_account.uri}" + }, + "payload": { + "identifiers": [ + { "type": "dns", "value": "" } + ] + } + } + """ + + Then the value response.status_code should be equal to 400 + And the value response with jq ".status" should be equal to 400 + And the value response with jq ".type" should be equal to "urn:ietf:params:acme:error:unsupportedIdentifier" + And the value response with jq ".detail" should be equal to "Invalid DNS identifier" + + Examples: Bad Identifier Vluaes + | identifier_value | + | 127.0.0.1 | + | 192.168.123.111 | + | 169.254.169.254 | + | ../../etc/passwd | + | !@#$ | + | ! | + | https://evil.com | + diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index d0fa627cd..46b10c13e 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -1,29 +1,33 @@ import json import logging -import os import re -import threading +import urllib.parse -import httpx +import acme.client import jq -import requests -import glom from faker import Faker from acme import client from acme import messages from acme import standalone +from acme.jws import Signature from behave.runner import Context from behave import given from behave import when from behave import then from josepy.jwk import JWKRSA -from josepy import JSONObjectWithFields +from josepy import json_util from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes +from features.steps.utils import define_nock, clean_all_nock, restore_nock +from utils import replace_vars, with_nocks +from utils import eval_var +from utils import prepare_headers + + ACC_KEY_BITS = 2048 ACC_KEY_PUBLIC_EXPONENT = 65537 logger = logging.getLogger(__name__) @@ -37,96 +41,6 @@ class AcmeProfile: self.eab_secret = eab_secret -def replace_vars(payload: dict | list | int | float | str, vars: dict): - if isinstance(payload, dict): - return { - replace_vars(key, vars): replace_vars(value, vars) - for key, value in payload.items() - } - elif isinstance(payload, list): - return [replace_vars(item, vars) for item in payload] - elif isinstance(payload, str): - return payload.format(**vars) - else: - return payload - - -def parse_glom_path(path_str: str) -> glom.Path: - """ - Parse a glom path string with 'attr[index]' syntax into a Path object. - - Examples: - >>> parse_glom_path('authorizations[0]') == Path('authorizations', 0) - True - >>> parse_glom_path('data.items[1].name') == Path('data', 'items', 1, 'name') - True - >>> parse_glom_path('user.addresses[0].street') == Path('user', 'addresses', 0, 'street') - True - """ - parts = [] - - # Split by dots, but preserve bracketed content - tokens = re.split(r"(? dict | None: - headers = {} - auth_token = getattr(context, "auth_token", None) - if auth_token is not None: - headers["authorization"] = "Bearer {}".format(auth_token) - if not headers: - return None - return headers - - @given("I make a random {faker_type} as {var_name}") def step_impl(context: Context, faker_type: str, var_name: str): context.vars[var_name] = getattr(faker, faker_type)() @@ -134,12 +48,39 @@ def step_impl(context: Context, faker_type: str, var_name: str): @given('I have an ACME cert profile as "{profile_var}"') def step_impl(context: Context, profile_var: str): - # TODO: Fixed value for now, just to make test much easier, - # we should call infisical API to create such profile instead - # in the future - profile_id = os.getenv("PROFILE_ID") - kid = profile_id - secret = os.getenv("EAB_SECRET") + profile_id = context.vars.get("PROFILE_ID") + secret = context.vars.get("EAB_SECRET") + if profile_id is not None and secret is not None: + kid = profile_id + else: + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/pki/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": {}, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + context.vars[profile_var] = AcmeProfile( profile_id, eab_kid=kid, @@ -147,12 +88,204 @@ def step_impl(context: Context, profile_var: str): ) +@given("I create a Cloudflare connection as {var_name}") +def step_impl(context: Context, var_name: str): + jwt_token = context.vars["AUTH_TOKEN"] + conn_slug = faker.slug() + mock_account_id = "MOCK_ACCOUNT_ID" + with with_nocks( + context, + definitions=[ + { + "scope": "https://api.cloudflare.com:443", + "method": "GET", + "path": f"/client/v4/accounts/{mock_account_id}", + "status": 200, + "response": { + "result": { + "id": "A2A6347F-88B5-442D-9798-95E408BC7701", + "name": "Mock Account", + "type": "standard", + "settings": { + "enforce_twofactor": True, + "api_access_enabled": None, + "access_approval_expiry": None, + "abuse_contact_email": None, + "user_groups_ui_beta": False, + }, + "legacy_flags": { + "enterprise_zone_quota": { + "maximum": 0, + "current": 0, + "available": 0, + } + }, + "created_on": "2013-04-18T00:41:02.215243Z", + }, + "success": True, + "errors": [], + "messages": [], + }, + "responseIsBinary": False, + } + ], + ): + response = context.http_client.post( + "/api/v1/app-connections/cloudflare", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "name": conn_slug, + "description": "", + "method": "api-token", + "credentials": { + "apiToken": "MOCK_API_TOKEN", + "accountId": mock_account_id, + }, + }, + ) + response.raise_for_status() + context.vars[var_name] = response + + +@given("I create a external ACME CA with the following config as {var_name}") +def step_impl(context: Context, var_name: str): + jwt_token = context.vars["AUTH_TOKEN"] + ca_slug = faker.slug() + config = replace_vars(json.loads(context.text), context.vars) + response = context.http_client.post( + "/api/v1/pki/ca/acme", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "name": ca_slug, + "type": "acme", + "status": "active", + "enableDirectIssuance": True, + "configuration": config, + }, + ) + response.raise_for_status() + context.vars[var_name] = response + + +@given("I create a certificate template with the following config as {var_name}") +def step_impl(context: Context, var_name: str): + jwt_token = context.vars["AUTH_TOKEN"] + template_slug = faker.slug() + config = replace_vars(json.loads(context.text), context.vars) + response = context.http_client.post( + "/api/v2/certificate-templates", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "name": template_slug, + "description": "", + } + | config, + ) + response.raise_for_status() + context.vars[var_name] = response + + +@given( + 'I create an ACME profile with ca {ca_id} and template {template_id} as "{profile_var}"' +) +def step_impl(context: Context, ca_id: str, template_id: str, profile_var: str): + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/pki/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": replace_vars(ca_id, context.vars), + "certificateTemplateId": replace_vars(template_id, context.vars), + "acmeConfig": {}, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + +@given('I have an ACME cert profile with external ACME CA as "{profile_var}"') +def step_impl(context: Context, profile_var: str): + profile_id = context.vars.get("PROFILE_ID") + secret = context.vars.get("EAB_SECRET") + if profile_id is not None and secret is not None: + kid = profile_id + else: + profile_slug = faker.slug() + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/pki/certificate-profiles", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "projectId": context.vars["PROJECT_ID"], + "slug": profile_slug, + "description": "ACME Profile created by BDD test", + "enrollmentType": "acme", + "caId": context.vars["CERT_CA_ID"], + "certificateTemplateId": context.vars["CERT_TEMPLATE_ID"], + "acmeConfig": {}, + }, + ) + response.raise_for_status() + resp_json = response.json() + profile_id = resp_json["certificateProfile"]["id"] + kid = profile_id + + response = context.http_client.get( + f"/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal", + headers=dict(authorization="Bearer {}".format(jwt_token)), + ) + response.raise_for_status() + resp_json = response.json() + secret = resp_json["eabSecret"] + + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + +@given("I intercept outgoing requests") +def step_impl(context: Context): + definitions = replace_vars(json.loads(context.text), context.vars) + define_nock(context, definitions) + + +@then("I reset requests interceptions") +def step_impl(context: Context): + clean_all_nock(context) + restore_nock(context) + + @given("I use {token_var} for authentication") def step_impl(context: Context, token_var: str): context.auth_token = eval_var(context, token_var) -@when('I send a {method} request to "{url}"') +@when('I send a "{method}" request to "{url}"') def step_impl(context: Context, method: str, url: str): logger.debug("Sending %s request to %s", method, url) response = context.http_client.request( @@ -166,7 +299,7 @@ def step_impl(context: Context, method: str, url: str): pass -@when('I send a {method} request to "{url}" with JSON payload') +@when('I send a "{method}" request to "{url}" with JSON payload') def step_impl(context: Context, method: str, url: str): json_payload = json.loads(context.text) json_payload = replace_vars(json_payload, context.vars) @@ -187,23 +320,34 @@ def step_impl(context: Context, method: str, url: str): logger.debug("Response JSON payload: %r", response.json()) -@when("I have an ACME client connecting to {url}") -def step_impl(context: Context, url: str): - private_key = rsa.generate_private_key( - public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS - ) - pem_bytes = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - acc_jwk = JWKRSA.load(pem_bytes) +def create_acme_client(context: Context, url: str, acc_jwk: JWKRSA | None = None): + if acc_jwk is None: + private_key = rsa.generate_private_key( + public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + acc_jwk = JWKRSA.load(pem_bytes) net = client.ClientNetwork(acc_jwk) directory_url = url.format(**context.vars) directory = client.ClientV2.get_directory(directory_url, net) context.acme_client = client.ClientV2(directory, net=net) +@when('I have an ACME client connecting to "{url}"') +def step_impl(context: Context, url: str): + create_acme_client(context, url) + + +@when('I have an ACME client connecting to "{url}" with the key pair from {client_var}') +def step_impl(context: Context, url: str, client_var: str): + another_client = eval_var(context, client_var, as_json=False) + create_acme_client(context, url, acc_jwk=another_client.net.key) + + @then('the response status code should be "{expected_status_code:d}"') def step_impl(context: Context, expected_status_code: int): assert context.vars["response"].status_code == expected_status_code, ( @@ -228,24 +372,131 @@ def step_impl(context: Context): assert payload == replaced, f"{payload} != {replaced}" -@then( - 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' -) -def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): +@when('I use a different new-account URL "{url}" for EAB signature') +def step_impl(context: Context, url: str): + context.alt_eab_url = replace_vars(url, context.vars) + + +def register_account_with_eab( + context: Context, + email: str, + kid: str, + secret: str, + account_var: str, + only_return_existing: bool = False, +): acme_client = context.acme_client account_public_key = acme_client.net.key.public_key() + if hasattr(context, "alt_eab_url"): + eab_directory = messages.Directory.from_json( + {"newAccount": context.alt_eab_url} + ) + else: + eab_directory = acme_client.directory eab = messages.ExternalAccountBinding.from_data( account_public_key=account_public_key, kid=replace_vars(kid, context.vars), hmac_key=replace_vars(secret, context.vars), - directory=acme_client.directory, + directory=eab_directory, hmac_alg="HS256", ) registration = messages.NewRegistration.from_data( email=email, external_account_binding=eab, + only_return_existing=only_return_existing, ) - context.vars[account_var] = acme_client.new_account(registration) + try: + context.vars[account_var] = acme_client.new_account(registration) + except Exception as exp: + context.vars["error"] = exp + + +@then( + 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + register_account_with_eab( + context=context, email=email, kid=kid, secret=secret, account_var=account_var + ) + + +@then( + 'I find the existing ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + register_account_with_eab( + context=context, + email=email, + kid=kid, + secret=secret, + account_var=account_var, + only_return_existing=True, + ) + + +@then("I register a new ACME account with email {email} without EAB") +def step_impl(context: Context, email: str): + acme_client = context.acme_client + registration = messages.NewRegistration.from_data( + email=email, + ) + try: + context.vars["error"] = acme_client.new_account(registration) + except Exception as exp: + context.vars["error"] = exp + + +def send_raw_acme_req(context: Context, url: str): + acme_client = context.acme_client + content = json.loads(context.text) + protected = replace_vars(content["protected"], context.vars) + alg = acme_client.net.alg + if "raw_payload" in content: + encoded_payload = content["raw_payload"].encode("utf-8") + elif "payload" in content: + payload = ( + replace_vars(content["payload"], context.vars) + if "payload" in content + else None + ) + encoded_payload = json.dumps(payload).encode() if payload is not None else b"" + else: + encoded_payload = b"" + protected_headers = json.dumps(protected) + signature = alg.sign( + key=acme_client.net.key.key, + msg=Signature._msg(protected_headers, encoded_payload), + ) + jws = json.dumps( + { + "protected": json_util.encode_b64jose(protected_headers.encode()), + "payload": json_util.encode_b64jose(encoded_payload), + "signature": json_util.encode_b64jose(signature), + } + ) + base_url = context.vars["BASE_URL"] + actual_url = urllib.parse.urljoin(base_url, replace_vars(url, context.vars)) + response = acme_client.net._send_request( + "POST", + actual_url, + data=jws, + headers={"Content-Type": acme.client.ClientNetwork.JOSE_CONTENT_TYPE}, + ) + context.vars["response"] = response + + +@when('I send a raw ACME request to "{url}"') +def step_impl(context: Context, url: str): + send_raw_acme_req(context, url) + + +@then( + "I encode CSR {pem_var} as JOSE Base-64 DER as {var_name}", +) +def step_impl(context: Context, pem_var: str, var_name: str): + csr = eval_var(context, pem_var) + parsed_csr = x509.load_pem_x509_csr(csr) + context.vars[var_name] = json_util.encode_csr(parsed_csr) @then( @@ -384,10 +635,17 @@ def step_impl(context: Context, var_path: str): @then("the value {var_path} should be equal to {expected}") def step_impl(context: Context, var_path: str, expected: str): value = eval_var(context, var_path) - expected_value = json.loads(expected) + expected_value = replace_vars(json.loads(expected), context.vars) assert value == expected_value, f"{value!r} does not match {expected_value!r}" +@then("the value {var_path} should not be equal to {expected}") +def step_impl(context: Context, var_path: str, expected: str): + value = eval_var(context, var_path) + expected_value = replace_vars(json.loads(expected), context.vars) + assert value != expected_value, f"{value!r} does match {expected_value!r}" + + @then('I memorize {var_path} with jq "{jq_query}" as {var_name}') def step_impl(context: Context, var_path: str, jq_query, var_name: str): _, value = apply_value_with_jq( @@ -398,6 +656,19 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str): context.vars[var_name] = value +@then("I peak and memorize the next nonce as {var_name}") +def step_impl(context: Context, var_name: str): + acme_client = context.acme_client + context.vars[var_name] = json_util.encode_b64jose(list(acme_client.net._nonces)[0]) + + +@then("I put away current ACME client as {var_name}") +def step_impl(context: Context, var_name: str): + acme_client = context.acme_client + del context.acme_client + context.vars[var_name] = acme_client + + @then("I memorize {var_path} as {var_name}") def step_impl(context: Context, var_path: str, var_name: str): value = eval_var(context, var_path) @@ -410,51 +681,61 @@ def step_impl(context: Context, var_path: str): print(json.dumps(value.json(), indent=2)) -@then( - "I select challenge with type {challenge_type} for domain {domain} from order at {var_path} as {challenge_var}" -) -def step_impl( +def select_challenge( context: Context, challenge_type: str, + order_var_path: str, domain: str, - var_path: str, - challenge_var: str, ): - order = eval_var(context, var_path, as_json=False) + acme_client = context.acme_client + order = eval_var(context, order_var_path, as_json=False) + if isinstance(order, dict): + order_body = messages.Order.from_json(order) + order = messages.OrderResource( + body=order_body, + authorizations=[ + acme_client._authzr_from_response( + acme_client._post_as_get(url), uri=url + ) + for url in order_body.authorizations + ], + ) if not isinstance(order, messages.OrderResource): raise ValueError( - f"Expected OrderResource but got {type(order)!r} at {var_path!r}" + f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}" ) auths = list( filter(lambda o: o.body.identifier.value == domain, order.authorizations) ) if not auths: raise ValueError( - f"Authorization for domain {domain!r} not found in {var_path!r}" + f"Authorization for domain {domain!r} not found in {order_var_path!r}" ) if len(auths) > 1: raise ValueError( - f"More than one order for domain {domain!r} found in {var_path!r}" + f"More than one order for domain {domain!r} found in {order_var_path!r}" ) auth = auths[0] challenges = list(filter(lambda a: a.typ == challenge_type, auth.body.challenges)) if not challenges: raise ValueError( - f"Authorization type {challenge_type!r} not found in {var_path!r}" + f"Authorization type {challenge_type!r} not found in {order_var_path!r}" ) if len(challenges) > 1: raise ValueError( - f"More than one authorization for type {challenge_type!r} found in {var_path!r}" + f"More than one authorization for type {challenge_type!r} found in {order_var_path!r}" ) - context.vars[challenge_var] = challenges[0] + return challenges[0] -@then("I serve challenge response for {var_path} at {hostname}") -def step_impl(context: Context, var_path: str, hostname: str): - if hostname != "localhost": - raise ValueError("Currently only localhost is supported") - challenge = eval_var(context, var_path, as_json=False) +def serve_challenge( + context: Context, + challenge: messages.ChallengeBody, +): + if hasattr(context, "web_server"): + context.web_server.shutdown_and_server_close() + response, validation = challenge.response_and_validation( context.acme_client.net.key ) @@ -463,19 +744,101 @@ def step_impl(context: Context, var_path: str, hostname: str): ) # TODO: make port configurable servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource}) - # Start client standalone web server. - web_server = threading.Thread(name="web_server", target=servers.serve_forever) - web_server.daemon = True - web_server.start() - context.web_server = web_server + servers.serve_forever() + context.web_server = servers + + +def notify_challenge_ready(context: Context, challenge: messages.ChallengeBody): + acme_client = context.acme_client + response, validation = challenge.response_and_validation(acme_client.net.key) + acme_client.answer_challenge(challenge, response) + + +@then( + "I select challenge with type {challenge_type} for domain {domain} from order in {var_path} as {challenge_var}" +) +def step_impl( + context: Context, + challenge_type: str, + domain: str, + var_path: str, + challenge_var: str, +): + challenge = select_challenge( + context=context, + challenge_type=challenge_type, + domain=domain, + order_var_path=var_path, + ) + context.vars[challenge_var] = challenge + + +@then("I pass all challenges with type {challenge_type} for order in {order_var_path}") +def step_impl( + context: Context, + challenge_type: str, + order_var_path: str, +): + acme_client = context.acme_client + order = eval_var(context, order_var_path, as_json=False) + if isinstance(order, dict): + order_body = messages.Order.from_json(order) + order = messages.OrderResource( + body=order_body, + authorizations=[ + acme_client._authzr_from_response( + acme_client._post_as_get(url), uri=url + ) + for url in order_body.authorizations + ], + ) + if not isinstance(order, messages.OrderResource): + raise ValueError( + f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}" + ) + + for domain in order.body.identifiers: + logger.info( + "Selecting challenge for domain %s with type %s ...", + domain.value, + challenge_type, + ) + challenge = select_challenge( + context=context, + challenge_type=challenge_type, + domain=domain.value, + order_var_path=order_var_path, + ) + logger.info( + "Found challenge for domain %s with type %s, challenge=%s", + domain.value, + challenge_type, + challenge.uri, + ) + + logger.info( + "Serving challenge for domain %s with type %s ...", + domain.value, + challenge_type, + ) + serve_challenge(context=context, challenge=challenge) + + logger.info( + "Notifying challenge for domain %s with type %s ...", domain, challenge_type + ) + notify_challenge_ready(context=context, challenge=challenge) + + +@then("I serve challenge response for {var_path} at {hostname}") +def step_impl(context: Context, var_path: str, hostname: str): + challenge = eval_var(context, var_path, as_json=False) + serve_challenge(context=context, challenge=challenge) @then("I tell ACME server that {var_path} is ready to be verified") def step_impl(context: Context, var_path: str): challenge = eval_var(context, var_path, as_json=False) - acme_client = context.acme_client - response, validation = challenge.response_and_validation(acme_client.net.key) - acme_client.answer_challenge(challenge, response) + notify_challenge_ready(context=context, challenge=challenge) @then("I poll and finalize the ACME order {var_path} as {finalized_var}") @@ -484,3 +847,10 @@ def step_impl(context: Context, var_path: str, finalized_var: str): acme_client = context.acme_client finalized_order = acme_client.poll_and_finalize(order) context.vars[finalized_var] = finalized_order + + +@then("I parse the full-chain certificate from order {order_var_path} as {cert_var}") +def step_impl(context: Context, order_var_path: str, cert_var: str): + order = eval_var(context, order_var_path, as_json=False) + cert = x509.load_pem_x509_certificate(order.fullchain_pem.encode()) + context.vars[cert_var] = cert diff --git a/backend/bdd/features/steps/utils.py b/backend/bdd/features/steps/utils.py new file mode 100644 index 000000000..4ee7c8921 --- /dev/null +++ b/backend/bdd/features/steps/utils.py @@ -0,0 +1,302 @@ +from cryptography import x509 +from cryptography.hazmat.primitives import hashes +from cryptography.x509.oid import NameOID +import logging +import re +import contextlib + +import httpx +import requests +import requests.structures +import glom +from faker import Faker +from behave.runner import Context +from josepy import JSONObjectWithFields + +ACC_KEY_BITS = 2048 +ACC_KEY_PUBLIC_EXPONENT = 65537 +logger = logging.getLogger(__name__) +faker = Faker() + + +class AcmeProfile: + def __init__(self, id: str, eab_kid: str, eab_secret: str): + self.id = id + self.eab_kid = eab_kid + self.eab_secret = eab_secret + + +def replace_vars(payload: dict | list | int | float | str, vars: dict): + if isinstance(payload, dict): + return { + replace_vars(key, vars): replace_vars(value, vars) + for key, value in payload.items() + } + elif isinstance(payload, list): + return [replace_vars(item, vars) for item in payload] + elif isinstance(payload, str): + return payload.format(**vars) + else: + return payload + + +def parse_glom_path(path_str: str) -> glom.Path: + """ + Parse a glom path string with 'attr[index]' syntax into a Path object. + + Examples: + >>> parse_glom_path('authorizations[0]') == Path('authorizations', 0) + True + >>> parse_glom_path('data.items[1].name') == Path('data', 'items', 1, 'name') + True + >>> parse_glom_path('user.addresses[0].street') == Path('user', 'addresses', 0, 'street') + True + """ + parts = [] + + # Split by dots, but preserve bracketed content + tokens = re.split(r"(? dict | None: + headers = {} + auth_token = getattr(context, "auth_token", None) + if auth_token is not None: + headers["authorization"] = "Bearer {}".format(auth_token) + if not headers: + return None + return headers + + +def x509_cert_to_dict(cert: x509.Certificate) -> dict: + """ + Convert a cryptography.x509.Certificate to a JSON-serializable nested dict + with human-readable keys. + """ + + def oid_to_name(oid): + # Map known OIDs to human-readable names + mapping = { + NameOID.COMMON_NAME: "common_name", + NameOID.ORGANIZATION_NAME: "organization", + NameOID.ORGANIZATIONAL_UNIT_NAME: "organizational_unit", + NameOID.COUNTRY_NAME: "country", + NameOID.LOCALITY_NAME: "locality", + NameOID.STATE_OR_PROVINCE_NAME: "state_or_province", + NameOID.EMAIL_ADDRESS: "email_address", + NameOID.SERIAL_NUMBER: "serial_number", + NameOID.SURNAME: "surname", + NameOID.GIVEN_NAME: "given_name", + NameOID.TITLE: "title", + NameOID.JURISDICTION_COUNTRY_NAME: "jurisdiction_country", + NameOID.JURISDICTION_STATE_OR_PROVINCE_NAME: "jurisdiction_state", + NameOID.JURISDICTION_LOCALITY_NAME: "jurisdiction_locality", + NameOID.BUSINESS_CATEGORY: "business_category", + NameOID.POSTAL_CODE: "postal_code", + NameOID.STREET_ADDRESS: "street_address", + NameOID.DOMAIN_COMPONENT: "domain_component", + NameOID.USER_ID: "user_id", + # Add more as needed + } + return mapping.get(oid, oid.dotted_string) + + def name_to_dict(name: x509.Name) -> dict: + return {oid_to_name(attr.oid): attr.value for attr in name} + + def dns_to_dict(dns: x509.DNSName) -> dict: + return dict(value=dns.value) + + def extension_to_dict(ext): + if isinstance(ext.value, x509.SubjectAlternativeName): + return { + "critical": ext.critical, + "general_names": [dns_to_dict(gn) for gn in ext.value], + } + elif isinstance(ext.value, x509.BasicConstraints): + return { + "critical": ext.critical, + "ca": ext.value.ca, + "path_length": ext.value.path_length, + } + elif isinstance(ext.value, x509.KeyUsage): + return { + "critical": ext.critical, + **{ + field.lower(): getattr(ext.value, field) + for field in [ + "digital_signature", + "content_commitment", + "key_encipherment", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + # TODO: deal with error: "ValueError: encipher_only is undefined unless key_agreement is true" + # "encipher_only", + # "decipher_only", + ] + if getattr(ext.value, field) is not None + }, + } + elif isinstance(ext.value, x509.ExtendedKeyUsage): + return { + "critical": ext.critical, + "usages": [eku.dotted_string for eku in ext.value], + } + elif isinstance(ext.value, x509.CRLDistributionPoints): + return { + "critical": ext.critical, + "distribution_points": [ + { + "full_name": [str(uri) for uri in dp.full_name] + if dp.full_name + else None, + "crl_issuer": [str(issuer) for issuer in dp.crl_issuer] + if dp.crl_issuer + else None, + "reasons": [r.name for r in dp.reasons] if dp.reasons else None, + } + for dp in ext.value + ], + } + elif isinstance(ext.value, x509.AuthorityKeyIdentifier): + return { + "critical": ext.critical, + "key_identifier": ext.value.key_identifier.hex() + if ext.value.key_identifier + else None, + "authority_cert_issuer": [ + str(n) for n in ext.value.authority_cert_issuer + ] + if ext.value.authority_cert_issuer + else None, + "authority_cert_serial_number": ext.value.authority_cert_serial_number, + } + elif isinstance(ext.value, x509.SubjectKeyIdentifier): + return {"critical": ext.critical, "digest": ext.value.digest.hex()} + else: + return { + "critical": ext.critical, + "oid": ext.oid.dotted_string, + "value": str(ext.value), + } + + # Build the main dict + result = dict( + version=cert.version.name, + serial_number=cert.serial_number, + signature_algorithm=cert.signature_algorithm_oid._name, + issuer=name_to_dict(cert.issuer), + subject=name_to_dict(cert.subject), + validity={ + "not_valid_before": cert.not_valid_before.isoformat(), + "not_valid_after": cert.not_valid_after.isoformat(), + }, + public_key={ + "key_size": cert.public_key().key_size, + }, + extensions={ + ext.oid._name + if hasattr(ext.oid, "_name") and ext.oid._name + else ext.oid.dotted_string: extension_to_dict(ext) + for ext in cert.extensions + }, + fingerprint={ + "sha1": cert.fingerprint(hashes.SHA1()).hex(), + "sha256": cert.fingerprint(hashes.SHA256()).hex(), + }, + ) + + return result + + +def define_nock(context: Context, definitions: list[dict]): + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/bdd-nock/define", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json=dict(definitions=definitions), + ) + response.raise_for_status() + + +def restore_nock(context: Context): + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/bdd-nock/restore", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json=dict(), + ) + response.raise_for_status() + + +def clean_all_nock(context: Context): + jwt_token = context.vars["AUTH_TOKEN"] + response = context.http_client.post( + "/api/v1/bdd-nock/clean-all", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json=dict(), + ) + response.raise_for_status() + + +@contextlib.contextmanager +def with_nocks(context: Context, definitions: list[dict]): + try: + define_nock(context, definitions) + yield + finally: + clean_all_nock(context) + restore_nock(context) diff --git a/backend/bdd/pebble/localhost/cert.pem b/backend/bdd/pebble/localhost/cert.pem new file mode 100644 index 000000000..9117526df --- /dev/null +++ b/backend/bdd/pebble/localhost/cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICBDCCAYmgAwIBAgIIHZvNVJSPdsYwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV +bWluaWNhIHJvb3QgY2EgN2ZlMDQwMB4XDTI1MTExMzAwMzAxMloXDTI3MTIxMzAw +MzAxMlowFDESMBAGA1UEAxMJbG9jYWxob3N0MHYwEAYHKoZIzj0CAQYFK4EEACID +YgAE2V5oM5JimqDjzEfH10cKu6L8eQ9rxzkULbIJRFFuuXtKQQwkcAW8L4UuMkmG +lu5hFCBR8saHDpISuAyYLYqsddxwndxmGT3zyw6oU+8oXWX0tThL0KgajmZckOfR +ysYpo4GbMIGYMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAWgBSIDfQe2L6+9aYyBFbd +t0S51xW3UDA4BgNVHREEMTAvgglsb2NhbGhvc3SCBnBlYmJsZYIUaG9zdC5kb2Nr +ZXIuaW50ZXJuYWyHBH8AAAEwCgYIKoZIzj0EAwMDaQAwZgIxAPkeGVzCDKuJYd/1 +87+lXXtlMHrW7F+Rn1kyR8SBud2hDt5r3a+ZZ8IQ9aHazRia/AIxAOI4I41jwxf0 +86i7fKx8of4s/CBc4+PF0hbCBkmen3aKuiZ7ueYuEsSNT6zHV2xc2w== +-----END CERTIFICATE----- diff --git a/backend/bdd/pebble/localhost/key.pem b/backend/bdd/pebble/localhost/key.pem new file mode 100644 index 000000000..93b93eada --- /dev/null +++ b/backend/bdd/pebble/localhost/key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBx7d0VqxwTYcJajFgz +ja0PExBmxdZjEQRfGCMQY8GfHa0WpBUEwVtBD6XOGE5xZB2hZANiAATZXmgzkmKa +oOPMR8fXRwq7ovx5D2vHORQtsglEUW65e0pBDCRwBbwvhS4ySYaW7mEUIFHyxocO +khK4DJgtiqx13HCd3GYZPfPLDqhT7yhdZfS1OEvQqBqOZlyQ59HKxik= +-----END PRIVATE KEY----- diff --git a/backend/bdd/pebble/pebble-config.json b/backend/bdd/pebble/pebble-config.json new file mode 100644 index 000000000..013f6ff64 --- /dev/null +++ b/backend/bdd/pebble/pebble-config.json @@ -0,0 +1,28 @@ +{ + "pebble": { + "listenAddress": "0.0.0.0:14000", + "managementListenAddress": "0.0.0.0:15000", + "certificate": "/var/data/pebble/localhost/cert.pem", + "privateKey": "/var/data/pebble/localhost/key.pem", + "httpPort": 5002, + "tlsPort": 5001, + "ocspResponderURL": "", + "externalAccountBindingRequired": false, + "domainBlocklist": ["blocked-domain.example"], + "retryAfter": { + "authz": 3, + "order": 5 + }, + "keyAlgorithm": "ecdsa", + "profiles": { + "default": { + "description": "The profile you know and love", + "validityPeriod": 7776000 + }, + "shortlived": { + "description": "A short-lived cert profile, without actual enforcement", + "validityPeriod": 518400 + } + } + } +} \ No newline at end of file diff --git a/backend/bdd/pebble/pebble.minica.key.pem b/backend/bdd/pebble/pebble.minica.key.pem new file mode 100644 index 000000000..322b4e88e --- /dev/null +++ b/backend/bdd/pebble/pebble.minica.key.pem @@ -0,0 +1,6 @@ +-----BEGIN PRIVATE KEY----- +MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDnPx90G0J4ba0CMTrh +AT0kJkRGyhv5ePWyobdT75za/I9MpU/VsC8BG5uJBraxiSOhZANiAAQWEiTINq0t +j+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ +0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4SVQ9GzizHiJ//Qb71vrXbco= +-----END PRIVATE KEY----- diff --git a/backend/bdd/pebble/pebble.minica.pem b/backend/bdd/pebble/pebble.minica.pem new file mode 100644 index 000000000..030ca32bb --- /dev/null +++ b/backend/bdd/pebble/pebble.minica.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB+zCCAYKgAwIBAgIIf+BA3XMRozcwCgYIKoZIzj0EAwMwIDEeMBwGA1UEAxMV +bWluaWNhIHJvb3QgY2EgN2ZlMDQwMCAXDTI1MTExMzAwMzAxMloYDzIxMjUxMTEz +MDAzMDEyWjAgMR4wHAYDVQQDExVtaW5pY2Egcm9vdCBjYSA3ZmUwNDAwdjAQBgcq +hkjOPQIBBgUrgQQAIgNiAAQWEiTINq0tj+6Qiyzin74FU4/zLNuEs1FnipFn+Vb1 +W8qhvbBwLOGsANpaHIg4dpR+CghfccRQ0kQm/AMgj08VXvta6vV7aQ8yk+/Cp6l4 +SVQ9GzizHiJ//Qb71vrXbcqjgYYwgYMwDgYDVR0PAQH/BAQDAgKEMB0GA1UdJQQW +MBQGCCsGAQUFBwMBBggrBgEFBQcDAjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1Ud +DgQWBBSIDfQe2L6+9aYyBFbdt0S51xW3UDAfBgNVHSMEGDAWgBSIDfQe2L6+9aYy +BFbdt0S51xW3UDAKBggqhkjOPQQDAwNnADBkAjAK2OUUVHs2LVqwyLEqIrXbc3gw +5r5p9TC9asqPN8vJxlTRStrXnJQRSQ2KoWztiSICMEV5jZGVk6TaUwlqcGmXEmGr +iFeQ3rXLaRw8XKMqj7+EiwaCD1o2wLgzny/21NFtxQ== +-----END CERTIFICATE----- diff --git a/backend/package-lock.json b/backend/package-lock.json index 9cdaa764b..a871c38b1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -58,6 +58,7 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", + "@types/node-forge": "^1.3.14", "@ucast/mongo2js": "^1.3.4", "acme-client": "^5.4.0", "ajv": "^8.12.0", @@ -97,6 +98,7 @@ "ms": "^2.1.3", "mysql2": "^3.9.8", "nanoid": "^3.3.8", + "node-forge": "^1.3.1", "nodemailer": "^6.9.9", "oci-sdk": "^2.108.0", "odbc": "^2.4.9", @@ -175,6 +177,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-simple-import-sort": "^10.0.0", + "nock": "^14.0.10", "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", @@ -9703,6 +9706,24 @@ "win32" ] }, + "node_modules/@mswjs/interceptors": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.39.8.tgz", + "integrity": "sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@next/env": { "version": "15.5.2", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.2.tgz", @@ -10712,6 +10733,31 @@ "urijs": "^1.19.11" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -15272,6 +15318,15 @@ "form-data": "^4.0.0" } }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node/node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -22947,6 +23002,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -23496,6 +23558,13 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -25063,6 +25132,21 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nock": { + "version": "14.0.10", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.10.tgz", + "integrity": "sha512-Q7HjkpyPeLa0ZVZC5qpxBt5EyLczFJ91MEewQiIi9taWuA0KB/MDJlUWtON+7dGouVdADTQsf9RA7TZk6D8VMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.39.5", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/node-abi": { "version": "3.65.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz", @@ -27691,6 +27775,13 @@ "@otplib/preset-v11": "^12.0.1" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-finally": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", @@ -29092,6 +29183,16 @@ "node": ">= 6" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proto3-json-serializer": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", @@ -31590,6 +31691,13 @@ "node": ">=4.0.0" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -33526,18 +33634,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/vite-node/node_modules/@types/node": { - "version": "24.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", - "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.16.0" - } - }, "node_modules/vite-node/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -33569,15 +33665,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite-node/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/vite-node/node_modules/vite": { "version": "7.1.12", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", diff --git a/backend/package.json b/backend/package.json index fa4ee2f5f..aa97de2ed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,6 +44,7 @@ "test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts", + "test:bdd": "cd bdd && uv run behave", "generate:component": "tsx ./scripts/create-backend-file.ts", "generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas", "auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest", @@ -122,6 +123,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-simple-import-sort": "^10.0.0", + "nock": "^14.0.10", "nodemon": "^3.0.2", "pino-pretty": "^10.2.3", "prompt-sync": "^4.2.0", @@ -185,6 +187,7 @@ "@sindresorhus/slugify": "1.1.0", "@slack/oauth": "^3.0.2", "@slack/web-api": "^7.8.0", + "@types/node-forge": "^1.3.14", "@ucast/mongo2js": "^1.3.4", "acme-client": "^5.4.0", "ajv": "^8.12.0", @@ -224,6 +227,7 @@ "ms": "^2.1.3", "mysql2": "^3.9.8", "nanoid": "^3.3.8", + "node-forge": "^1.3.1", "nodemailer": "^6.9.9", "oci-sdk": "^2.108.0", "odbc": "^2.4.9", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index d511187d0..6ef775f90 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -91,6 +91,7 @@ import { TIdentityProjectServiceFactory } from "@app/services/identity-project/i import { TIdentityTlsCertAuthServiceFactory } from "@app/services/identity-tls-cert-auth/identity-tls-cert-auth-types"; import { TIdentityTokenAuthServiceFactory } from "@app/services/identity-token-auth/identity-token-auth-service"; import { TIdentityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; +import { TScopedIdentityV2ServiceFactory } from "@app/services/identity-v2/identity-service"; import { TIntegrationServiceFactory } from "@app/services/integration/integration-service"; import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TMembershipGroupServiceFactory } from "@app/services/membership-group/membership-group-service"; @@ -258,7 +259,8 @@ declare module "fastify" { integrationAuth: TIntegrationAuthServiceFactory; webhook: TWebhookServiceFactory; serviceToken: TServiceTokenServiceFactory; - identity: TIdentityServiceFactory; + identityV1: TIdentityServiceFactory; + identityV2: TScopedIdentityV2ServiceFactory; identityAccessToken: TIdentityAccessTokenServiceFactory; identityProject: TIdentityProjectServiceFactory; identityTokenAuth: TIdentityTokenAuthServiceFactory; diff --git a/backend/src/db/migrations/20251023100246_project-identity.ts b/backend/src/db/migrations/20251023100246_project-identity.ts new file mode 100644 index 000000000..4a54c6c43 --- /dev/null +++ b/backend/src/db/migrations/20251023100246_project-identity.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasProjectIdCol = await knex.schema.hasColumn(TableName.Identity, "projectId"); + if (!hasProjectIdCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.string("projectId"); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasProjectIdCol = await knex.schema.hasColumn(TableName.Identity, "projectId"); + if (hasProjectIdCol) { + await knex.schema.alterTable(TableName.Identity, (t) => { + t.dropColumn("projectId"); + }); + } +} diff --git a/backend/src/db/schemas/identities.ts b/backend/src/db/schemas/identities.ts index 06c37ff22..0814d665e 100644 --- a/backend/src/db/schemas/identities.ts +++ b/backend/src/db/schemas/identities.ts @@ -14,7 +14,8 @@ export const IdentitiesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), hasDeleteProtection: z.boolean().default(false), - orgId: z.string().uuid() + orgId: z.string().uuid(), + projectId: z.string().nullable().optional() }); export type TIdentities = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index ce05ea3b6..7ff9ec09a 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,4 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; -import { getConfig } from "@app/lib/config/env"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; @@ -109,10 +108,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); - // Notice: current this feature is still in development and is not yet ready for production. - if (getConfig().isAcmeFeatureEnabled === true) { - await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); - } + await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); }, { prefix: "/pki" } ); diff --git a/backend/src/ee/routes/v1/pit-router.ts b/backend/src/ee/routes/v1/pit-router.ts index 26909d294..3fa992601 100644 --- a/backend/src/ee/routes/v1/pit-router.ts +++ b/backend/src/ee/routes/v1/pit-router.ts @@ -435,14 +435,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { projectId: z.string().trim(), environment: z.string().trim(), secretPath: z.string().trim().default("/").transform(removeTrailingSlash), - message: z - .string() - .trim() - .min(1) - .max(255) - .refine((message) => message.trim() !== "", { - message: "Commit message cannot be empty" - }), + message: z.string().trim().max(255).optional(), changes: z.object({ secrets: z.object({ create: z @@ -546,7 +539,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { projectId: req.body.projectId, environment: req.body.environment, secretPath: req.body.secretPath, - message: req.body.message, + message: req.body.message || "", changes: { secrets: req.body.changes.secrets, folders: req.body.changes.folders @@ -564,7 +557,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => { projectId: req.body.projectId, environment: req.body.environment, secretPath: req.body.secretPath, - message: req.body.message + message: req.body.message || "" } } }); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts index 627537c44..c4ccf6be5 100644 --- a/backend/src/ee/routes/v1/pki-acme-router.ts +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -2,7 +2,6 @@ import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; -import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { AcmeOrderResourceSchema, CreateAcmeAccountResponseSchema, @@ -257,12 +256,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId, payload } = await validateExistingAccount({ + const { profileId, accountId } = await validateExistingAccount({ req }); - if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); - } return sendAcmeResponse( res, profileId, @@ -369,12 +365,9 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId, payload } = await validateExistingAccount({ + const { profileId, accountId } = await validateExistingAccount({ req }); - if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); - } res.type("application/pem-certificate-chain"); return sendAcmeResponse( res, @@ -405,10 +398,7 @@ export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const { profileId, accountId, payload } = await validateExistingAccount({ req }); - if (payload !== "") { - throw new AcmeMalformedError({ detail: "Payload should be empty" }); - } + const { profileId, accountId } = await validateExistingAccount({ req }); return sendAcmeResponse( res, profileId, diff --git a/backend/src/ee/routes/v1/sub-org-router.ts b/backend/src/ee/routes/v1/sub-org-router.ts index 200130488..8ebcd32a7 100644 --- a/backend/src/ee/routes/v1/sub-org-router.ts +++ b/backend/src/ee/routes/v1/sub-org-router.ts @@ -42,7 +42,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { organization } = await server.services.subOrganization.createSubOrg({ name: req.body.name, @@ -95,7 +95,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { organizations } = await server.services.subOrganization.listSubOrgs({ permissionActor: req.permission, @@ -137,7 +137,7 @@ export const registerSubOrgRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { organization } = await server.services.subOrganization.updateSubOrg({ subOrgId: req.params.subOrgId, diff --git a/backend/src/ee/services/app-connections/chef/chef-connection-schemas.ts b/backend/src/ee/services/app-connections/chef/chef-connection-schemas.ts index e5a3687a2..efe11c0f2 100644 --- a/backend/src/ee/services/app-connections/chef/chef-connection-schemas.ts +++ b/backend/src/ee/services/app-connections/chef/chef-connection-schemas.ts @@ -2,6 +2,7 @@ import z from "zod"; import { AppConnections } from "@app/lib/api-docs"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { BaseAppConnectionSchema, GenericCreateAppConnectionFieldsSchema, @@ -48,7 +49,7 @@ export const SanitizedChefConnectionSchema = z.discriminatedUnion("method", [ BaseChefConnectionSchema.extend({ method: z.literal(ChefConnectionMethod.UserKey), credentials: ChefConnectionUserKeyCredentialsSchema.pick({ serverUrl: true, orgName: true, userName: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Chef]} (User Key)` })) ]); export const ValidateChefConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -70,8 +71,10 @@ export const UpdateChefConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Chef)); -export const ChefConnectionListItemSchema = z.object({ - name: z.literal("Chef"), - app: z.literal(AppConnection.Chef), - methods: z.nativeEnum(ChefConnectionMethod).array() -}); +export const ChefConnectionListItemSchema = z + .object({ + name: z.literal("Chef"), + app: z.literal(AppConnection.Chef), + methods: z.nativeEnum(ChefConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Chef] })); diff --git a/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts index f09564455..e7cead989 100644 --- a/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts +++ b/backend/src/ee/services/app-connections/oci/oci-connection-schemas.ts @@ -2,6 +2,7 @@ import z from "zod"; import { AppConnections } from "@app/lib/api-docs"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { BaseAppConnectionSchema, GenericCreateAppConnectionFieldsSchema, @@ -34,7 +35,7 @@ export const SanitizedOCIConnectionSchema = z.discriminatedUnion("method", [ region: true, fingerprint: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.OCI]} (Access Key)` })) ]); export const ValidateOCIConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -58,8 +59,10 @@ export const UpdateOCIConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OCI)); -export const OCIConnectionListItemSchema = z.object({ - name: z.literal("OCI"), - app: z.literal(AppConnection.OCI), - methods: z.nativeEnum(OCIConnectionMethod).array() -}); +export const OCIConnectionListItemSchema = z + .object({ + name: z.literal("OCI"), + app: z.literal(AppConnection.OCI), + methods: z.nativeEnum(OCIConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.OCI] })); diff --git a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts index 38e0fc828..79996841e 100644 --- a/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts +++ b/backend/src/ee/services/app-connections/oracledb/oracledb-connection-schemas.ts @@ -2,6 +2,7 @@ import z from "zod"; import { AppConnections } from "@app/lib/api-docs"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { BaseAppConnectionSchema, GenericCreateAppConnectionFieldsSchema, @@ -32,7 +33,7 @@ export const SanitizedOracleDBConnectionSchema = z.discriminatedUnion("method", sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.OracleDB]} (Username and Password)` })) ]); export const ValidateOracleDBConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -64,9 +65,11 @@ export const UpdateOracleDBConnectionSchema = z }) ); -export const OracleDBConnectionListItemSchema = z.object({ - name: z.literal("OracleDB"), - app: z.literal(AppConnection.OracleDB), - methods: z.nativeEnum(OracleDBConnectionMethod).array(), - supportsPlatformManagement: z.literal(true) -}); +export const OracleDBConnectionListItemSchema = z + .object({ + name: z.literal("OracleDB"), + app: z.literal(AppConnection.OracleDB), + methods: z.nativeEnum(OracleDBConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.OracleDB] })); 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 97dfdb9fa..021da107f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -159,9 +159,22 @@ export enum EventType { DELETE_TRUSTED_IP = "delete-trusted-ip", CREATE_SERVICE_TOKEN = "create-service-token", // v2 DELETE_SERVICE_TOKEN = "delete-service-token", // v2 + + CREATE_SUB_ORGANIZATION = "create-sub-organization", + UPDATE_SUB_ORGANIZATION = "update-sub-organization", + CREATE_IDENTITY = "create-identity", UPDATE_IDENTITY = "update-identity", DELETE_IDENTITY = "delete-identity", + + CREATE_IDENTITY_ORG_MEMBERSHIP = "create-identity-org-membership", + UPDATE_IDENTITY_ORG_MEMBERSHIP = "update-identity-org-membership", + DELETE_IDENTITY_ORG_MEMBERSHIP = "delete-identity-org-membership", + + CREATE_IDENTITY_PROJECT_MEMBERSHIP = "create-identity-project-membership", + UPDATE_IDENTITY_PROJECT_MEMBERSHIP = "update-identity-project-membership", + DELETE_IDENTITY_PROJECT_MEMBERSHIP = "delete-identity-project-membership", + MACHINE_IDENTITY_AUTH_TEMPLATE_CREATE = "machine-identity-auth-template-create", MACHINE_IDENTITY_AUTH_TEMPLATE_UPDATE = "machine-identity-auth-template-update", MACHINE_IDENTITY_AUTH_TEMPLATE_DELETE = "machine-identity-auth-template-delete", @@ -175,9 +188,6 @@ export enum EventType { GET_TOKENS_IDENTITY_TOKEN_AUTH = "get-tokens-identity-token-auth", GET_TOKEN_IDENTITY_TOKEN_AUTH = "get-token-identity-token-auth", - CREATE_SUB_ORGANIZATION = "create-sub-organization", - UPDATE_SUB_ORGANIZATION = "update-sub-organization", - ADD_IDENTITY_TOKEN_AUTH = "add-identity-token-auth", UPDATE_IDENTITY_TOKEN_AUTH = "update-identity-token-auth", GET_IDENTITY_TOKEN_AUTH = "get-identity-token-auth", @@ -324,6 +334,7 @@ export enum EventType { GET_CERT_BODY = "get-cert-body", GET_CERT_PRIVATE_KEY = "get-cert-private-key", GET_CERT_BUNDLE = "get-cert-bundle", + EXPORT_CERT_PKCS12 = "export-cert-pkcs12", CREATE_PKI_ALERT = "create-pki-alert", GET_PKI_ALERT = "get-pki-alert", UPDATE_PKI_ALERT = "update-pki-alert", @@ -355,6 +366,8 @@ export enum EventType { LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup", ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project", ORG_ADMIN_BYPASS_SSO = "org-admin-bypassed-sso", + USER_LOGIN = "user-login", + SELECT_ORGANIZATION = "select-organization", CREATE_CERTIFICATE_TEMPLATE = "create-certificate-template", UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", @@ -560,6 +573,7 @@ interface UserActorMetadata { email?: string | null; username: string; permission?: Record; + authMethod?: string; } interface ServiceActorMetadata { @@ -891,6 +905,7 @@ interface CreateIdentityEvent { identityId: string; name: string; hasDeleteProtection: boolean; + metadata?: { key: string; value: string }[]; }; } @@ -900,6 +915,7 @@ interface UpdateIdentityEvent { identityId: string; name?: string; hasDeleteProtection?: boolean; + metadata?: { key: string; value: string }[]; }; } @@ -1509,6 +1525,52 @@ interface ClearIdentityLdapAuthLockoutsEvent { }; } +interface CreateIdentityOrgMembershipEvent { + type: EventType.CREATE_IDENTITY_ORG_MEMBERSHIP; + metadata: { + identityId: string; + roles: unknown; + }; +} + +interface UpdateIdentityOrgMembershipEvent { + type: EventType.UPDATE_IDENTITY_ORG_MEMBERSHIP; + metadata: { + identityId: string; + roles?: unknown; + }; +} + +interface DeleteIdentityOrgMembershipEvent { + type: EventType.DELETE_IDENTITY_ORG_MEMBERSHIP; + metadata: { + identityId: string; + }; +} + +interface CreateIdentityProjectMembershipEvent { + type: EventType.CREATE_IDENTITY_PROJECT_MEMBERSHIP; + metadata: { + identityId: string; + roles: unknown; + }; +} + +interface UpdateIdentityProjectMembershipEvent { + type: EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP; + metadata: { + identityId: string; + roles?: unknown; + }; +} + +interface DeleteIdentityProjectMembershipEvent { + type: EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP; + metadata: { + identityId: string; + }; +} + interface LoginIdentityOidcAuthEvent { type: EventType.LOGIN_IDENTITY_OIDC_AUTH; metadata: { @@ -2324,6 +2386,14 @@ interface GetCertBundle { serialNumber: string; }; } +interface GetCertPkcs12 { + type: EventType.EXPORT_CERT_PKCS12; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} interface CreatePkiAlert { type: EventType.CREATE_PKI_ALERT; @@ -2599,6 +2669,22 @@ interface OrgAdminBypassSSOEvent { metadata: Record; // no metadata yet } +interface UserLoginEvent { + type: EventType.USER_LOGIN; + metadata: { + organizationId?: string; + authProvider?: string; + }; +} + +interface SelectOrganizationEvent { + type: EventType.SELECT_ORGANIZATION; + metadata: { + organizationId: string; + organizationName: string; + }; +} + interface CreateCertificateTemplateEstConfig { type: EventType.CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG; metadata: { @@ -4198,6 +4284,12 @@ export type Event = | GetIdentityLdapAuthEvent | RevokeIdentityLdapAuthEvent | ClearIdentityLdapAuthLockoutsEvent + | CreateIdentityOrgMembershipEvent + | UpdateIdentityOrgMembershipEvent + | DeleteIdentityOrgMembershipEvent + | CreateIdentityProjectMembershipEvent + | UpdateIdentityProjectMembershipEvent + | DeleteIdentityProjectMembershipEvent | CreateEnvironmentEvent | GetEnvironmentEvent | UpdateEnvironmentEvent @@ -4262,6 +4354,7 @@ export type Event = | GetCertBody | GetCertPrivateKey | GetCertBundle + | GetCertPkcs12 | CreatePkiAlert | GetPkiAlert | UpdatePkiAlert @@ -4471,4 +4564,6 @@ export type Event = | UpdateCertificateRenewalConfigEvent | DisableCertificateRenewalConfigEvent | AutomatedRenewCertificate - | AutomatedRenewCertificateFailed; + | AutomatedRenewCertificateFailed + | UserLoginEvent + | SelectOrganizationEvent; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 97061e3ca..14b7bcfbd 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretsLimit: 40 }, pkiEst: false, + pkiAcme: false, enforceMfa: false, projectTemplates: false, kmip: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c4ff6a8fa..5157b0730 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { secretsLimit: number; }; pkiEst: boolean; + pkiAcme: false; enforceMfa: boolean; projectTemplates: false; kmip: false; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 413990c5d..7dd7948ef 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -104,8 +104,7 @@ const makeSqlConnection = ( // (like being able to do an auth handshake regardless pass or not) if ( connectOnly && - (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` || - error.message.includes("no pg_hba.conf entry for host")) + error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` ) { return; } diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 5e7025f05..81814a67c 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -171,7 +171,11 @@ const buildAdminPermissionRules = () => { ProjectPermissionIdentityActions.Delete, ProjectPermissionIdentityActions.Read, ProjectPermissionIdentityActions.GrantPrivileges, - ProjectPermissionIdentityActions.AssumePrivileges + ProjectPermissionIdentityActions.AssumePrivileges, + ProjectPermissionIdentityActions.GetToken, + ProjectPermissionIdentityActions.CreateToken, + ProjectPermissionIdentityActions.DeleteToken, + ProjectPermissionIdentityActions.RevokeAuth ], ProjectPermissionSub.Identity ); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 88b52be22..efe17edad 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -204,9 +204,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { .on(`${TableName.IdentityMetadata}.userId`, db.raw("?", [actorId])) .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); } else if (actorType === ActorType.IDENTITY) { - void queryBuilder - .on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId])) - .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId])); } }) .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) @@ -667,9 +665,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .where(`${TableName.Membership}.scopeOrgId`, orgId) .whereNotNull(`${TableName.Membership}.actorIdentityId`) diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index b71e63c10..9b72f2ac4 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -196,7 +196,7 @@ export const permissionServiceFactory = ({ } if (orgId !== actorOrgId) { - throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); + throw new ForbiddenRequestError({ name: "You are not allowed to access organization resource" }); } const permissionData = await permissionDAL.getPermission({ @@ -344,7 +344,7 @@ export const permissionServiceFactory = ({ }); if (projectDetails.orgId !== actorOrgId) { - throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); + throw new ForbiddenRequestError({ name: "This project does not belong to your selected organization." }); } if (actionProjectType !== ActionProjectType.Any && actionProjectType !== projectDetails.type) { @@ -362,7 +362,7 @@ export const permissionServiceFactory = ({ actorId, actorType: actor }); - if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this organization" }); + if (!permissionData?.length) throw new ForbiddenRequestError({ name: "You are not member of this project" }); const permissionFromRoles = permissionData.flatMap((membership) => { const activeRoles = membership?.roles diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 74e4554ed..19340644a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -65,7 +65,11 @@ export enum ProjectPermissionIdentityActions { Edit = "edit", Delete = "delete", GrantPrivileges = "grant-privileges", - AssumePrivileges = "assume-privileges" + AssumePrivileges = "assume-privileges", + RevokeAuth = "revoke-auth", + CreateToken = "create-token", + GetToken = "get-token", + DeleteToken = "delete-token" } export enum ProjectPermissionMemberActions { 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 61bd0c110..9148b0336 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 @@ -76,7 +76,9 @@ export const pkiAcmeChallengeServiceFactory = ({ // challenge validation at the same time, it should be fine. const challengeResponse = await fetch(challengeUrl, { signal: AbortSignal.timeout(timeoutMs) }); if (challengeResponse.status !== 200) { - throw new BadRequestError({ message: "ACME challenge response is not 200" }); + throw new AcmeIncorrectResponseError({ + message: `ACME challenge response is not 200: ${challengeResponse.status}` + }); } const challengeResponseBody = await challengeResponse.text(); const thumbprint = challenge.auth.account.publicKeyThumbprint; @@ -107,6 +109,7 @@ export const pkiAcmeChallengeServiceFactory = ({ if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); } + logger.error(exp, "Unknown error validating ACME challenge response"); return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); } } else if (exp instanceof DOMException) { diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts index febce5e81..9053be391 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-errors.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -35,7 +35,7 @@ export enum AcmeErrorType { export interface IAcmeError { type: AcmeErrorType; - detail: string; + message: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; } @@ -43,7 +43,7 @@ export interface IAcmeError { export class AcmeError extends Error implements IAcmeError { type: AcmeErrorType; - detail: string; + message: string; status: number; @@ -53,22 +53,20 @@ export class AcmeError extends Error implements IAcmeError { constructor({ type, - detail, + message, status, subproblems, - error, - message + error }: { type: AcmeErrorType; - detail: string; + message: string; status: number; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; error?: unknown; - message?: string; }) { - super(message || detail); + super(message); this.type = type; - this.detail = detail; + this.message = message; this.status = status; this.subproblems = subproblems; this.error = error; @@ -78,7 +76,7 @@ export class AcmeError extends Error implements IAcmeError { toAcmeResponse(): IAcmeError { return { type: this.type, - detail: this.detail, + message: this.message, status: this.status, subproblems: this.subproblems }; @@ -90,20 +88,17 @@ export class AcmeError extends Error implements IAcmeError { */ export class AcmeMalformedError extends AcmeError { constructor({ - detail = "The request message was malformed", - error, - message + message = "The request message was malformed", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Malformed, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeMalformedError"; } @@ -114,20 +109,17 @@ export class AcmeMalformedError extends AcmeError { */ export class AcmeUnauthorizedError extends AcmeError { constructor({ - detail = "The client lacks sufficient authorization", - error, - message + message = "The client lacks sufficient authorization", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Unauthorized, - detail, + message, status: 403, - error, - message + error }); this.name = "AcmeUnauthorizedError"; } @@ -139,20 +131,17 @@ export class AcmeUnauthorizedError extends AcmeError { */ export class AcmeAccountDoesNotExistError extends AcmeError { constructor({ - detail = "The request specified an account that does not exist", - error, - message + message = "The request specified an account that does not exist", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.AccountDoesNotExist, - detail, - status: 400, - error, - message + message, + status: 404, + error }); this.name = "AcmeAccountDoesNotExistError"; } @@ -163,20 +152,17 @@ export class AcmeAccountDoesNotExistError extends AcmeError { */ export class AcmeBadNonceError extends AcmeError { constructor({ - detail = "The client sent an unacceptable anti-replay nonce", - error, - message + message = "The client sent an unacceptable anti-replay nonce", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadNonce, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadNonceError"; } @@ -187,20 +173,17 @@ export class AcmeBadNonceError extends AcmeError { */ export class AcmeBadSignatureAlgorithmError extends AcmeError { constructor({ - detail = "The signature algorithm is invalid", - error, - message + message = "The signature algorithm is invalid", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadSignatureAlgorithm, - detail, + message, status: 401, - error, - message + error }); this.name = "AcmeBadSignatureAlgorithmError"; } @@ -211,20 +194,17 @@ export class AcmeBadSignatureAlgorithmError extends AcmeError { */ export class AcmeBadPublicKeyError extends AcmeError { constructor({ - detail = "The public key is not acceptable", - error, - message + message = "The public key is not acceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadPublicKey, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadPublicKeyError"; } @@ -235,20 +215,17 @@ export class AcmeBadPublicKeyError extends AcmeError { */ export class AcmeBadCsrError extends AcmeError { constructor({ - detail = "The CSR is unacceptable", - error, - message + message = "The CSR is unacceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadCsr, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadCsrError"; } @@ -260,20 +237,17 @@ export class AcmeBadCsrError extends AcmeError { */ export class AcmeBadRevocationReasonError extends AcmeError { constructor({ - detail = "The revocation reason provided is not allowed", - error, - message + message = "The revocation reason provided is not allowed", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadRevocationReason, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadRevocationReasonError"; } @@ -284,20 +258,17 @@ export class AcmeBadRevocationReasonError extends AcmeError { */ export class AcmeRateLimitedError extends AcmeError { constructor({ - detail = "The client has exceeded a rate limit", - error, - message + message = "The client has exceeded a rate limit", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.RateLimited, - detail, + message, status: 429, - error, - message + error }); this.name = "AcmeRateLimitedError"; } @@ -309,23 +280,20 @@ export class AcmeRateLimitedError extends AcmeError { */ export class AcmeRejectedIdentifierError extends AcmeError { constructor({ - detail = "The server will not issue certificates for the identifier", + message = "The server will not issue certificates for the identifier", subproblems, - error, - message + error }: { - detail?: string; + message?: string; subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; error?: unknown; - message?: string; } = {}) { super({ type: AcmeErrorType.RejectedIdentifier, - detail, + message, status: 400, subproblems, - error, - message + error }); this.name = "AcmeRejectedIdentifierError"; } @@ -336,20 +304,17 @@ export class AcmeRejectedIdentifierError extends AcmeError { */ export class AcmeServerInternalError extends AcmeError { constructor({ - detail = "An internal error occurred", - error, - message + message = "An internal error occurred", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.ServerInternal, - detail, + message, status: 500, - error, - message + error }); this.name = "AcmeServerInternalError"; } @@ -360,20 +325,17 @@ export class AcmeServerInternalError extends AcmeError { */ export class AcmeUnsupportedContactError extends AcmeError { constructor({ - detail = "A contact URL is of an unsupported type", - error, - message + message = "A contact URL is of an unsupported type", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.UnsupportedContact, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeUnsupportedContactError"; } @@ -385,20 +347,17 @@ export class AcmeUnsupportedContactError extends AcmeError { */ export class AcmeUnsupportedIdentifierError extends AcmeError { constructor({ - detail = "An identifier is of an unsupported type", - error, - message + message = "An identifier is of an unsupported type", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.UnsupportedIdentifier, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeUnsupportedIdentifierError"; } @@ -412,22 +371,19 @@ export class AcmeUserActionRequiredError extends AcmeError { instance?: string; constructor({ - detail = "Visit the instance URL and take actions specified there", + message = "Visit the instance URL and take actions specified there", instance, - error, - message + error }: { - detail?: string; + message?: string; instance?: string; error?: unknown; - message?: string; } = {}) { super({ type: AcmeErrorType.UserActionRequired, - detail, + message, status: 403, - error, - message + error }); this.instance = instance; this.name = "AcmeUserActionRequiredError"; @@ -446,20 +402,17 @@ export class AcmeUserActionRequiredError extends AcmeError { */ export class AcmeIncorrectResponseError extends AcmeError { constructor({ - detail = "The response is incorrect", - error, - message + message = "The response is incorrect", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.IncorrectResponse, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeIncorrectResponseError"; } @@ -470,20 +423,17 @@ export class AcmeIncorrectResponseError extends AcmeError { */ export class AcmeConnectionError extends AcmeError { constructor({ - detail = "A connection error occurred", - error, - message + message = "A connection error occurred", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.Connection, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeConnectionError"; } @@ -491,20 +441,17 @@ export class AcmeConnectionError extends AcmeError { export class AcmeDnsFailureError extends AcmeError { constructor({ - detail = "Hostname could not be resolved (DNS failure)", - error, - message + message = "Hostname could not be resolved (DNS failure)", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.DNS, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeDnsFailureError"; } @@ -512,20 +459,17 @@ export class AcmeDnsFailureError extends AcmeError { export class AcmeOrderNotReadyError extends AcmeError { constructor({ - detail = "The order is not ready", - error, - message + message = "The order is not ready", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.OrderNotReady, - detail, - status: 403, - error, - message + message, + status: 400, + error }); this.name = "AcmeOrderNotReadyError"; } @@ -533,20 +477,17 @@ export class AcmeOrderNotReadyError extends AcmeError { export class AcmeBadCSRError extends AcmeError { constructor({ - detail = "The CSR is unacceptable", - error, - message + message = "The CSR is unacceptable", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.BadCsr, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeBadCSRError"; } @@ -554,20 +495,17 @@ export class AcmeBadCSRError extends AcmeError { export class AcmeExternalAccountRequiredError extends AcmeError { constructor({ - detail = "External account binding is required", - error, - message + message = "External account binding is required", + error }: { - detail?: string; - error?: unknown; message?: string; + error?: unknown; } = {}) { super({ type: AcmeErrorType.ExternalAccountRequired, - detail, + message, status: 400, - error, - message + error }); this.name = "AcmeExternalAccountRequiredError"; } diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts index 828e0801b..a5206d036 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-fns.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -1,8 +1,9 @@ +import RE2 from "re2"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { AcmeMalformedError } from "./pki-acme-errors"; +import { AcmeAccountDoesNotExistError } from "./pki-acme-errors"; export const buildUrl = (profileId: string, path: string): string => { const appCfg = getConfig(); @@ -13,7 +14,14 @@ export const buildUrl = (profileId: string, path: string): string => { export const extractAccountIdFromKid = (kid: string, profileId: string): string => { const kidPrefix = buildUrl(profileId, "/accounts/"); if (!kid.startsWith(kidPrefix)) { - throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); + throw new AcmeAccountDoesNotExistError({ message: "KID must start with the profile account URL" }); } return z.string().uuid().parse(kid.slice(kidPrefix.length)); }; + +export const validateDnsIdentifier = (identifier: string): boolean => { + // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen + const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); + const labels = identifier.split("."); + return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts index 4c7d6c3c1..58ca7e833 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -1,4 +1,3 @@ -import RE2 from "re2"; import { z } from "zod"; export enum AcmeIdentifierType { @@ -88,13 +87,8 @@ export const CreateAcmeAccountResponseSchema = z.object({ export const CreateAcmeOrderBodySchema = z.object({ identifiers: z.array( z.object({ - type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), - value: z.string().refine((val) => { - // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen - const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); - const labels = val.split("."); - return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); - }, "Invalid DNS identifier") + type: z.string(), + value: z.string() }) ), notBefore: z.string().optional(), diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 1e7123353..43da08b1c 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -12,12 +12,26 @@ import { z, ZodError } from "zod"; import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +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 { 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 { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType, @@ -28,6 +42,7 @@ 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 { TLicenseServiceFactory } from "../license/license-service"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; @@ -44,7 +59,7 @@ import { AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; -import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns"; +import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns"; import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; import { @@ -77,9 +92,14 @@ import { } from "./pki-acme-types"; type TPkiAcmeServiceFactoryDep = { - projectDAL: Pick; + projectDAL: Pick; + appConnectionDAL: Pick; + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + externalCertificateAuthorityDAL: Pick; certificateProfileDAL: Pick; - certificateBodyDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; acmeAccountDAL: Pick< TPkiAcmeAccountDALFactory, "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" @@ -100,15 +120,24 @@ type TPkiAcmeServiceFactoryDep = { "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" >; keyStore: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" | "createCipherPairWithDataKey" + >; + licenseService: Pick; certificateV3Service: Pick; acmeChallengeService: TPkiAcmeChallengeServiceFactory; }; export const pkiAcmeServiceFactory = ({ projectDAL, + appConnectionDAL, + certificateDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, + certificateSecretDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -116,6 +145,7 @@ export const pkiAcmeServiceFactory = ({ acmeChallengeDAL, keyStore, kmsService, + licenseService, certificateV3Service, acmeChallengeService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { @@ -127,6 +157,12 @@ export const pkiAcmeServiceFactory = ({ if (profile.enrollmentType !== EnrollmentType.ACME) { throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" }); } + const orgLicensePlan = await licenseService.getPlan(profile.project!.orgId); + if (!orgLicensePlan.pkiAcme) { + throw new AcmeUnauthorizedError({ + message: "Failed to validate ACME profile: Plan restriction. Upgrade plan to continue" + }); + } return profile; }; @@ -148,7 +184,7 @@ export const pkiAcmeServiceFactory = ({ try { result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { if (protectedHeader === undefined) { - throw new AcmeMalformedError({ detail: "Protected header is required" }); + throw new AcmeMalformedError({ message: "Protected header is required" }); } const jwk = await getJWK(protectedHeader); const key = await importJWK(jwk, protectedHeader.alg); @@ -159,28 +195,35 @@ export const pkiAcmeServiceFactory = ({ throw error; } if (error instanceof ZodError) { - throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` }); } if (error instanceof errors.JWSSignatureVerificationFailed) { - throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); + throw new AcmeBadPublicKeyError({ message: "Invalid JWS payload" }); } logger.error(error, "Unexpected error while verifying JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + throw new AcmeMalformedError({ message: "Failed to verify JWS payload" }); } const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; try { const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + const parsedUrl = (() => { + try { + return new URL(protectedHeader.url); + } catch (error) { + throw new AcmeMalformedError({ message: "Invalid URL in the protected header" }); + } + })(); // Validate the URL - if (new URL(protectedHeader.url).href !== url.href) { - throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); + if (parsedUrl.href !== url.href) { + throw new AcmeMalformedError({ message: "URL mismatch in the protected header" }); } // Consume the nonce if (!protectedHeader.nonce) { - throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" }); + throw new AcmeMalformedError({ message: "Nonce is required in the protected header" }); } const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce)); if (deleted !== 1) { - throw new AcmeBadNonceError({ detail: "Invalid nonce" }); + throw new AcmeBadNonceError({ message: "Invalid nonce" }); } // Parse the payload @@ -196,10 +239,10 @@ export const pkiAcmeServiceFactory = ({ throw error; } if (error instanceof ZodError) { - throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + throw new AcmeMalformedError({ message: `Invalid JWS payload: ${error.message}` }); } logger.error(error, "Unexpected error while parsing JWS payload"); - throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + throw new AcmeMalformedError({ message: "Failed to verify JWS payload" }); } }; @@ -215,7 +258,7 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload, getJWK: async (protectedHeader) => { if (!protectedHeader.jwk) { - throw new AcmeMalformedError({ detail: "JWK is required in the protected header" }); + throw new AcmeMalformedError({ message: "JWK is required in the protected header" }); } return protectedHeader.jwk as unknown as JsonWebKey; }, @@ -246,18 +289,18 @@ export const pkiAcmeServiceFactory = ({ rawJwsPayload, getJWK: async (protectedHeader) => { if (!protectedHeader.kid) { - throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); + throw new AcmeMalformedError({ message: "KID is required in the protected header" }); } const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); if (expectedAccountId && accountId !== expectedAccountId) { - throw new NotFoundError({ message: "ACME resource not found" }); + throw new AcmeAccountDoesNotExistError({ message: "ACME resource not found" }); } const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId); if (!account) { throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); } if (account.alg !== protectedHeader.alg) { - throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" }); + throw new AcmeMalformedError({ message: "ACME account algorithm mismatch" }); } return account.publicKey as JsonWebKey; }, @@ -344,7 +387,7 @@ export const pkiAcmeServiceFactory = ({ }): Promise> => { const profile = await validateAcmeProfile(profileId); if (!externalAccountBinding) { - throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding is required" }); } const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); @@ -363,26 +406,28 @@ export const pkiAcmeServiceFactory = ({ return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader }; } catch (error) { if (error instanceof errors.JWSSignatureVerificationFailed) { - throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" }); + throw new AcmeExternalAccountRequiredError({ message: "Invalid external account binding JWS signature" }); } logger.error(error, "Unexpected error while verifying EAB JWS signature"); - throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" }); + throw new AcmeServerInternalError({ message: "Failed to verify EAB JWS signature" }); } })(); const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { - throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + throw new AcmeExternalAccountRequiredError({ + message: "Invalid algorithm for external account binding JWS payload" + }); } // Make sure the KID in the EAB payload matches the profile ID if (eabKid !== profile.id) { - throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding KID mismatch" }); } // Make sure the URL matches the expected URL const url = eabProtectedHeader!.url!; if (url !== buildUrl(profile.id, "/new-account")) { - throw new UnauthorizedError({ message: "External account binding URL mismatch" }); + throw new AcmeExternalAccountRequiredError({ message: "External account binding URL mismatch" }); } // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload @@ -481,6 +526,21 @@ export const pkiAcmeServiceFactory = ({ // TODO: check the identifiers and see if are they even allowed for this profile. // if not, we may be able to reject it early with an unsupportedIdentifier error. + // TODO: ideally, we should return an error with subproblems if we have multiple unsupported identifiers + if (payload.identifiers.some((identifier) => identifier.type !== AcmeIdentifierType.DNS)) { + throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" }); + } + if ( + payload.identifiers.some( + (identifier) => + !validateDnsIdentifier(identifier.value) || + isPrivateIp(identifier.value) || + (!getConfig().isDevelopmentMode && identifier.value.toLowerCase() === "localhost") + ) + ) { + throw new AcmeUnsupportedIdentifierError({ message: "Invalid DNS identifier" }); + } + const order = await acmeOrderDAL.transaction(async (tx) => { const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; const createdOrder = await acmeOrderDAL.create( @@ -497,10 +557,10 @@ export const pkiAcmeServiceFactory = ({ const authorizations: TPkiAcmeAuths[] = await Promise.all( payload.identifiers.map(async (identifier) => { if (identifier.type !== AcmeIdentifierType.DNS) { - throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); + throw new AcmeUnsupportedIdentifierError({ message: "Only DNS identifiers are supported" }); } if (isPrivateIp(identifier.value)) { - throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" }); + throw new AcmeUnsupportedIdentifierError({ message: "Private IP addresses are not allowed" }); } const auth = await acmeAuthDAL.create( { @@ -588,6 +648,7 @@ export const pkiAcmeServiceFactory = ({ orderId: string; payload: TFinalizeAcmeOrderPayload; }): Promise> => { + const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!; let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); @@ -603,28 +664,100 @@ export const pkiAcmeServiceFactory = ({ if (finalizingOrder.expiresAt < new Date()) { throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); } + const { csr } = payload; + + // Check and validate the CSR + const certificateRequest = extractCertificateRequestFromCSR(csr); + if (!certificateRequest.commonName) { + throw new AcmeBadCSRError({ message: "Invalid CSR: Common name is required" }); + } + if ( + certificateRequest.subjectAlternativeNames?.some( + (san) => san.type !== CertSubjectAlternativeNameType.DNS_NAME + ) + ) { + throw new AcmeBadCSRError({ message: "Invalid CSR: Only DNS subject alternative names are supported" }); + } + const orderWithAuthorizations = (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations( + accountId, + orderId, + tx + ))!; + const csrIdentifierValues = new Set( + (certificateRequest.subjectAlternativeNames ?? []) + .map((san) => san.value.toLowerCase()) + .concat([certificateRequest.commonName.toLowerCase()]) + ); + if ( + csrIdentifierValues.size !== orderWithAuthorizations.authorizations.length || + !orderWithAuthorizations.authorizations.every((auth) => + csrIdentifierValues.has(auth.identifierValue.toLowerCase()) + ) + ) { + throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; let errorToReturn: Error | undefined; try { - const { certificateId } = await 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 - ? { - // TODO: read config from the profile to get the expiration time instead - ttl: (24 * 60 * 60 * 1000).toString() - } - : // ttl is not used if notAfter is provided - ({ ttl: "0" } as const), - enrollmentType: EnrollmentType.ACME - }); - // TODO: associate the certificate with the order + const { certificateId } = await (async () => { + if (caType === CaType.INTERNAL) { + const result = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, + validity: !finalizingOrder.notAfter + ? { + // 47 days, the default TTL comes with Let's Encrypt + // TODO: read config from the profile to get the expiration time instead + ttl: `${47}d` + } + : // ttl is not used if notAfter is provided + ({ ttl: "0d" } as const), + enrollmentType: EnrollmentType.ACME + }); + return { certificateId: result.certificateId }; + } + const { certificateAuthority } = (await certificateProfileDAL.findByIdWithConfigs(profileId, tx))!; + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const csrPem = csrObj.toString("pem"); + // TODO: for internal CA, we rely on the internal certificate authority service to check CSR against the template + // we should check the CSR against the template here + // TODO: this is pretty slow, and we are holding the transaction open for a long time, + // we should queue the certificate issuance to a background job instead + const cert = await orderCertificate( + { + caId: certificateAuthority!.id, + commonName: certificateRequest.commonName!, + altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), + csr: Buffer.from(csrPem), + // TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now + keyUsages: [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT, CertKeyUsage.KEY_AGREEMENT], + extendedKeyUsages: [CertExtendedKeyUsage.SERVER_AUTH] + }, + { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL + } + ); + return { certificateId: cert.id }; + })(); await acmeOrderDAL.updateById( orderId, { @@ -647,9 +780,9 @@ export const pkiAcmeServiceFactory = ({ logger.error(exp, "Failed to sign certificate"); // TODO: audit log the error if (exp instanceof BadRequestError) { - errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` }); + errorToReturn = new AcmeBadCSRError({ message: `Invalid CSR: ${exp.message}` }); } else { - errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); + errorToReturn = new AcmeServerInternalError({ message: "Failed to sign certificate with internal error" }); } } return { diff --git a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts index 9b1fd14a0..cc96cf327 100644 --- a/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/ldap-password/ldap-password-rotation-fns.ts @@ -10,7 +10,7 @@ import { import { logger } from "@app/lib/logger"; import { DistinguishedNameRegex } from "@app/lib/regex"; import { encryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; -import { getLdapConnectionClient, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; +import { executeWithPotentialGateway, LdapProvider, TLdapConnection } from "@app/services/app-connection/ldap"; import { generatePassword } from "../shared/utils"; import { @@ -71,17 +71,18 @@ export const ldapPasswordRotationFactory: TRotationFactory< TLdapPasswordRotationWithConnection, TLdapPasswordRotationGeneratedCredentials, TLdapPasswordRotationInput["temporaryParameters"] -> = (secretRotation, appConnectionDAL, kmsService) => { +> = (secretRotation, appConnectionDAL, kmsService, gatewayService, gatewayV2Service) => { const { connection, parameters, secretsMapping, activeIndex } = secretRotation; const { dn, passwordRequirements } = parameters; const $verifyCredentials = async (credentials: Pick) => { try { - const client = await getLdapConnectionClient({ ...connection.credentials, ...credentials }); - - client.unbind(); - client.destroy(); + await executeWithPotentialGateway( + { ...connection, credentials: { ...connection.credentials, ...credentials } }, + gatewayV2Service, + async () => {} + ); } catch (error) { throw new Error(`Failed to verify credentials - ${(error as Error).message}`); } @@ -92,17 +93,7 @@ export const ldapPasswordRotationFactory: TRotationFactory< if (!credentials.url.startsWith("ldaps")) throw new Error("Password Rotation requires an LDAPS connection"); - const client = await getLdapConnectionClient( - currentPassword - ? { - ...credentials, - password: currentPassword, - dn - } - : credentials - ); const isConnectionRotation = credentials.dn === dn; - const password = generatePassword(passwordRequirements); let changes: ldap.Change[] | ldap.Change; @@ -147,22 +138,32 @@ export const ldapPasswordRotationFactory: TRotationFactory< throw new Error(`Unhandled provider: ${credentials.provider as LdapProvider}`); } - try { - const userDn = await getDN(dn, client); - await new Promise((resolve, reject) => { - client.modify(userDn, changes, (err) => { - if (err) { - logger.error(err, "LDAP Password Rotation Failed"); - reject(new Error(`Provider Modify Error: ${err.message}`)); - } else { - resolve(true); - } + await executeWithPotentialGateway( + { + ...connection, + credentials: currentPassword + ? { + ...credentials, + password: currentPassword, + dn + } + : credentials + }, + gatewayV2Service, + async (client) => { + const userDn = await getDN(dn, client); + await new Promise((resolve, reject) => { + client.modify(userDn, changes, (err) => { + if (err) { + logger.error(err, "LDAP Password Rotation Failed"); + reject(new Error(`Provider Modify Error: ${err.message}`)); + } else { + resolve(); + } + }); }); - }); - } finally { - client.unbind(); - client.destroy(); - } + } + ); await $verifyCredentials({ dn, password }); diff --git a/backend/src/ee/services/secret-sync/chef/chef-sync-schemas.ts b/backend/src/ee/services/secret-sync/chef/chef-sync-schemas.ts index 9702f97d3..03b5e7f77 100644 --- a/backend/src/ee/services/secret-sync/chef/chef-sync-schemas.ts +++ b/backend/src/ee/services/secret-sync/chef/chef-sync-schemas.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { BaseSecretSyncSchema, GenericCreateSecretSyncFieldsSchema, @@ -25,10 +26,12 @@ const ChefSyncDestinationConfigSchema = z.object({ const ChefSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Chef), - destinationConfig: ChefSyncDestinationConfigSchema -}); +export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Chef), + destinationConfig: ChefSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Chef] })); export const CreateChefSyncSchema = GenericCreateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({ destinationConfig: ChefSyncDestinationConfigSchema @@ -38,10 +41,12 @@ export const UpdateChefSyncSchema = GenericUpdateSecretSyncFieldsSchema(SecretSy destinationConfig: ChefSyncDestinationConfigSchema.optional() }); -export const ChefSyncListItemSchema = z.object({ - name: z.literal("Chef"), - connection: z.literal(AppConnection.Chef), - destination: z.literal(SecretSync.Chef), - canImportSecrets: z.literal(true), - enterprise: z.boolean() -}); +export const ChefSyncListItemSchema = z + .object({ + name: z.literal("Chef"), + connection: z.literal(AppConnection.Chef), + destination: z.literal(SecretSync.Chef), + canImportSecrets: z.literal(true), + enterprise: z.boolean() + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Chef] })); diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts index a0bd29382..7f0319420 100644 --- a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-schemas.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { SecretSyncs } from "@app/lib/api-docs"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { SECRET_SYNC_NAME_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { BaseSecretSyncSchema, GenericCreateSecretSyncFieldsSchema, @@ -43,10 +44,12 @@ const OCIVaultSyncDestinationConfigSchema = z.object({ const OCIVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const OCIVaultSyncSchema = BaseSecretSyncSchema(SecretSync.OCIVault, OCIVaultSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.OCIVault), - destinationConfig: OCIVaultSyncDestinationConfigSchema -}); +export const OCIVaultSyncSchema = BaseSecretSyncSchema(SecretSync.OCIVault, OCIVaultSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.OCIVault), + destinationConfig: OCIVaultSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OCIVault] })); export const CreateOCIVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.OCIVault, @@ -62,10 +65,12 @@ export const UpdateOCIVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: OCIVaultSyncDestinationConfigSchema.optional() }); -export const OCIVaultSyncListItemSchema = z.object({ - name: z.literal("OCI Vault"), - connection: z.literal(AppConnection.OCI), - destination: z.literal(SecretSync.OCIVault), - canImportSecrets: z.literal(true), - enterprise: z.boolean() -}); +export const OCIVaultSyncListItemSchema = z + .object({ + name: z.literal("OCI Vault"), + connection: z.literal(AppConnection.OCI), + destination: z.literal(SecretSync.OCIVault), + canImportSecrets: z.literal(true), + enterprise: z.boolean() + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OCIVault] })); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 09adf2b8b..8bd2827fc 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -33,11 +33,13 @@ export enum ApiDocsTags { LdapAuth = "LDAP Auth", Groups = "Groups", Organizations = "Organizations", + OrgIdentityMembership = "Organization Identity Membership", SubOrganizations = "Sub Organizations", Projects = "Projects", ProjectUsers = "Project Users", ProjectGroups = "Project Groups", ProjectIdentities = "Project Identities", + IdentityProjectMembership = "Project Identity Membership", ProjectRoles = "Project Roles", ProjectTemplates = "Project Templates", Environments = "Environments", @@ -122,13 +124,15 @@ export const IDENTITIES = { name: "The name of the identity to create.", organizationId: "The organization ID to which the identity belongs.", role: "The role of the identity. Possible values are 'no-access', 'member', and 'admin'.", - hasDeleteProtection: "Prevents deletion of the identity when enabled." + hasDeleteProtection: "Prevents deletion of the identity when enabled.", + metadata: "An optional array of key-value pairs to attach to the identity." }, UPDATE: { identityId: "The ID of the machine identity to update.", name: "The new name of the identity.", role: "The new role of the identity.", - hasDeleteProtection: "Prevents deletion of the identity when enabled." + hasDeleteProtection: "Prevents deletion of the identity when enabled.", + metadata: "An optional array of key-value pairs to attach to the identity." }, DELETE: { identityId: "The ID of the machine identity to delete." @@ -138,7 +142,10 @@ export const IDENTITIES = { orgId: "The ID of the org of the identity" }, LIST: { - orgId: "The ID of the organization to list identities." + orgId: "The ID of the organization to list identities.", + search: "The text string that identity names will be filtered by.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return." }, SEARCH: { search: { @@ -723,6 +730,50 @@ export const ORGANIZATIONS = { } } as const; +export const ORG_IDENTITY_MEMBERSHIP = { + CREATE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to create the membership for.", + roles: { + description: "A list of role slugs to assign to the identity organization membership.", + role: "The role slug to assign to the newly created identity organization membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + UPDATE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity organization membership.", + role: "The role slug to assign to the identity organization membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + DELETE_IDENTITY_MEMBERSHIP: { + identityId: "The ID of the machine identity to delete the membership from." + }, + LIST_IDENTITY_MEMBERSHIPS: { + offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", + limit: "The number of identity memberships to return.", + identityName: "", + roles: "The role slugs to filter identity memberships by." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + identityId: "The ID of the machine identity to get the membership for." + }, + LIST_AVAILABLE_IDENTITIES: { + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + identityName: "The text string that identity membership names will be filtered by." + } +} as const; + export const SUB_ORGANIZATIONS = { CREATE: { name: "The name of the sub organization to create." @@ -911,6 +962,56 @@ export const PROJECT_IDENTITIES = { } }; +export const PROJECT_IDENTITY_MEMBERSHIP = { + CREATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to create the identity membership for.", + identityId: "The ID of the machine identity to create the membership for.", + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the newly created identity project membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + UPDATE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to update the identity membership for.", + identityId: "The ID of the machine identity to update the membership for.", + roles: { + description: "A list of role slugs to assign to the identity project membership.", + role: "The role slug to assign to the identity project membership.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", + temporaryMode: "Type of temporary expiry.", + temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s, 2m, 3h, etc.", + temporaryAccessStartTime: "Time to which the temporary access starts." + } + }, + DELETE_IDENTITY_MEMBERSHIP: { + projectId: "The ID of the project to delete the identity membership from.", + identityId: "The ID of the machine identity to delete the membership from." + }, + LIST_IDENTITY_MEMBERSHIPS: { + projectId: "The ID of the project to list identity memberships from.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity membership.", + limit: "The number of identity memberships to return.", + identityName: "The text string that identity membership names will be filtered by.", + roles: "The role slugs to filter identity memberships by." + }, + GET_IDENTITY_MEMBERSHIP_BY_ID: { + projectId: "The ID of the project to get the identity membership for.", + identityId: "The ID of the machine identity to get the membership for." + }, + LIST_AVAILABLE_IDENTITIES: { + projectId: "The ID of the project to list available identities for.", + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + identityName: "The text string that identity membership names will be filtered by." + } +} as const; + export const ENVIRONMENTS = { CREATE: { projectId: "The ID of the project to create the environment in.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index b60971b1f..96107306f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -106,11 +106,9 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), - // Note: The ACME feature is still in development and is not yet ready for production. - // This is the feature flag to enable/disable the ACME feature. - // It's not intended to be used by users outside of the development team yet. - ACME_FEATURE_ENABLED: zodStrBool.default("false").optional(), + BDD_NOCK_API_ENABLED: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + ACME_SKIP_UPSTREAM_VALIDATION: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr( z .string() @@ -399,10 +397,10 @@ const envSchema = z (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test", isDailyResourceCleanUpDevelopmentMode: data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, - isAcmeFeatureEnabled: data.NODE_ENV === "development" && data.ACME_FEATURE_ENABLED === true, isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), + isBddNockApiEnabled: data.NODE_ENV === "development" && data.BDD_NOCK_API_ENABLED, REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() ?.split(",") .map((el) => { diff --git a/backend/src/lib/validator/validate-url.ts b/backend/src/lib/validator/validate-url.ts index a4c07b37d..7b2b09209 100644 --- a/backend/src/lib/validator/validate-url.ts +++ b/backend/src/lib/validator/validate-url.ts @@ -8,10 +8,10 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "../errors"; import { isPrivateIp } from "../ip/ipRange"; -export const blockLocalAndPrivateIpAddresses = async (url: string) => { +export const blockLocalAndPrivateIpAddresses = async (url: string, isGateway = false) => { const appCfg = getConfig(); - if (appCfg.isDevelopmentMode) return; + if (appCfg.isDevelopmentMode || isGateway) return; const validUrl = new URL(url); diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index 31f3139ec..e6ba2eec7 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -182,8 +182,8 @@ export const injectIdentity = fp( case AuthMode.IDENTITY_ACCESS_TOKEN: { const identity = await server.services.identityAccessToken.fnValidateIdentityAccessToken( token, - subOrganizationSelector, - req.realIp + req.realIp, + subOrganizationSelector ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 4b29f6930..e703df5ef 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -252,8 +252,7 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: error.name, status: error.status, type: `urn:ietf:params:acme:error:${error.type}`, - detail: error.detail, - message: error.message + detail: error.message // TODO: add subproblems if they exist }); } else { diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 4330f9397..b71451b6e 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -31,10 +31,7 @@ export const registerServeUI = async ( CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY, POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY, INTERCOM_ID: appCfg.INTERCOM_ID, - TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED, - // The feature flag to enable/disable the ACME feature. - // Will be removed once the feature is ready for production. - ACME_FEATURE_ENABLED: appCfg.isAcmeFeatureEnabled + TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; return res.send(js); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7c2e3b326..5dd7a1c22 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -241,6 +241,8 @@ import { identityTokenAuthServiceFactory } from "@app/services/identity-token-au import { identityUaClientSecretDALFactory } from "@app/services/identity-ua/identity-ua-client-secret-dal"; import { identityUaDALFactory } from "@app/services/identity-ua/identity-ua-dal"; import { identityUaServiceFactory } from "@app/services/identity-ua/identity-ua-service"; +import { identityV2DALFactory } from "@app/services/identity-v2/identity-dal"; +import { identityV2ServiceFactory } from "@app/services/identity-v2/identity-service"; import { integrationDALFactory } from "@app/services/integration/integration-dal"; import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; @@ -445,6 +447,7 @@ export const registerRoutes = async ( const serviceTokenDAL = serviceTokenDALFactory(db); const identityDAL = identityDALFactory(db); + const identityV2DAL = identityV2DALFactory(db); const identityMetadataDAL = identityMetadataDALFactory(db); const identityAccessTokenDAL = identityAccessTokenDALFactory(db); const identityOrgMembershipDAL = identityOrgDALFactory(db); @@ -640,7 +643,8 @@ export const registerRoutes = async ( projectDAL, identityDAL, userDAL, - externalGroupOrgRoleMappingDAL + externalGroupOrgRoleMappingDAL, + membershipRoleDAL }); const additionalPrivilegeService = additionalPrivilegeServiceFactory({ additionalPrivilegeDAL, @@ -1184,6 +1188,7 @@ export const registerRoutes = async ( certificateAuthorityDAL, certificateAuthorityCertDAL, permissionService, + licenseService, kmsService, projectDAL }); @@ -1655,6 +1660,17 @@ export const registerRoutes = async ( membershipIdentityDAL, membershipRoleDAL }); + + const identityV2Service = identityV2ServiceFactory({ + membershipIdentityDAL, + membershipRoleDAL, + identityMetadataDAL, + licenseService, + permissionService, + identityDAL: identityV2DAL, + keyStore + }); + const identityProjectService = identityProjectServiceFactory({ identityProjectDAL, membershipIdentityDAL, @@ -2229,8 +2245,13 @@ export const registerRoutes = async ( }); const pkiAcmeService = pkiAcmeServiceFactory({ projectDAL, + appConnectionDAL, + certificateDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, certificateProfileDAL, certificateBodyDAL, + certificateSecretDAL, acmeAccountDAL, acmeOrderDAL, acmeAuthDAL, @@ -2238,6 +2259,7 @@ export const registerRoutes = async ( acmeChallengeDAL, keyStore, kmsService, + licenseService, certificateV3Service, acmeChallengeService }); @@ -2457,7 +2479,8 @@ export const registerRoutes = async ( integrationAuth: integrationAuthService, webhook: webhookService, serviceToken: serviceTokenService, - identity: identityService, + identityV1: identityService, + identityV2: identityV2Service, identityAuthTemplate: identityAuthTemplateService, identityAccessToken: identityAccessTokenService, identityTokenAuth: identityTokenAuthService, diff --git a/backend/src/server/routes/v1/bdd-nock-router.ts b/backend/src/server/routes/v1/bdd-nock-router.ts new file mode 100644 index 000000000..6a32cac20 --- /dev/null +++ b/backend/src/server/routes/v1/bdd-nock-router.ts @@ -0,0 +1,87 @@ +// import { z } from "zod"; + +// import { getConfig } from "@app/lib/config/env"; +// import { ForbiddenRequestError } from "@app/lib/errors"; +// import { logger } from "@app/lib/logger"; +// import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +// import { AuthMode } from "@app/services/auth/auth-type"; + +// export const registerBddNockRouter = async (server: FastifyZodProvider) => { +// const checkIfBddNockApiEnabled = () => { +// const appCfg = getConfig(); +// // Note: Please note that this API is only available in development mode and only for BDD tests. +// // This endpoint should NEVER BE ENABLED IN PRODUCTION! +// if (appCfg.NODE_ENV !== "development" || !appCfg.isBddNockApiEnabled) { +// throw new ForbiddenRequestError({ message: "BDD Nock API is not enabled" }); +// } +// }; + +// server.route({ +// method: "POST", +// url: "/define", +// schema: { +// body: z.object({ definitions: z.unknown().array() }), +// response: { +// 200: z.object({ status: z.string() }) +// } +// }, +// onRequest: verifyAuth([AuthMode.JWT]), +// handler: async (req) => { +// checkIfBddNockApiEnabled(); +// const { body } = req; +// const { definitions } = body; +// logger.info(definitions, "Defining nock"); +// const processedDefinitions = definitions.map((definition: unknown) => { +// const { path, ...rest } = definition as Definition; +// return { +// ...rest, +// path: +// path !== undefined && typeof path === "string" +// ? path +// : new RegExp((path as unknown as { regex: string }).regex ?? "") +// } as Definition; +// }); + +// nock.define(processedDefinitions); +// // Ensure we are activating the nocks, because we could have called `nock.restore()` before this call. +// if (!nock.isActive()) { +// nock.activate(); +// } +// return { status: "ok" }; +// } +// }); + +// server.route({ +// method: "POST", +// url: "/clean-all", +// schema: { +// response: { +// 200: z.object({ status: z.string() }) +// } +// }, +// onRequest: verifyAuth([AuthMode.JWT]), +// handler: async () => { +// checkIfBddNockApiEnabled(); +// logger.info("Cleaning all nocks"); +// nock.cleanAll(); +// return { status: "ok" }; +// } +// }); + +// server.route({ +// method: "POST", +// url: "/restore", +// schema: { +// response: { +// 200: z.object({ status: z.string() }) +// } +// }, +// onRequest: verifyAuth([AuthMode.JWT]), +// handler: async () => { +// checkIfBddNockApiEnabled(); +// logger.info("Restore network requests from nock"); +// nock.restore(); +// return { status: "ok" }; +// } +// }); +// }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index 443d64e22..e8cdbb540 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ +import RE2 from "re2"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -616,4 +617,64 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { }; } }); + + server.route({ + method: "POST", + url: "/:serialNumber/pkcs12", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.PkiCertificates], + description: "Download certificate in PKCS12 format", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + body: z.object({ + password: z + .string() + .min(6, "Password must be at least 6 characters long") + .describe("Password for the keystore (minimum 6 characters)"), + alias: z.string().min(1, "Alias is required").describe("Alias for the certificate in the keystore") + }), + response: { + 200: z.any().describe("PKCS12 keystore as binary data") + } + }, + handler: async (req, reply) => { + const { pkcs12Data, cert } = await server.services.certificate.getCertPkcs12({ + serialNumber: req.params.serialNumber, + password: req.body.password, + alias: req.body.alias, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: cert.projectId, + event: { + type: EventType.EXPORT_CERT_PKCS12, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + addNoCacheHeaders(reply); + reply.header("Content-Type", "application/octet-stream"); + reply.header( + "Content-Disposition", + `attachment; filename="certificate-${req.params.serialNumber.replace(new RE2("[^\\w.-]", "g"), "_")}.p12"` + ); + + return pkcs12Data; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-project-router.ts b/backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts similarity index 97% rename from backend/src/server/routes/v1/identity-project-router.ts rename to backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts index fd39c7efe..2ac530d00 100644 --- a/backend/src/server/routes/v1/identity-project-router.ts +++ b/backend/src/server/routes/v1/deprecated-identity-project-membership-router.ts @@ -19,7 +19,7 @@ import { ProjectIdentityOrderBy } from "@app/services/identity-project/identity- import { SanitizedProjectSchema } from "../sanitizedSchemas"; -export const registerIdentityProjectRouter = async (server: FastifyZodProvider) => { +export const registerDeprecatedIdentityProjectMembershipRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/:projectId/identity-memberships/:identityId", @@ -293,7 +293,7 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) temporaryAccessEndTime: z.date().nullable().optional() }) ), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + identity: IdentitiesSchema.pick({ name: true, id: true, projectId: true, orgId: true }).extend({ authMethods: z.array(z.string()) }), project: SanitizedProjectSchema.pick({ name: true, id: true }) @@ -362,7 +362,9 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) temporaryAccessEndTime: z.date().nullable().optional() }) ), - identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional(), + identity: IdentitiesSchema.pick({ name: true, id: true, projectId: true, orgId: true }).extend({ authMethods: z.array(z.string()) }), project: SanitizedProjectSchema.pick({ name: true, id: true }) diff --git a/backend/src/server/routes/v1/identity-org-membership-router.ts b/backend/src/server/routes/v1/identity-org-membership-router.ts index c9b93965a..275ed5475 100644 --- a/backend/src/server/routes/v1/identity-org-membership-router.ts +++ b/backend/src/server/routes/v1/identity-org-membership-router.ts @@ -1,9 +1,10 @@ import { z } from "zod"; -import { AccessScope, TemporaryPermissionMode } from "@app/db/schemas"; -import { ApiDocsTags, PROJECT_IDENTITIES } from "@app/lib/api-docs"; +import { AccessScope, IdentitiesSchema, MembershipRolesSchema, TemporaryPermissionMode } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, ORG_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; import { ms } from "@app/lib/ms"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -15,7 +16,7 @@ const sanitizedOrgIdentityMembershipSchema = z.object({ updatedAt: z.date() }); -export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProvider) => { +export const registerIdentityOrgMembershipRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", url: "/identity-memberships/:identityId", @@ -25,8 +26,7 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - // this is hidden so not updating tags - tags: [ApiDocsTags.ProjectIdentities], + tags: [ApiDocsTags.OrgIdentityMembership], description: "Create org identity membership", security: [ { @@ -34,38 +34,40 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } ], params: z.object({ - identityId: z.string().trim() + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.identityId) }), body: z.object({ roles: z .array( z.union([ z.object({ - role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), isTemporary: z .literal(false) .default(false) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary) }), z.object({ - role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), - isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.isTemporary), temporaryMode: z .nativeEnum(TemporaryPermissionMode) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), temporaryRange: z .string() .refine((val) => ms(val) > 0, "Temporary range must be a positive number") - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), temporaryAccessStartTime: z .string() .datetime() - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) }) ]) ) - .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) - .max(1) + .describe(ORG_IDENTITY_MEMBERSHIP.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .min(1) }), response: { 200: z.object({ @@ -86,12 +88,115 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } }); + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + return { identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } }; } }); + server.route({ + method: "PATCH", + url: "/identity-memberships/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Update org identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(true) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(ORG_IDENTITY_MEMBERSHIP.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + roles: MembershipRolesSchema.array() + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + roles: membership.roles.map((el) => ({ ...el, membershipId: membership.id })) + }; + } + }); + server.route({ method: "DELETE", url: "/identity-memberships/:identityId", @@ -101,15 +206,15 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv onRequest: verifyAuth([AuthMode.JWT]), schema: { hide: true, - tags: [ApiDocsTags.ProjectIdentities], - description: "Delete org identity memberships", + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Delete org identity membership", security: [ { bearerAuth: [] } ], params: z.object({ - identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.DELETE_IDENTITY_MEMBERSHIP.identityId) }), response: { 200: z.object({ @@ -129,9 +234,226 @@ export const registerOrgIdentityMembershipRouter = async (server: FastifyZodProv } }); + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY_ORG_MEMBERSHIP, + metadata: { + identityId: req.params.identityId + } + } + }); + return { identityMembership: { ...membership, identityId: req.params.identityId, orgId: req.permission.orgId } }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List org identity memberships", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: true, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "Get org identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(ORG_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()) + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.OrgIdentityMembership], + description: "List available identities for org membership", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(100) + .default(20) + .describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional(), + identityName: z.string().describe(ORG_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.identityName).optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName + } + }); + + return { identities }; + } + }); }; diff --git a/backend/src/server/routes/v1/identity-project-membership-router.ts b/backend/src/server/routes/v1/identity-project-membership-router.ts new file mode 100644 index 000000000..74ffdb39b --- /dev/null +++ b/backend/src/server/routes/v1/identity-project-membership-router.ts @@ -0,0 +1,493 @@ +import { z } from "zod"; + +import { + AccessScope, + IdentitiesSchema, + IdentityProjectMembershipsSchema, + ProjectMembershipRole, + TemporaryPermissionMode +} from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, PROJECT_IDENTITIES, PROJECT_IDENTITY_MEMBERSHIP } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerIdentityProjectMembershipRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Create project identity membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim(), + identityId: z.string().trim() + }), + body: z.object({ + // @depreciated + role: z.string().trim().optional().default(ProjectMembershipRole.NoAccess), + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.role) + }) + ]) + ) + .describe(PROJECT_IDENTITIES.CREATE_IDENTITY_MEMBERSHIP.roles.description) + .optional() + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { role, roles } = req.body; + if (!role && !roles) throw new BadRequestError({ message: "You must provide either role or roles field" }); + + const { membership } = await server.services.membershipIdentity.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + identityId: req.params.identityId, + roles: roles || [{ role, isTemporary: false }] + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.CREATE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "PATCH", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Update project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.identityId) + }), + body: z.object({ + roles: z + .array( + z.union([ + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z + .literal(false) + .default(false) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary) + }), + z.object({ + role: z.string().describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.role), + isTemporary: z.literal(true).describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.isTemporary), + temporaryMode: z + .nativeEnum(TemporaryPermissionMode) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryMode), + temporaryRange: z + .string() + .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryRange), + temporaryAccessStartTime: z + .string() + .datetime() + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.temporaryAccessStartTime) + }) + ]) + ) + .min(1) + .describe(PROJECT_IDENTITIES.UPDATE_IDENTITY_MEMBERSHIP.roles.description) + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.updateMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + }, + data: { + roles: req.body.roles + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId, + roles: req.body.roles + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "DELETE", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.ProjectIdentities], + description: "Delete project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITIES.DELETE_IDENTITY_MEMBERSHIP.identityId) + }), + response: { + 200: z.object({ + identityMembership: IdentityProjectMembershipsSchema + }) + } + }, + handler: async (req) => { + const { membership } = await server.services.membershipIdentity.deleteMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: req.permission.orgId, + projectId: req.params.projectId, + event: { + type: EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { + identityMembership: { ...membership, identityId: req.params.identityId, projectId: req.params.projectId } + }; + } + }); + + server.route({ + method: "GET", + url: "/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "List project identity memberships", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(1000) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.identityName) + .optional(), + roles: z + .string() + .transform((val) => val.split(",").map((role) => role.trim())) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_IDENTITY_MEMBERSHIPS.roles) + .optional() + }), + response: { + 200: z.object({ + identityMemberships: z + .object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }) + }) + .array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { data: identityMemberships, totalCount } = await server.services.membershipIdentity.listMemberships({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName, + roles: req.query.roles + } + }); + + return { identityMemberships, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "Get project identity membership by identity ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.projectId), + identityId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.GET_IDENTITY_MEMBERSHIP_BY_ID.identityId) + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + lastLoginAuthMethod: z.string().nullable().optional(), + lastLoginTime: z.date().nullable().optional(), + identity: IdentitiesSchema.pick({ name: true, id: true, orgId: true, projectId: true }).extend({ + authMethods: z.array(z.string()), + metadata: z + .object({ + id: z.string().trim().min(1), + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .optional() + }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.membershipIdentity.getMembershipByIdentityId({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identityMembership }; + } + }); + + server.route({ + method: "GET", + url: "/available-identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + hide: false, + tags: [ApiDocsTags.IdentityProjectMembership], + description: "List available identities for project membership", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.projectId) + }), + querystring: z.object({ + offset: z.coerce + .number() + .min(0) + .default(0) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.offset) + .optional(), + limit: z.coerce + .number() + .min(1) + .max(1000) + .default(20) + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.limit) + .optional(), + identityName: z + .string() + .trim() + .describe(PROJECT_IDENTITY_MEMBERSHIP.LIST_AVAILABLE_IDENTITIES.identityName) + .optional() + }), + response: { + 200: z.object({ + identities: IdentitiesSchema.pick({ id: true, name: true }).array() + }) + } + }, + handler: async (req) => { + const { identities } = await server.services.membershipIdentity.listAvailableIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + identityName: req.query.identityName + } + }); + + return { identities }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index f8e6c78ee..ba8506be4 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -60,7 +60,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.createIdentity({ + const identity = await server.services.identityV1.createIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -136,7 +136,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.updateIdentity({ + const identity = await server.services.identityV1.updateIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -189,7 +189,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.deleteIdentity({ + const identity = await server.services.identityV1.deleteIdentity({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -258,7 +258,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identity = await server.services.identity.getIdentityById({ + const identity = await server.services.identityV1.getIdentityById({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -308,7 +308,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.listOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -402,7 +402,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.searchOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, @@ -468,7 +468,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const identityMemberships = await server.services.identity.listProjectIdentitiesByIdentityId({ + const identityMemberships = await server.services.identityV1.listProjectIdentitiesByIdentityId({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 4300f5698..68099e50e 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -8,12 +8,14 @@ import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; +// import { registerBddNockRouter } from "./bdd-nock-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; +import { registerDeprecatedIdentityProjectMembershipRouter } from "./deprecated-identity-project-membership-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; import { registerDeprecatedProjectRouter } from "./deprecated-project-router"; @@ -33,8 +35,8 @@ import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-rou import { registerIdentityLdapAuthRouter } from "./identity-ldap-auth-router"; import { registerIdentityOciAuthRouter } from "./identity-oci-auth-router"; import { registerIdentityOidcAuthRouter } from "./identity-oidc-auth-router"; -import { registerOrgIdentityMembershipRouter } from "./identity-org-membership-router"; -import { registerIdentityProjectRouter } from "./identity-project-router"; +import { registerIdentityOrgMembershipRouter } from "./identity-org-membership-router"; +import { registerIdentityProjectMembershipRouter } from "./identity-project-membership-router"; import { registerIdentityRouter } from "./identity-router"; import { registerIdentityTlsCertAuthRouter } from "./identity-tls-cert-auth-router"; import { registerIdentityTokenAuthRouter } from "./identity-token-auth-router"; @@ -45,6 +47,7 @@ import { registerInviteOrgRouter } from "./invite-org-router"; import { registerMicrosoftTeamsRouter } from "./microsoft-teams-router"; import { registerNotificationRouter } from "./notification-router"; import { registerOrgAdminRouter } from "./org-admin-router"; +import { registerOrgIdentityRouter } from "./org-identity-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; @@ -52,6 +55,7 @@ import { registerPkiCollectionRouter } from "./pki-collection-router"; import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; import { PKI_SYNC_REGISTER_ROUTER_MAP, registerPkiSyncRouter } from "./pki-sync-routers"; import { registerProjectEnvRouter } from "./project-env-router"; +import { registerProjectIdentityRouter } from "./project-identity-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; @@ -90,8 +94,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/auth" } ); await server.register(registerPasswordRouter, { prefix: "/password" }); - await server.register(registerOrgRouter, { prefix: "/organization" }); - await server.register(registerOrgIdentityMembershipRouter, { prefix: "/organization" }); + await server.register( + async (orgRouter) => { + await orgRouter.register(registerOrgRouter); + await orgRouter.register(registerOrgIdentityRouter); + await orgRouter.register(registerIdentityOrgMembershipRouter); + }, + { prefix: "/organization" } + ); await server.register(registerAdminRouter, { prefix: "/admin" }); await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); @@ -126,14 +136,19 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { async (projectRouter) => { await projectRouter.register(registerProjectRouter); await projectRouter.register(registerProjectMembershipRouter); + await projectRouter.register(registerProjectIdentityRouter); await projectRouter.register(registerProjectEnvRouter); await projectRouter.register(registerSecretTagRouter); await projectRouter.register(registerGroupProjectRouter); - await projectRouter.register(registerIdentityProjectRouter); + await projectRouter.register(registerDeprecatedIdentityProjectMembershipRouter); }, { prefix: "/projects" } ); + await server.register(registerIdentityProjectMembershipRouter, { + prefix: "/projects/:projectId/memberships" + }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); @@ -223,4 +238,10 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerEventRouter, { prefix: "/events" }); await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); + + // Note: This is a special route for BDD tests. It's only available in development mode and only for BDD tests. + // This route should NEVER BE ENABLED IN PRODUCTION! + // if (getConfig().isBddNockApiEnabled) { + // await server.register(registerBddNockRouter, { prefix: "/bdd-nock" }); + // } }; diff --git a/backend/src/server/routes/v1/org-identity-router.ts b/backend/src/server/routes/v1/org-identity-router.ts new file mode 100644 index 000000000..7376959d8 --- /dev/null +++ b/backend/src/server/routes/v1/org-identity-router.ts @@ -0,0 +1,286 @@ +import { z } from "zod"; + +import { AccessScope, IdentitiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, IDENTITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const metadataSchema = z.object({ + key: z.string().trim().min(1, "Metadata key cannot be empty"), + value: z.string().trim().min(1, "Metadata value cannot be empty") +}); + +const sanitizedIdentitySchema = IdentitiesSchema.pick({ + id: true, + name: true, + orgId: true, + projectId: true, + createdAt: true, + updatedAt: true, + hasDeleteProtection: true +}).extend({ + authMethods: z.array(z.string()).optional(), + metadata: z.array(metadataSchema).optional() +}); + +export const registerOrgIdentityRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/identities", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Create an identity", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + name: z.string().trim().min(1).describe(IDENTITIES.CREATE.name), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.CREATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.createIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY, + metadata: { + identityId: identity.id, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "PATCH", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Update an identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.UPDATE.identityId) + }), + body: z.object({ + name: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.name), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.UPDATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.updateIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY, + metadata: { + identityId: req.params.identityId, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "DELETE", + url: "/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Delete an identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.DELETE.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.deleteIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + orgId: req.permission.orgId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "Get an identity by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim().describe(IDENTITIES.GET_BY_ID.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.getIdentityById({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + tags: [ApiDocsTags.Identities], + description: "List identities", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(IDENTITIES.LIST.offset).optional(), + limit: z.coerce.number().min(1).max(1000).default(20).describe(IDENTITIES.LIST.limit).optional(), + search: z.string().trim().describe(IDENTITIES.LIST.search).optional() + }), + response: { + 200: z.object({ + identities: z.array(sanitizedIdentitySchema), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { docs: identities, count: totalCount } = await server.services.identityV2.listIdentities({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + search: req.query.search + } + }); + + return { identities, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v1/project-identity-router.ts b/backend/src/server/routes/v1/project-identity-router.ts new file mode 100644 index 000000000..0eabe0a13 --- /dev/null +++ b/backend/src/server/routes/v1/project-identity-router.ts @@ -0,0 +1,313 @@ +import { z } from "zod"; + +import { AccessScope, IdentitiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags, IDENTITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +const metadataSchema = z.object({ + key: z.string().trim().min(1, "Metadata key cannot be empty"), + value: z.string().trim().min(1, "Metadata value cannot be empty") +}); + +const sanitizedIdentitySchema = IdentitiesSchema.pick({ + id: true, + name: true, + orgId: true, + projectId: true, + createdAt: true, + updatedAt: true, + hasDeleteProtection: true +}).extend({ + activeLockoutAuthMethods: z.string().array().optional(), + authMethods: z.string().array().optional(), + metadata: z + .object({ + key: z.string(), + value: z.string(), + id: z.string() + }) + .array() + .optional() +}); + +export const registerProjectIdentityRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/:projectId/identities", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Create an identity in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project to create the identity in") + }), + body: z.object({ + name: z.string().trim().min(1).describe(IDENTITIES.CREATE.name), + hasDeleteProtection: z.boolean().default(false).describe(IDENTITIES.CREATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.CREATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.createIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + orgId: req.permission.orgId, + projectId: req.params.projectId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.CREATE_IDENTITY, + metadata: { + identityId: identity.id, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "PATCH", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Update an identity in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.UPDATE.identityId) + }), + body: z.object({ + name: z.string().trim().min(1).optional().describe(IDENTITIES.UPDATE.name), + hasDeleteProtection: z.boolean().optional().describe(IDENTITIES.UPDATE.hasDeleteProtection), + metadata: z.array(metadataSchema).optional().describe(IDENTITIES.UPDATE.metadata) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.updateIdentity({ + permission: req.permission, + scopeData: { + scope: AccessScope.Project, + projectId: req.params.projectId, + orgId: req.permission.orgId + }, + selector: { + identityId: req.params.identityId + }, + data: { + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.UPDATE_IDENTITY, + metadata: { + identityId: req.params.identityId, + name: req.body.name, + hasDeleteProtection: req.body.hasDeleteProtection, + metadata: req.body.metadata + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "DELETE", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Delete an identity from a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.DELETE.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.deleteIdentity({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + await server.services.auditLog.createAuditLog({ + projectId: req.params.projectId, + ...req.auditLogInfo, + event: { + type: EventType.DELETE_IDENTITY, + metadata: { + identityId: req.params.identityId + } + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "Get an identity by ID in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project"), + identityId: z.string().trim().describe(IDENTITIES.GET_BY_ID.identityId) + }), + response: { + 200: z.object({ + identity: sanitizedIdentitySchema + }) + } + }, + handler: async (req) => { + const { identity } = await server.services.identityV2.getIdentityById({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + selector: { + identityId: req.params.identityId + } + }); + + return { identity }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/identities", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.Identities], + description: "List identities in a project", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectId: z.string().trim().describe("The ID of the project") + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0).describe(IDENTITIES.LIST.offset).optional(), + limit: z.coerce.number().min(1).max(1000).default(20).describe(IDENTITIES.LIST.limit).optional(), + search: z.string().trim().describe(IDENTITIES.LIST.search).optional() + }), + response: { + 200: z.object({ + identities: z.array(sanitizedIdentitySchema), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { docs: identities, count: totalCount } = await server.services.identityV2.listIdentities({ + permission: req.permission, + scopeData: { + orgId: req.permission.orgId, + scope: AccessScope.Project, + projectId: req.params.projectId + }, + data: { + offset: req.query.offset, + limit: req.query.limit, + search: req.query.search + } + }); + + return { identities, totalCount }; + } + }); +}; diff --git a/backend/src/server/routes/v2/deprecated-project-membership-router.ts b/backend/src/server/routes/v2/deprecated-project-membership-router.ts index 4dff4d5ea..aa693bcb5 100644 --- a/backend/src/server/routes/v2/deprecated-project-membership-router.ts +++ b/backend/src/server/routes/v2/deprecated-project-membership-router.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { AccessScope, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; +import { AccessScope, OrgMembershipRole, ProjectMembershipRole, ProjectMembershipsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ApiDocsTags, PROJECT_USERS } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -51,6 +51,19 @@ export const registerDeprecatedProjectMembershipRouter = async (server: FastifyZ onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; + + await server.services.membershipUser.createMembership({ + permission: req.permission, + scopeData: { + scope: AccessScope.Organization, + orgId: req.permission.orgId + }, + data: { + roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }], + usernames: usernamesAndEmails + } + }); + const { memberships } = await server.services.membershipUser.createMembership({ permission: req.permission, scopeData: { diff --git a/backend/src/server/routes/v2/identity-org-router.ts b/backend/src/server/routes/v2/identity-org-router.ts index 630e09dda..f1295209f 100644 --- a/backend/src/server/routes/v2/identity-org-router.ts +++ b/backend/src/server/routes/v2/identity-org-router.ts @@ -70,7 +70,7 @@ export const registerIdentityOrgRouter = async (server: FastifyZodProvider) => { } }, handler: async (req) => { - const { identityMemberships, totalCount } = await server.services.identity.listOrgIdentities({ + const { identityMemberships, totalCount } = await server.services.identityV1.listOrgIdentities({ actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/services/app-connection/1password/1password-connection-schemas.ts b/backend/src/services/app-connection/1password/1password-connection-schemas.ts index da63dc32a..8be6dff1a 100644 --- a/backend/src/services/app-connection/1password/1password-connection-schemas.ts +++ b/backend/src/services/app-connection/1password/1password-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { OnePassConnectionMethod } from "./1password-connection-enums"; export const OnePassConnectionAccessTokenCredentialsSchema = z.object({ @@ -33,7 +34,7 @@ export const SanitizedOnePassConnectionSchema = z.discriminatedUnion("method", [ credentials: OnePassConnectionAccessTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.OnePass]} (API Token)` })) ]); export const ValidateOnePassConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -57,8 +58,10 @@ export const UpdateOnePassConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.OnePass)); -export const OnePassConnectionListItemSchema = z.object({ - name: z.literal("1Password"), - app: z.literal(AppConnection.OnePass), - methods: z.nativeEnum(OnePassConnectionMethod).array() -}); +export const OnePassConnectionListItemSchema = z + .object({ + name: z.literal("1Password"), + app: z.literal(AppConnection.OnePass), + methods: z.nativeEnum(OnePassConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.OnePass] })); diff --git a/backend/src/services/app-connection/auth0/auth0-connection-schemas.ts b/backend/src/services/app-connection/auth0/auth0-connection-schemas.ts index 67992a503..270e37ccb 100644 --- a/backend/src/services/app-connection/auth0/auth0-connection-schemas.ts +++ b/backend/src/services/app-connection/auth0/auth0-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { Auth0ConnectionMethod } from "./auth0-connection-enums"; export const Auth0ConnectionClientCredentialsInputCredentialsSchema = z.object({ @@ -59,7 +60,7 @@ export const SanitizedAuth0ConnectionSchema = z.discriminatedUnion("method", [ clientId: true, audience: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Auth0]} (Client Credentials)` })) ]); export const ValidateAuth0ConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -85,10 +86,12 @@ export const UpdateAuth0ConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Auth0)); -export const Auth0ConnectionListItemSchema = z.object({ - name: z.literal("Auth0"), - app: z.literal(AppConnection.Auth0), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), - methods: z.nativeEnum(Auth0ConnectionMethod).array() -}); +export const Auth0ConnectionListItemSchema = z + .object({ + name: z.literal("Auth0"), + app: z.literal(AppConnection.Auth0), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(Auth0ConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Auth0] })); diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts index 8cb19ba26..7e8fd36a8 100644 --- a/backend/src/services/app-connection/aws/aws-connection-schemas.ts +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AwsConnectionMethod } from "./aws-connection-enums"; export const AwsConnectionAssumeRoleCredentialsSchema = z.object({ @@ -39,11 +40,11 @@ export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ BaseAwsConnectionSchema.extend({ method: z.literal(AwsConnectionMethod.AssumeRole), credentials: AwsConnectionAssumeRoleCredentialsSchema.pick({}) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AWS]} (Assume Role)` })), BaseAwsConnectionSchema.extend({ method: z.literal(AwsConnectionMethod.AccessKey), credentials: AwsConnectionAccessTokenCredentialsSchema.pick({ accessKeyId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AWS]} (Access Key)` })) ]); export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -72,11 +73,13 @@ export const UpdateAwsConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AWS)); -export const AwsConnectionListItemSchema = z.object({ - name: z.literal("AWS"), - app: z.literal(AppConnection.AWS), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), - methods: z.nativeEnum(AwsConnectionMethod).array(), - accessKeyId: z.string().optional() -}); +export const AwsConnectionListItemSchema = z + .object({ + name: z.literal("AWS"), + app: z.literal(AppConnection.AWS), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(AwsConnectionMethod.AssumeRole), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(AwsConnectionMethod).array(), + accessKeyId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AWS] })); diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts index a43bb5954..1b55ef196 100644 --- a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; export const AzureADCSUsernamePasswordCredentialsSchema = z.object({ @@ -55,7 +56,7 @@ export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureADCS]} (Username and Password)` })) ]); export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -81,8 +82,10 @@ export const UpdateAzureADCSConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureADCS)); -export const AzureADCSConnectionListItemSchema = z.object({ - name: z.literal("Azure ADCS"), - app: z.literal(AppConnection.AzureADCS), - methods: z.nativeEnum(AzureADCSConnectionMethod).array() -}); +export const AzureADCSConnectionListItemSchema = z + .object({ + name: z.literal("Azure ADCS"), + app: z.literal(AppConnection.AzureADCS), + methods: z.nativeEnum(AzureADCSConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AzureADCS] })); diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts index 68579f9ba..49a557582 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AzureAppConfigurationConnectionMethod } from "./azure-app-configuration-connection-enums"; export const AzureAppConfigurationConnectionOAuthInputCredentialsSchema = z.object({ @@ -104,19 +105,23 @@ export const SanitizedAzureAppConfigurationConnectionSchema = z.discriminatedUni credentials: AzureAppConfigurationConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureAppConfiguration]} (OAuth)` })), BaseAzureAppConfigurationConnectionSchema.extend({ method: z.literal(AzureAppConfigurationConnectionMethod.ClientSecret), credentials: AzureAppConfigurationConnectionClientSecretOutputCredentialsSchema.pick({ clientId: true, tenantId: true }) - }) + }).describe( + JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureAppConfiguration]} (Client Secret)` }) + ) ]); -export const AzureAppConfigurationConnectionListItemSchema = z.object({ - name: z.literal("Azure App Configuration"), - app: z.literal(AppConnection.AzureAppConfiguration), - methods: z.nativeEnum(AzureAppConfigurationConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const AzureAppConfigurationConnectionListItemSchema = z + .object({ + name: z.literal("Azure App Configuration"), + app: z.literal(AppConnection.AzureAppConfiguration), + methods: z.nativeEnum(AzureAppConfigurationConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AzureAppConfiguration] })); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts index dd387894e..910244d9e 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; export const AzureClientSecretsConnectionOAuthInputCredentialsSchema = z.object({ @@ -162,26 +163,30 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion( credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureClientSecrets]} (OAuth)` })), BaseAzureClientSecretsConnectionSchema.extend({ method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema.pick({ clientId: true, tenantId: true }) - }), + }).describe( + JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureClientSecrets]} (Client Secret)` }) + ), BaseAzureClientSecretsConnectionSchema.extend({ method: z.literal(AzureClientSecretsConnectionMethod.Certificate), credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({ tenantId: true, clientId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureClientSecrets]} (Certificate)` })) ]); -export const AzureClientSecretsConnectionListItemSchema = z.object({ - name: z.literal("Azure Client Secrets"), - app: z.literal(AppConnection.AzureClientSecrets), - methods: z.nativeEnum(AzureClientSecretsConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const AzureClientSecretsConnectionListItemSchema = z + .object({ + name: z.literal("Azure Client Secrets"), + app: z.literal(AppConnection.AzureClientSecrets), + methods: z.nativeEnum(AzureClientSecretsConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AzureClientSecrets] })); diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts index 2580d0114..c574770bb 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AzureDevOpsConnectionMethod } from "./azure-devops-enums"; export const AzureDevOpsConnectionOAuthInputCredentialsSchema = z.object({ @@ -147,13 +148,13 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method tenantId: true, orgName: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureDevOps]} (OAuth)` })), BaseAzureDevOpsConnectionSchema.extend({ method: z.literal(AzureDevOpsConnectionMethod.AccessToken), credentials: AzureDevOpsConnectionAccessTokenOutputCredentialsSchema.pick({ orgName: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureDevOps]} (Access Token)` })), BaseAzureDevOpsConnectionSchema.extend({ method: z.literal(AzureDevOpsConnectionMethod.ClientSecret), credentials: AzureDevOpsConnectionClientSecretOutputCredentialsSchema.pick({ @@ -161,12 +162,14 @@ export const SanitizedAzureDevOpsConnectionSchema = z.discriminatedUnion("method tenantId: true, orgName: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureDevOps]} (Client Secret)` })) ]); -export const AzureDevOpsConnectionListItemSchema = z.object({ - name: z.literal("Azure DevOps"), - app: z.literal(AppConnection.AzureDevOps), - methods: z.nativeEnum(AzureDevOpsConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const AzureDevOpsConnectionListItemSchema = z + .object({ + name: z.literal("Azure DevOps"), + app: z.literal(AppConnection.AzureDevOps), + methods: z.nativeEnum(AzureDevOpsConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AzureDevOps] })); diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts index d86878bab..3c0323789 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { AzureKeyVaultConnectionMethod } from "./azure-key-vault-connection-enums"; export const AzureKeyVaultConnectionOAuthInputCredentialsSchema = z.object({ @@ -104,19 +105,21 @@ export const SanitizedAzureKeyVaultConnectionSchema = z.discriminatedUnion("meth credentials: AzureKeyVaultConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureKeyVault]} (OAuth)` })), BaseAzureKeyVaultConnectionSchema.extend({ method: z.literal(AzureKeyVaultConnectionMethod.ClientSecret), credentials: AzureKeyVaultConnectionClientSecretOutputCredentialsSchema.pick({ clientId: true, tenantId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.AzureKeyVault]} (Client Secret)` })) ]); -export const AzureKeyVaultConnectionListItemSchema = z.object({ - name: z.literal("Azure Key Vault"), - app: z.literal(AppConnection.AzureKeyVault), - methods: z.nativeEnum(AzureKeyVaultConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const AzureKeyVaultConnectionListItemSchema = z + .object({ + name: z.literal("Azure Key Vault"), + app: z.literal(AppConnection.AzureKeyVault), + methods: z.nativeEnum(AzureKeyVaultConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.AzureKeyVault] })); diff --git a/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts b/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts index fab1bf74c..5235446de 100644 --- a/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts +++ b/backend/src/services/app-connection/bitbucket/bitbucket-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { BitbucketConnectionMethod } from "./bitbucket-connection-enums"; export const BitbucketConnectionAccessTokenCredentialsSchema = z.object({ @@ -39,7 +40,7 @@ export const SanitizedBitbucketConnectionSchema = z.discriminatedUnion("method", credentials: BitbucketConnectionAccessTokenCredentialsSchema.pick({ email: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Bitbucket]} (API Token)` })) ]); export const ValidateBitbucketConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -65,8 +66,10 @@ export const UpdateBitbucketConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Bitbucket)); -export const BitbucketConnectionListItemSchema = z.object({ - name: z.literal("Bitbucket"), - app: z.literal(AppConnection.Bitbucket), - methods: z.nativeEnum(BitbucketConnectionMethod).array() -}); +export const BitbucketConnectionListItemSchema = z + .object({ + name: z.literal("Bitbucket"), + app: z.literal(AppConnection.Bitbucket), + methods: z.nativeEnum(BitbucketConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Bitbucket] })); diff --git a/backend/src/services/app-connection/camunda/camunda-connection-schema.ts b/backend/src/services/app-connection/camunda/camunda-connection-schema.ts index fa769c650..c6c51a9e8 100644 --- a/backend/src/services/app-connection/camunda/camunda-connection-schema.ts +++ b/backend/src/services/app-connection/camunda/camunda-connection-schema.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { CamundaConnectionMethod } from "./camunda-connection-enums"; const BaseCamundaConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Camunda) }); @@ -44,7 +45,7 @@ export const SanitizedCamundaConnectionSchema = z.discriminatedUnion("method", [ credentials: CamundaConnectionClientCredentialsOutputCredentialsSchema.pick({ clientId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Camunda]} (Client Credentials)` })) ]); export const ValidateCamundaConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -70,8 +71,10 @@ export const UpdateCamundaConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Camunda)); -export const CamundaConnectionListItemSchema = z.object({ - name: z.literal("Camunda"), - app: z.literal(AppConnection.Camunda), - methods: z.nativeEnum(CamundaConnectionMethod).array() -}); +export const CamundaConnectionListItemSchema = z + .object({ + name: z.literal("Camunda"), + app: z.literal(AppConnection.Camunda), + methods: z.nativeEnum(CamundaConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Camunda] })); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts b/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts index 174e5bce0..644a41a8b 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { ChecklyConnectionMethod } from "./checkly-connection-constants"; export const ChecklyConnectionMethodSchema = z @@ -31,7 +32,7 @@ export const SanitizedChecklyConnectionSchema = z.discriminatedUnion("method", [ BaseChecklyConnectionSchema.extend({ method: ChecklyConnectionMethodSchema, credentials: ChecklyConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Checkly]} (Access Token)` })) ]); export const ValidateChecklyConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -55,8 +56,10 @@ export const UpdateChecklyConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Checkly)); -export const ChecklyConnectionListItemSchema = z.object({ - name: z.literal("Checkly"), - app: z.literal(AppConnection.Checkly), - methods: z.nativeEnum(ChecklyConnectionMethod).array() -}); +export const ChecklyConnectionListItemSchema = z + .object({ + name: z.literal("Checkly"), + app: z.literal(AppConnection.Checkly), + methods: z.nativeEnum(ChecklyConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Checkly] })); diff --git a/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts b/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts index f3b26a2d6..a6ffc56ee 100644 --- a/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts +++ b/backend/src/services/app-connection/cloudflare/cloudflare-connection-schema.ts @@ -9,6 +9,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { CloudflareConnectionMethod } from "./cloudflare-connection-enum"; const accountIdCharacterValidator = characterValidator([ @@ -41,7 +42,7 @@ export const SanitizedCloudflareConnectionSchema = z.discriminatedUnion("method" BaseCloudflareConnectionSchema.extend({ method: z.literal(CloudflareConnectionMethod.APIToken), credentials: CloudflareConnectionApiTokenCredentialsSchema.pick({ accountId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Cloudflare]} (API Token)` })) ]); export const ValidateCloudflareConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -67,8 +68,10 @@ export const UpdateCloudflareConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Cloudflare)); -export const CloudflareConnectionListItemSchema = z.object({ - name: z.literal("Cloudflare"), - app: z.literal(AppConnection.Cloudflare), - methods: z.nativeEnum(CloudflareConnectionMethod).array() -}); +export const CloudflareConnectionListItemSchema = z + .object({ + name: z.literal("Cloudflare"), + app: z.literal(AppConnection.Cloudflare), + methods: z.nativeEnum(CloudflareConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Cloudflare] })); diff --git a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts index 2ac58b070..ac0f1e830 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { DatabricksConnectionMethod } from "./databricks-connection-enums"; export const DatabricksConnectionServicePrincipalInputCredentialsSchema = z.object({ @@ -42,7 +43,7 @@ export const SanitizedDatabricksConnectionSchema = z.discriminatedUnion("method" clientId: true, workspaceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Databricks]} (Service Principal)` })) ]); export const ValidateDatabricksConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -68,10 +69,12 @@ export const UpdateDatabricksConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Databricks)); -export const DatabricksConnectionListItemSchema = z.object({ - name: z.literal("Databricks"), - app: z.literal(AppConnection.Databricks), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), - methods: z.nativeEnum(DatabricksConnectionMethod).array() -}); +export const DatabricksConnectionListItemSchema = z + .object({ + name: z.literal("Databricks"), + app: z.literal(AppConnection.Databricks), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(DatabricksConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Databricks] })); diff --git a/backend/src/services/app-connection/digital-ocean/digital-ocean-connection-schemas.ts b/backend/src/services/app-connection/digital-ocean/digital-ocean-connection-schemas.ts index 449721a3d..9c1ea1d51 100644 --- a/backend/src/services/app-connection/digital-ocean/digital-ocean-connection-schemas.ts +++ b/backend/src/services/app-connection/digital-ocean/digital-ocean-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { DigitalOceanConnectionMethod } from "./digital-ocean-connection-constants"; export const DigitalOceanConnectionMethodSchema = z @@ -36,7 +37,7 @@ export const SanitizedDigitalOceanConnectionSchema = z.discriminatedUnion("metho BaseDigitalOceanConnectionSchema.extend({ method: DigitalOceanConnectionMethodSchema, credentials: DigitalOceanConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.DigitalOcean]} (Access Token)` })) ]); export const ValidateDigitalOceanConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -60,8 +61,10 @@ export const UpdateDigitalOceanConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.DigitalOcean)); -export const DigitalOceanConnectionListItemSchema = z.object({ - name: z.literal("Digital Ocean"), - app: z.literal(AppConnection.DigitalOcean), - methods: z.nativeEnum(DigitalOceanConnectionMethod).array() -}); +export const DigitalOceanConnectionListItemSchema = z + .object({ + name: z.literal("Digital Ocean"), + app: z.literal(AppConnection.DigitalOcean), + methods: z.nativeEnum(DigitalOceanConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.DigitalOcean] })); diff --git a/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts b/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts index 4466d24df..aa19aef44 100644 --- a/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts +++ b/backend/src/services/app-connection/flyio/flyio-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { FlyioConnectionMethod } from "./flyio-connection-enums"; export const FlyioConnectionAccessTokenCredentialsSchema = z.object({ @@ -31,7 +32,7 @@ export const SanitizedFlyioConnectionSchema = z.discriminatedUnion("method", [ BaseFlyioConnectionSchema.extend({ method: z.literal(FlyioConnectionMethod.AccessToken), credentials: FlyioConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Flyio]} (Access Token)` })) ]); export const ValidateFlyioConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -55,8 +56,10 @@ export const UpdateFlyioConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Flyio)); -export const FlyioConnectionListItemSchema = z.object({ - name: z.literal("Fly.io"), - app: z.literal(AppConnection.Flyio), - methods: z.nativeEnum(FlyioConnectionMethod).array() -}); +export const FlyioConnectionListItemSchema = z + .object({ + name: z.literal("Fly.io"), + app: z.literal(AppConnection.Flyio), + methods: z.nativeEnum(FlyioConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Flyio] })); diff --git a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts index 3637f06dc..374be92e7 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { GcpConnectionMethod } from "./gcp-connection-enums"; export const GcpConnectionServiceAccountImpersonationCredentialsSchema = z.object({ @@ -30,7 +31,9 @@ export const SanitizedGcpConnectionSchema = z.discriminatedUnion("method", [ BaseGcpConnectionSchema.extend({ method: z.literal(GcpConnectionMethod.ServiceAccountImpersonation), credentials: GcpConnectionServiceAccountImpersonationCredentialsSchema.pick({}) - }) + }).describe( + JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GCP]} (Service Account Impersonation)` }) + ) ]); export const ValidateGcpConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -56,10 +59,12 @@ export const UpdateGcpConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GCP)); -export const GcpConnectionListItemSchema = z.object({ - name: z.literal("GCP"), - app: z.literal(AppConnection.GCP), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), - methods: z.nativeEnum(GcpConnectionMethod).array() -}); +export const GcpConnectionListItemSchema = z + .object({ + name: z.literal("GCP"), + app: z.literal(AppConnection.GCP), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GcpConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.GCP] })); diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts index ebdfa45e1..4b5b52c6d 100644 --- a/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { GitHubRadarConnectionMethod } from "./github-radar-connection-enums"; export const GitHubRadarConnectionInputCredentialsSchema = z.object({ @@ -53,14 +54,16 @@ export const SanitizedGitHubRadarConnectionSchema = z.discriminatedUnion("method BaseGitHubRadarConnectionSchema.extend({ method: z.literal(GitHubRadarConnectionMethod.App), credentials: GitHubRadarConnectionOutputCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitHubRadar]} (GitHub App)` })) ]); -export const GitHubRadarConnectionListItemSchema = z.object({ - name: z.literal("GitHub Radar"), - app: z.literal(AppConnection.GitHubRadar), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), - methods: z.nativeEnum(GitHubRadarConnectionMethod).array(), - appClientSlug: z.string().optional() -}); +export const GitHubRadarConnectionListItemSchema = z + .object({ + name: z.literal("GitHub Radar"), + app: z.literal(AppConnection.GitHubRadar), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubRadarConnectionMethod).array(), + appClientSlug: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.GitHubRadar] })); diff --git a/backend/src/services/app-connection/github/github-connection-schemas.ts b/backend/src/services/app-connection/github/github-connection-schemas.ts index 20ef2c0e0..165a1c59f 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { GitHubConnectionMethod } from "./github-connection-enums"; export const GitHubConnectionOAuthInputCredentialsSchema = z.union([ @@ -161,29 +162,31 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitHub]} (GitHub App)` })), BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.OAuth), credentials: z.object({ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitHub]} (OAuth)` })), BaseGitHubConnectionSchema.extend({ method: z.literal(GitHubConnectionMethod.Pat), credentials: z.object({ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitHub]} (Personal Access Token)` })) ]); -export const GitHubConnectionListItemSchema = z.object({ - name: z.literal("GitHub"), - app: z.literal(AppConnection.GitHub), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), - methods: z.nativeEnum(GitHubConnectionMethod).array(), - oauthClientId: z.string().optional(), - appClientSlug: z.string().optional() -}); +export const GitHubConnectionListItemSchema = z + .object({ + name: z.literal("GitHub"), + app: z.literal(AppConnection.GitHub), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(GitHubConnectionMethod.App), z.literal(GitHubConnectionMethod.OAuth)]), + methods: z.nativeEnum(GitHubConnectionMethod).array(), + oauthClientId: z.string().optional(), + appClientSlug: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.GitHub] })); diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts index 936a370bf..63c635472 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { GitLabAccessTokenType, GitLabConnectionMethod } from "./gitlab-connection-enums"; export const GitLabConnectionAccessTokenCredentialsSchema = z.object({ @@ -84,13 +85,13 @@ export const SanitizedGitLabConnectionSchema = z.discriminatedUnion("method", [ instanceUrl: true, accessTokenType: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitLab]} (Access Token)` })), BaseGitLabConnectionSchema.extend({ method: z.literal(GitLabConnectionMethod.OAuth), credentials: GitLabConnectionOAuthOutputCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.GitLab]} (OAuth)` })) ]); export const ValidateGitLabConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -130,9 +131,11 @@ export const UpdateGitLabConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.GitLab)); -export const GitLabConnectionListItemSchema = z.object({ - name: z.literal("GitLab"), - app: z.literal(AppConnection.GitLab), - methods: z.nativeEnum(GitLabConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const GitLabConnectionListItemSchema = z + .object({ + name: z.literal("GitLab"), + app: z.literal(AppConnection.GitLab), + methods: z.nativeEnum(GitLabConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.GitLab] })); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts index 57e8fbeaf..26af6db65 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; const InstanceUrlSchema = z @@ -59,7 +60,7 @@ export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ namespace: true, instanceUrl: true }) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.HCVault]} (Access Token)` })), BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AppRole), credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({ @@ -67,7 +68,7 @@ export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ instanceUrl: true, roleId: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.HCVault]} (App Role)` })) ]); export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -100,8 +101,10 @@ export const UpdateHCVaultConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })); -export const HCVaultConnectionListItemSchema = z.object({ - name: z.literal("HCVault"), - app: z.literal(AppConnection.HCVault), - methods: z.nativeEnum(HCVaultConnectionMethod).array() -}); +export const HCVaultConnectionListItemSchema = z + .object({ + name: z.literal("HCVault"), + app: z.literal(AppConnection.HCVault), + methods: z.nativeEnum(HCVaultConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.HCVault] })); diff --git a/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts b/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts index 99d637dd5..710fc706a 100644 --- a/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts +++ b/backend/src/services/app-connection/heroku/heroku-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { HerokuConnectionMethod } from "./heroku-connection-enums"; export const HerokuConnectionAuthTokenCredentialsSchema = z.object({ @@ -51,11 +52,11 @@ export const SanitizedHerokuConnectionSchema = z.discriminatedUnion("method", [ BaseHerokuConnectionSchema.extend({ method: z.literal(HerokuConnectionMethod.AuthToken), credentials: HerokuConnectionAuthTokenCredentialsSchema.pick({}) - }), + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Heroku]} (Auth Token)` })), BaseHerokuConnectionSchema.extend({ method: z.literal(HerokuConnectionMethod.OAuth), credentials: HerokuConnectionOAuthOutputCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Heroku]} (OAuth)` })) ]); export const ValidateHerokuConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -95,9 +96,11 @@ export const UpdateHerokuConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Heroku)); -export const HerokuConnectionListItemSchema = z.object({ - name: z.literal("Heroku"), - app: z.literal(AppConnection.Heroku), - methods: z.nativeEnum(HerokuConnectionMethod).array(), - oauthClientId: z.string().optional() -}); +export const HerokuConnectionListItemSchema = z + .object({ + name: z.literal("Heroku"), + app: z.literal(AppConnection.Heroku), + methods: z.nativeEnum(HerokuConnectionMethod).array(), + oauthClientId: z.string().optional() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Heroku] })); diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts index 4e6cb0078..7c0a5c1cd 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { HumanitecConnectionMethod } from "./humanitec-connection-enums"; export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({ @@ -25,7 +26,7 @@ export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", BaseHumanitecConnectionSchema.extend({ method: z.literal(HumanitecConnectionMethod.ApiToken), credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Humanitec]} (API Token)` })) ]); export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -51,8 +52,10 @@ export const UpdateHumanitecConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Humanitec)); -export const HumanitecConnectionListItemSchema = z.object({ - name: z.literal("Humanitec"), - app: z.literal(AppConnection.Humanitec), - methods: z.nativeEnum(HumanitecConnectionMethod).array() -}); +export const HumanitecConnectionListItemSchema = z + .object({ + name: z.literal("Humanitec"), + app: z.literal(AppConnection.Humanitec), + methods: z.nativeEnum(HumanitecConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Humanitec] })); diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts index 1647b38a1..e5c526be5 100644 --- a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; export const LaravelForgeConnectionApiTokenCredentialsSchema = z.object({ @@ -25,7 +26,7 @@ export const SanitizedLaravelForgeConnectionSchema = z.discriminatedUnion("metho BaseLaravelForgeConnectionSchema.extend({ method: z.literal(LaravelForgeConnectionMethod.ApiToken), credentials: LaravelForgeConnectionApiTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.LaravelForge]} (API Token)` })) ]); export const ValidateLaravelForgeConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -51,8 +52,10 @@ export const UpdateLaravelForgeConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LaravelForge)); -export const LaravelForgeConnectionListItemSchema = z.object({ - name: z.literal("Laravel Forge"), - app: z.literal(AppConnection.LaravelForge), - methods: z.nativeEnum(LaravelForgeConnectionMethod).array() -}); +export const LaravelForgeConnectionListItemSchema = z + .object({ + name: z.literal("Laravel Forge"), + app: z.literal(AppConnection.LaravelForge), + methods: z.nativeEnum(LaravelForgeConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.LaravelForge] })); diff --git a/backend/src/services/app-connection/ldap/ldap-connection-fns.ts b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts index 03005c7d7..961dcff59 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-fns.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-fns.ts @@ -1,6 +1,11 @@ import ldap from "ldapjs"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; +import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; @@ -8,6 +13,66 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { LdapConnectionMethod } from "./ldap-connection-enums"; import { TLdapConnectionConfig } from "./ldap-connection-types"; +const LDAP_TIMEOUT = 15_000; + +const parseLdapUrl = (url: string): { protocol: string; host: string; port: number } => { + const urlObj = new URL(url); + const isSSL = urlObj.protocol === "ldaps:"; + const defaultPort = isSSL ? 636 : 389; + + return { + protocol: urlObj.protocol.replace(":", ""), + host: urlObj.hostname, + port: urlObj.port ? parseInt(urlObj.port, 10) : defaultPort + }; +}; + +const constructLdapUrl = (protocol: string, host: string, port: number): string => { + return `${protocol}://${host}:${port}`; +}; + +const setupLdapClientHandlers = ( + client: ldap.Client, + dn: string, + password: string, + onSuccess: (client: ldap.Client) => T | Promise +): Promise => { + return new Promise((resolve, reject) => { + const handleError = (errorType: string, err: Error) => { + logger.error(err, errorType); + client.destroy(); + reject(new Error(`${errorType.replace("LDAP ", "")} - ${err.message}`)); + }; + + client.on("error", (err: Error) => handleError("LDAP Error", err)); + client.on("connectError", (err: Error) => handleError("LDAP Connection Error", err)); + client.on("connectRefused", (err: Error) => handleError("LDAP Connection Refused", err)); + client.on("connectTimeout", (err: Error) => handleError("LDAP Connection Timeout", err)); + + client.on("connect", () => { + client.bind(dn, password, (err) => { + if (err) { + logger.error(err, "LDAP Bind Error"); + client.destroy(); + reject(new Error(`Bind Error: ${err.message}`)); + return; + } + + try { + const result = onSuccess(client); + if (result instanceof Promise) { + result.then((value) => resolve(value)).catch(reject); + } else { + resolve(result); + } + } catch (error) { + reject(error); + } + }); + }); + }); +}; + export const getLdapConnectionListItem = () => { return { name: "LDAP" as const, @@ -16,8 +81,6 @@ export const getLdapConnectionListItem = () => { }; }; -const LDAP_TIMEOUT = 15_000; - export const getLdapConnectionClient = async ({ url, dn, @@ -25,78 +88,112 @@ export const getLdapConnectionClient = async ({ sslCertificate, sslRejectUnauthorized = true }: TLdapConnectionConfig["credentials"]) => { - await blockLocalAndPrivateIpAddresses(url); + await blockLocalAndPrivateIpAddresses(url, false); const isSSL = url.startsWith("ldaps"); - return new Promise((resolve, reject) => { - const client = ldap.createClient({ - url, - timeout: LDAP_TIMEOUT, - connectTimeout: LDAP_TIMEOUT, - tlsOptions: isSSL - ? { - rejectUnauthorized: sslRejectUnauthorized, - ca: sslCertificate ? [sslCertificate] : undefined - } - : undefined - }); - - client.on("error", (err: Error) => { - logger.error(err, "LDAP Error"); - client.destroy(); - reject(new Error(`Provider Error - ${err.message}`)); - }); - - client.on("connectError", (err: Error) => { - logger.error(err, "LDAP Connection Error"); - client.destroy(); - reject(new Error(`Provider Connect Error - ${err.message}`)); - }); - - client.on("connectRefused", (err: Error) => { - logger.error(err, "LDAP Connection Refused"); - client.destroy(); - reject(new Error(`Provider Connection Refused - ${err.message}`)); - }); - - client.on("connectTimeout", (err: Error) => { - logger.error(err, "LDAP Connection Timeout"); - client.destroy(); - reject(new Error(`Provider Connection Timeout - ${err.message}`)); - }); - - client.on("connect", () => { - client.bind(dn, password, (err) => { - if (err) { - logger.error(err, "LDAP Bind Error"); - reject(new Error(`Bind Error: ${err.message}`)); - client.destroy(); + const client = ldap.createClient({ + url, + timeout: LDAP_TIMEOUT, + connectTimeout: LDAP_TIMEOUT, + tlsOptions: isSSL + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate ? [sslCertificate] : undefined } - - resolve(client); - }); - }); + : undefined }); + + return setupLdapClientHandlers(client, dn, password, (ldapClient) => ldapClient); }; -export const validateLdapConnectionCredentials = async ({ credentials }: TLdapConnectionConfig) => { - let client: ldap.Client | undefined; +export const executeWithPotentialGateway = async ( + config: TLdapConnectionConfig, + gatewayV2Service: Pick, + operation: (client: ldap.Client) => Promise +): Promise => { + const { gatewayId, credentials } = config; + const { protocol, host, port } = parseLdapUrl(credentials.url); + const appCfg = getConfig(); - try { - client = await getLdapConnectionClient(credentials); + if (gatewayId && gatewayV2Service) { + await blockLocalAndPrivateIpAddresses(credentials.url, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost: host, + targetPort: port + }); - // this shouldn't occur as handle connection error events in client but here as fallback - if (!client.connected) { - throw new BadRequestError({ message: "Unable to connect to LDAP server" }); + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); } - return credentials; - } catch (e: unknown) { - throw new BadRequestError({ - message: `Unable to validate connection: ${(e as Error).message || "verify credentials"}` - }); + return withGatewayV2Proxy( + async (proxyPort) => { + const proxyUrl = constructLdapUrl(protocol, "localhost", proxyPort); + const isSSL = protocol === "ldaps"; + + const client = ldap.createClient({ + url: proxyUrl, + timeout: LDAP_TIMEOUT, + connectTimeout: LDAP_TIMEOUT, + tlsOptions: isSSL + ? { + rejectUnauthorized: config.credentials.sslRejectUnauthorized, + ca: config.credentials.sslCertificate ? [config.credentials.sslCertificate] : undefined, + servername: host, + // bypass hostname verification for development + ...(appCfg.isDevelopmentMode ? { checkServerIdentity: () => undefined } : {}) + } + : undefined + }); + + return setupLdapClientHandlers(client, credentials.dn, credentials.password, async (ldapClient) => { + try { + return await operation(ldapClient); + } finally { + ldapClient.destroy(); + } + }); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); + } + + // Non-gateway path - calls getLdapConnectionClient which has validation + const client = await getLdapConnectionClient(credentials); + try { + return await operation(client); } finally { - client?.destroy(); + client.destroy(); + } +}; + +export const validateLdapConnectionCredentials = async ( + config: TLdapConnectionConfig, + gatewayService: Pick, + gatewayV2Service: Pick +) => { + try { + await executeWithPotentialGateway(config, gatewayV2Service, async (client) => { + // this shouldn't occur as handle connection error events in client but here as fallback + if (!client.connected) { + throw new BadRequestError({ message: "Unable to connect to LDAP server" }); + } + }); + + return config.credentials; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection: ${ + (error as Error)?.message?.replaceAll(config.credentials.password, "********************") ?? + "verify credentials" + }` + }); } }; diff --git a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts index 134b9667b..54a6335f4 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-schemas.ts @@ -9,6 +9,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { LdapConnectionMethod, LdapProvider } from "./ldap-connection-enums"; export const LdapConnectionSimpleBindCredentialsSchema = z.object({ @@ -61,7 +62,7 @@ export const SanitizedLdapConnectionSchema = z.discriminatedUnion("method", [ sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.LDAP]} (Simple Bind)` })) ]); export const ValidateLdapConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -74,7 +75,9 @@ export const ValidateLdapConnectionCredentialsSchema = z.discriminatedUnion("met ]); export const CreateLdapConnectionSchema = ValidateLdapConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP) + GenericCreateAppConnectionFieldsSchema(AppConnection.LDAP, { + supportsGateways: true + }) ); export const UpdateLdapConnectionSchema = z @@ -83,12 +86,18 @@ export const UpdateLdapConnectionSchema = z AppConnections.UPDATE(AppConnection.LDAP).credentials ) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LDAP)); + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.LDAP, { + supportsGateways: true + }) + ); -export const LdapConnectionListItemSchema = z.object({ - name: z.literal("LDAP"), - app: z.literal(AppConnection.LDAP), - // the below is preferable but currently breaks with our zod to json schema parser - // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), - methods: z.nativeEnum(LdapConnectionMethod).array() -}); +export const LdapConnectionListItemSchema = z + .object({ + name: z.literal("LDAP"), + app: z.literal(AppConnection.LDAP), + // the below is preferable but currently breaks with our zod to json schema parser + // methods: z.tuple([z.literal(AwsConnectionMethod.ServicePrincipal), z.literal(AwsConnectionMethod.AccessKey)]), + methods: z.nativeEnum(LdapConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.LDAP] })); diff --git a/backend/src/services/app-connection/ldap/ldap-connection-types.ts b/backend/src/services/app-connection/ldap/ldap-connection-types.ts index ee69b2542..581fca560 100644 --- a/backend/src/services/app-connection/ldap/ldap-connection-types.ts +++ b/backend/src/services/app-connection/ldap/ldap-connection-types.ts @@ -17,6 +17,9 @@ export type TLdapConnectionInput = z.infer & export type TValidateLdapConnectionCredentialsSchema = typeof ValidateLdapConnectionCredentialsSchema; -export type TLdapConnectionConfig = DiscriminativePick & { +export type TLdapConnectionConfig = DiscriminativePick< + TLdapConnectionInput, + "method" | "app" | "credentials" | "gatewayId" +> & { orgId: string; }; diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts index f8d380949..d82b03c44 100644 --- a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -8,6 +8,7 @@ import { } from "@app/services/app-connection/app-connection-schemas"; import { AppConnection } from "../app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; import { MsSqlConnectionMethod } from "./mssql-connection-enums"; @@ -34,7 +35,7 @@ export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [ sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.MsSql]} (Username and Password)` })) ]); export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -68,9 +69,11 @@ export const UpdateMsSqlConnectionSchema = z }) ); -export const MsSqlConnectionListItemSchema = z.object({ - name: z.literal("Microsoft SQL Server"), - app: z.literal(AppConnection.MsSql), - methods: z.nativeEnum(MsSqlConnectionMethod).array(), - supportsPlatformManagement: z.literal(true) -}); +export const MsSqlConnectionListItemSchema = z + .object({ + name: z.literal("Microsoft SQL Server"), + app: z.literal(AppConnection.MsSql), + methods: z.nativeEnum(MsSqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.MsSql] })); diff --git a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts index 51a533395..3ed98f98b 100644 --- a/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts +++ b/backend/src/services/app-connection/mysql/mysql-connection-schemas.ts @@ -8,6 +8,7 @@ import { } from "@app/services/app-connection/app-connection-schemas"; import { AppConnection } from "../app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; import { MySqlConnectionMethod } from "./mysql-connection-enums"; @@ -32,7 +33,7 @@ export const SanitizedMySqlConnectionSchema = z.discriminatedUnion("method", [ sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.MySql]} (Username and Password)` })) ]); export const ValidateMySqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -66,9 +67,11 @@ export const UpdateMySqlConnectionSchema = z }) ); -export const MySqlConnectionListItemSchema = z.object({ - name: z.literal("MySQL"), - app: z.literal(AppConnection.MySql), - methods: z.nativeEnum(MySqlConnectionMethod).array(), - supportsPlatformManagement: z.literal(true) -}); +export const MySqlConnectionListItemSchema = z + .object({ + name: z.literal("MySQL"), + app: z.literal(AppConnection.MySql), + methods: z.nativeEnum(MySqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.MySql] })); diff --git a/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts index 45fb89760..31efafb19 100644 --- a/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts +++ b/backend/src/services/app-connection/netlify/netlify-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { NetlifyConnectionMethod } from "./netlify-connection-constants"; export const NetlifyConnectionMethodSchema = z @@ -36,7 +37,7 @@ export const SanitizedNetlifyConnectionSchema = z.discriminatedUnion("method", [ BaseNetlifyConnectionSchema.extend({ method: NetlifyConnectionMethodSchema, credentials: NetlifyConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Netlify]} (Access Token)` })) ]); export const ValidateNetlifyConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -60,8 +61,10 @@ export const UpdateNetlifyConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Netlify)); -export const NetlifyConnectionListItemSchema = z.object({ - name: z.literal("Netlify"), - app: z.literal(AppConnection.Netlify), - methods: z.nativeEnum(NetlifyConnectionMethod).array() -}); +export const NetlifyConnectionListItemSchema = z + .object({ + name: z.literal("Netlify"), + app: z.literal(AppConnection.Netlify), + methods: z.nativeEnum(NetlifyConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Netlify] })); diff --git a/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts index 95be757f6..cf0b06c00 100644 --- a/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts +++ b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { NorthflankConnectionMethod } from "./northflank-connection-enums"; export const NorthflankConnectionApiTokenCredentialsSchema = z.object({ @@ -27,7 +28,7 @@ export const SanitizedNorthflankConnectionSchema = z.discriminatedUnion("method" BaseNorthflankConnectionSchema.extend({ method: z.literal(NorthflankConnectionMethod.ApiToken), credentials: NorthflankConnectionApiTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Northflank]} (API Token)` })) ]); export const ValidateNorthflankConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -53,8 +54,10 @@ export const UpdateNorthflankConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Northflank)); -export const NorthflankConnectionListItemSchema = z.object({ - name: z.literal("Northflank"), - app: z.literal(AppConnection.Northflank), - methods: z.nativeEnum(NorthflankConnectionMethod).array() -}); +export const NorthflankConnectionListItemSchema = z + .object({ + name: z.literal("Northflank"), + app: z.literal(AppConnection.Northflank), + methods: z.nativeEnum(NorthflankConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Northflank] })); diff --git a/backend/src/services/app-connection/okta/okta-connection-schemas.ts b/backend/src/services/app-connection/okta/okta-connection-schemas.ts index 37ce0ec11..cdde954e2 100644 --- a/backend/src/services/app-connection/okta/okta-connection-schemas.ts +++ b/backend/src/services/app-connection/okta/okta-connection-schemas.ts @@ -9,6 +9,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { OktaConnectionMethod } from "./okta-connection-enums"; export const OktaConnectionApiTokenCredentialsSchema = z.object({ @@ -40,7 +41,7 @@ export const SanitizedOktaConnectionSchema = z.discriminatedUnion("method", [ credentials: OktaConnectionApiTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Okta]} (API Token)` })) ]); export const ValidateOktaConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -62,8 +63,10 @@ export const UpdateOktaConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Okta)); -export const OktaConnectionListItemSchema = z.object({ - name: z.literal("Okta"), - app: z.literal(AppConnection.Okta), - methods: z.nativeEnum(OktaConnectionMethod).array() -}); +export const OktaConnectionListItemSchema = z + .object({ + name: z.literal("Okta"), + app: z.literal(AppConnection.Okta), + methods: z.nativeEnum(OktaConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Okta] })); diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts index da74bd669..a9e6e113b 100644 --- a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -8,6 +8,7 @@ import { } from "@app/services/app-connection/app-connection-schemas"; import { AppConnection } from "../app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; import { PostgresConnectionMethod } from "./postgres-connection-enums"; @@ -32,7 +33,7 @@ export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Postgres]} (Username and Password)` })) ]); export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -66,9 +67,11 @@ export const UpdatePostgresConnectionSchema = z }) ); -export const PostgresConnectionListItemSchema = z.object({ - name: z.literal("PostgreSQL"), - app: z.literal(AppConnection.Postgres), - methods: z.nativeEnum(PostgresConnectionMethod).array(), - supportsPlatformManagement: z.literal(true) -}); +export const PostgresConnectionListItemSchema = z + .object({ + name: z.literal("PostgreSQL"), + app: z.literal(AppConnection.Postgres), + methods: z.nativeEnum(PostgresConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Postgres] })); diff --git a/backend/src/services/app-connection/railway/railway-connection-schemas.ts b/backend/src/services/app-connection/railway/railway-connection-schemas.ts index 066258f1e..8b4f87116 100644 --- a/backend/src/services/app-connection/railway/railway-connection-schemas.ts +++ b/backend/src/services/app-connection/railway/railway-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { RailwayConnectionMethod } from "./railway-connection-constants"; export const RailwayConnectionMethodSchema = z @@ -36,7 +37,7 @@ export const SanitizedRailwayConnectionSchema = z.discriminatedUnion("method", [ BaseRailwayConnectionSchema.extend({ method: RailwayConnectionMethodSchema, credentials: RailwayConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Railway]} (Access Token)` })) ]); export const ValidateRailwayConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -60,11 +61,13 @@ export const UpdateRailwayConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Railway)); -export const RailwayConnectionListItemSchema = z.object({ - name: z.literal("Railway"), - app: z.literal(AppConnection.Railway), - methods: z.nativeEnum(RailwayConnectionMethod).array() -}); +export const RailwayConnectionListItemSchema = z + .object({ + name: z.literal("Railway"), + app: z.literal(AppConnection.Railway), + methods: z.nativeEnum(RailwayConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Railway] })); export const RailwayResourceSchema = z.object({ node: z.object({ diff --git a/backend/src/services/app-connection/redis/redis-connection-schemas.ts b/backend/src/services/app-connection/redis/redis-connection-schemas.ts index f29a2d036..8d5abadcf 100644 --- a/backend/src/services/app-connection/redis/redis-connection-schemas.ts +++ b/backend/src/services/app-connection/redis/redis-connection-schemas.ts @@ -8,6 +8,7 @@ import { } from "@app/services/app-connection/app-connection-schemas"; import { AppConnection } from "../app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { RedisConnectionMethod } from "./redis-connection-enums"; export const BaseRedisUsernameAndPasswordConnectionSchema = z.object({ @@ -45,7 +46,7 @@ export const SanitizedRedisConnectionSchema = z.discriminatedUnion("method", [ sslRejectUnauthorized: true, sslCertificate: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Redis]} (Username and Password)` })) ]); export const ValidateRedisConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -79,9 +80,11 @@ export const UpdateRedisConnectionSchema = z }) ); -export const RedisConnectionListItemSchema = z.object({ - name: z.literal("Redis"), - app: z.literal(AppConnection.Redis), - methods: z.nativeEnum(RedisConnectionMethod).array(), - supportsPlatformManagement: z.literal(false) -}); +export const RedisConnectionListItemSchema = z + .object({ + name: z.literal("Redis"), + app: z.literal(AppConnection.Redis), + methods: z.nativeEnum(RedisConnectionMethod).array(), + supportsPlatformManagement: z.literal(false) + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Redis] })); diff --git a/backend/src/services/app-connection/render/render-connection-schema.ts b/backend/src/services/app-connection/render/render-connection-schema.ts index 77cc46714..cf1d35b02 100644 --- a/backend/src/services/app-connection/render/render-connection-schema.ts +++ b/backend/src/services/app-connection/render/render-connection-schema.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { RenderConnectionMethod } from "./render-connection-enums"; export const RenderConnectionApiKeyCredentialsSchema = z.object({ @@ -25,7 +26,7 @@ export const SanitizedRenderConnectionSchema = z.discriminatedUnion("method", [ BaseRenderConnectionSchema.extend({ method: z.literal(RenderConnectionMethod.ApiKey), credentials: RenderConnectionApiKeyCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Render]} (API Key)` })) ]); export const ValidateRenderConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -49,8 +50,10 @@ export const UpdateRenderConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Render)); -export const RenderConnectionListItemSchema = z.object({ - name: z.literal("Render"), - app: z.literal(AppConnection.Render), - methods: z.nativeEnum(RenderConnectionMethod).array() -}); +export const RenderConnectionListItemSchema = z + .object({ + name: z.literal("Render"), + app: z.literal(AppConnection.Render), + methods: z.nativeEnum(RenderConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Render] })); diff --git a/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts b/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts index 9a06b6554..d66c2a7fc 100644 --- a/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts +++ b/backend/src/services/app-connection/supabase/supabase-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { SupabaseConnectionMethod } from "./supabase-connection-constants"; export const SupabaseConnectionMethodSchema = z @@ -39,7 +40,7 @@ export const SanitizedSupabaseConnectionSchema = z.discriminatedUnion("method", credentials: SupabaseConnectionAccessTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Supabase]} (Access Token)` })) ]); export const ValidateSupabaseConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -63,8 +64,10 @@ export const UpdateSupabaseConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Supabase)); -export const SupabaseConnectionListItemSchema = z.object({ - name: z.literal("Supabase"), - app: z.literal(AppConnection.Supabase), - methods: z.nativeEnum(SupabaseConnectionMethod).array() -}); +export const SupabaseConnectionListItemSchema = z + .object({ + name: z.literal("Supabase"), + app: z.literal(AppConnection.Supabase), + methods: z.nativeEnum(SupabaseConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Supabase] })); diff --git a/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts index 30494e2ba..aafc61daf 100644 --- a/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts +++ b/backend/src/services/app-connection/teamcity/teamcity-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { TeamCityConnectionMethod } from "./teamcity-connection-enums"; export const TeamCityConnectionAccessTokenCredentialsSchema = z.object({ @@ -37,7 +38,7 @@ export const SanitizedTeamCityConnectionSchema = z.discriminatedUnion("method", credentials: TeamCityConnectionAccessTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.TeamCity]} (Access Token)` })) ]); export const ValidateTeamCityConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -63,8 +64,10 @@ export const UpdateTeamCityConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TeamCity)); -export const TeamCityConnectionListItemSchema = z.object({ - name: z.literal("TeamCity"), - app: z.literal(AppConnection.TeamCity), - methods: z.nativeEnum(TeamCityConnectionMethod).array() -}); +export const TeamCityConnectionListItemSchema = z + .object({ + name: z.literal("TeamCity"), + app: z.literal(AppConnection.TeamCity), + methods: z.nativeEnum(TeamCityConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.TeamCity] })); diff --git a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts index 0d408ba4f..006d32217 100644 --- a/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts +++ b/backend/src/services/app-connection/terraform-cloud/terraform-cloud-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { TerraformCloudConnectionMethod } from "./terraform-cloud-connection-enums"; export const TerraformCloudConnectionAccessTokenCredentialsSchema = z.object({ @@ -27,7 +28,7 @@ export const SanitizedTerraformCloudConnectionSchema = z.discriminatedUnion("met BaseTerraformCloudConnectionSchema.extend({ method: z.literal(TerraformCloudConnectionMethod.ApiToken), credentials: TerraformCloudConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.TerraformCloud]} (API Token)` })) ]); export const ValidateTerraformCloudConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -53,8 +54,10 @@ export const UpdateTerraformCloudConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.TerraformCloud)); -export const TerraformCloudConnectionListItemSchema = z.object({ - name: z.literal("Terraform Cloud"), - app: z.literal(AppConnection.TerraformCloud), - methods: z.nativeEnum(TerraformCloudConnectionMethod).array() -}); +export const TerraformCloudConnectionListItemSchema = z + .object({ + name: z.literal("Terraform Cloud"), + app: z.literal(AppConnection.TerraformCloud), + methods: z.nativeEnum(TerraformCloudConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.TerraformCloud] })); diff --git a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts index 60baa4f5c..e93aface0 100644 --- a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { VercelConnectionMethod } from "./vercel-connection-enums"; export const VercelConnectionAccessTokenCredentialsSchema = z.object({ @@ -27,7 +28,7 @@ export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ BaseVercelConnectionSchema.extend({ method: z.literal(VercelConnectionMethod.ApiToken), credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Vercel]} (API Token)` })) ]); export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -51,8 +52,10 @@ export const UpdateVercelConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel)); -export const VercelConnectionListItemSchema = z.object({ - name: z.literal("Vercel"), - app: z.literal(AppConnection.Vercel), - methods: z.nativeEnum(VercelConnectionMethod).array() -}); +export const VercelConnectionListItemSchema = z + .object({ + name: z.literal("Vercel"), + app: z.literal(AppConnection.Vercel), + methods: z.nativeEnum(VercelConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Vercel] })); diff --git a/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts index eb7f74ecd..864a4635b 100644 --- a/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts +++ b/backend/src/services/app-connection/windmill/windmill-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { WindmillConnectionMethod } from "./windmill-connection-enums"; export const WindmillConnectionAccessTokenCredentialsSchema = z.object({ @@ -37,7 +38,7 @@ export const SanitizedWindmillConnectionSchema = z.discriminatedUnion("method", credentials: WindmillConnectionAccessTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Windmill]} (Access Token)` })) ]); export const ValidateWindmillConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -63,8 +64,10 @@ export const UpdateWindmillConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Windmill)); -export const WindmillConnectionListItemSchema = z.object({ - name: z.literal("Windmill"), - app: z.literal(AppConnection.Windmill), - methods: z.nativeEnum(WindmillConnectionMethod).array() -}); +export const WindmillConnectionListItemSchema = z + .object({ + name: z.literal("Windmill"), + app: z.literal(AppConnection.Windmill), + methods: z.nativeEnum(WindmillConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Windmill] })); diff --git a/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts index 23bffd859..ce69de030 100644 --- a/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts +++ b/backend/src/services/app-connection/zabbix/zabbix-connection-schemas.ts @@ -8,6 +8,7 @@ import { GenericUpdateAppConnectionFieldsSchema } from "@app/services/app-connection/app-connection-schemas"; +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; import { ZabbixConnectionMethod } from "./zabbix-connection-enums"; export const ZabbixConnectionApiTokenCredentialsSchema = z.object({ @@ -31,7 +32,7 @@ export const SanitizedZabbixConnectionSchema = z.discriminatedUnion("method", [ BaseZabbixConnectionSchema.extend({ method: z.literal(ZabbixConnectionMethod.ApiToken), credentials: ZabbixConnectionApiTokenCredentialsSchema.pick({ instanceUrl: true }) - }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.Zabbix]} (API Token)` })) ]); export const ValidateZabbixConnectionCredentialsSchema = z.discriminatedUnion("method", [ @@ -55,8 +56,10 @@ export const UpdateZabbixConnectionSchema = z }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Zabbix)); -export const ZabbixConnectionListItemSchema = z.object({ - name: z.literal("Zabbix"), - app: z.literal(AppConnection.Zabbix), - methods: z.nativeEnum(ZabbixConnectionMethod).array() -}); +export const ZabbixConnectionListItemSchema = z + .object({ + name: z.literal("Zabbix"), + app: z.literal(AppConnection.Zabbix), + methods: z.nativeEnum(ZabbixConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.Zabbix] })); diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index b9c759703..e2f0f5f16 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -454,6 +454,30 @@ export const authLoginServiceFactory = ({ }); } + if (organizationId) { + await auditLogService.createAuditLog({ + orgId: organizationId, + ipAddress: ip, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: userEnc.email, + userId: userEnc.userId, + username: userEnc.username, + authMethod + } + }, + event: { + type: EventType.USER_LOGIN, + metadata: { + organizationId + } + } + }); + } + return { tokens: { accessToken: token.access, @@ -646,6 +670,29 @@ 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 + } + }, + event: { + type: EventType.SELECT_ORGANIZATION, + metadata: { + organizationId, + organizationName: selectedOrg.name + } + } + }); + return { ...tokens, user, @@ -1039,6 +1086,33 @@ export const authLoginServiceFactory = ({ organizationId }); + if (organizationId) { + await auditLogService.createAuditLog({ + orgId: organizationId, + ipAddress: ip, + userAgent, + userAgentType: getUserAgentType(userAgent), + actor: { + type: ActorType.USER, + metadata: { + email: userEnc.email, + userId: userEnc.userId, + username: userEnc.username, + authMethod: decodedProviderToken.authMethod + } + }, + event: { + type: EventType.USER_LOGIN, + metadata: { + organizationId, + ...(isAuthMethodSaml(decodedProviderToken.authMethod) && { + authProvider: decodedProviderToken.authMethod + }) + } + } + }); + } + return { token, isMfaEnabled: false, user: userEnc, decodedProviderToken } as const; }; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 0dea986d6..ff95083c6 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,7 +1,9 @@ import * as x509 from "@peculiar/x509"; -import acme from "acme-client"; +import acme, { CsrBuffer } from "acme-client"; +import { Knex } from "knex"; 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 { OrgServiceActor } from "@app/lib/types"; @@ -64,6 +66,20 @@ type TAcmeCertificateAuthorityFnsDeps = { projectDAL: Pick; }; +type TOrderCertificateDeps = { + appConnectionDAL: Pick; + certificateAuthorityDAL: Pick; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + projectDAL: Pick; +}; + type DBConfigurationColumn = { dnsProvider: string; directoryUrl: string; @@ -104,6 +120,245 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; +export const orderCertificate = async ( + { + caId, + subscriberId, + commonName, + altNames, + csr, + csrPrivateKey, + keyUsages, + extendedKeyUsages + }: { + caId: string; + subscriberId?: string; + commonName: string; + altNames?: string[]; + csr: CsrBuffer; + csrPrivateKey?: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + }, + deps: TOrderCertificateDeps, + tx?: Knex +) => { + const { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL + } = deps; + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx); + if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) { + throw new BadRequestError({ message: "CA is not an ACME CA" }); + } + + const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); + if (acmeCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + let accountKey: Buffer | undefined; + if (acmeCa.credentials) { + const decryptedCredentials = await kmsDecryptor({ + cipherTextBlob: acmeCa.credentials as Buffer + }); + + const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( + JSON.parse(decryptedCredentials.toString("utf8")) + ); + + accountKey = Buffer.from(parsedCredentials.accountKey, "base64"); + } + if (!accountKey) { + accountKey = await acme.crypto.createPrivateRsaKey(); + const newCredentials = { + accountKey: accountKey.toString("base64") + }; + const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({ + plainText: Buffer.from(JSON.stringify(newCredentials)) + }); + await externalCertificateAuthorityDAL.update( + { + caId: acmeCa.id + }, + { + credentials: encryptedNewCredentials + } + ); + } + + await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); + + const acmeClientOptions: acme.ClientOptions = { + directoryUrl: acmeCa.configuration.directoryUrl, + accountKey + }; + + if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { + acmeClientOptions.externalAccountBinding = { + kid: acmeCa.configuration.eabKid, + hmacKey: acmeCa.configuration.eabHmacKey + }; + } + + const acmeClient = new acme.Client(acmeClientOptions); + + const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId); + const connection = await decryptAppConnection(appConnection, kmsService); + + const pem = await acmeClient.auto({ + csr, + email: acmeCa.configuration.accountEmail, + challengePriority: ["dns-01"], + // For ACME development mode, we mock the DNS challenge API calls. So, no real DNS records are created. + // We need to disable the challenge verification to avoid errors. + skipChallengeVerification: getConfig().isAcmeDevelopmentMode && getConfig().ACME_SKIP_UPSTREAM_VALIDATION, + termsOfServiceAgreed: true, + + challengeCreateFn: async (authz, challenge, keyAuthorization) => { + if (challenge.type !== "dns-01") { + throw new Error("Unsupported challenge type"); + } + + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53InsertTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareInsertTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } + } + }, + challengeRemoveFn: async (authz, challenge, keyAuthorization) => { + const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" + const recordValue = `"${keyAuthorization}"`; // must be double quoted + + switch (acmeCa.configuration.dnsProviderConfig.provider) { + case AcmeDnsProvider.Route53: { + await route53DeleteTxtRecord( + connection as TAwsConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + case AcmeDnsProvider.Cloudflare: { + await cloudflareDeleteTxtRecord( + connection as TCloudflareConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } + default: { + throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); + } + } + } + }); + + const [leafCert, parentCert] = acme.crypto.splitPemChain(pem); + const certObj = new x509.X509Certificate(leafCert); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = parentCert.trim(); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = csrPrivateKey + ? await kmsEncryptor({ + plainText: Buffer.from(csrPrivateKey) + }) + : { cipherTextBlob: undefined }; + + return (tx || certificateDAL).transaction(async (innerTx: Knex) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + pkiSubscriberId: subscriberId, + status: CertStatus.ACTIVE, + friendlyName: commonName, + commonName, + altNames: altNames?.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages, + extendedKeyUsages, + projectId: ca.projectId + }, + innerTx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + innerTx + ); + + if (encryptedPrivateKey !== undefined) { + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + innerTx + ); + } + + return cert; + }); +}; + export const AcmeCertificateAuthorityFns = ({ appConnectionDAL, appConnectionService, @@ -322,77 +577,6 @@ export const AcmeCertificateAuthorityFns = ({ if (!subscriber.caId) { throw new BadRequestError({ message: "Subscriber does not have a CA" }); } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); - if (!ca.externalCa || ca.externalCa.type !== CaType.ACME) { - throw new BadRequestError({ message: "CA is not an ACME CA" }); - } - - const acmeCa = castDbEntryToAcmeCertificateAuthority(ca); - if (acmeCa.status !== CaStatus.ACTIVE) { - throw new BadRequestError({ message: "CA is disabled" }); - } - - const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ - projectId: ca.projectId, - projectDAL, - kmsService - }); - - const kmsEncryptor = await kmsService.encryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - const kmsDecryptor = await kmsService.decryptWithKmsKey({ - kmsId: certificateManagerKmsId - }); - - let accountKey: Buffer | undefined; - if (acmeCa.credentials) { - const decryptedCredentials = await kmsDecryptor({ - cipherTextBlob: acmeCa.credentials as Buffer - }); - - const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync( - JSON.parse(decryptedCredentials.toString("utf8")) - ); - - accountKey = Buffer.from(parsedCredentials.accountKey, "base64"); - } - if (!accountKey) { - accountKey = await acme.crypto.createPrivateRsaKey(); - const newCredentials = { - accountKey: accountKey.toString("base64") - }; - const { cipherTextBlob: encryptedNewCredentials } = await kmsEncryptor({ - plainText: Buffer.from(JSON.stringify(newCredentials)) - }); - await externalCertificateAuthorityDAL.update( - { - caId: acmeCa.id - }, - { - credentials: encryptedNewCredentials - } - ); - } - - await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); - - const acmeClientOptions: acme.ClientOptions = { - directoryUrl: acmeCa.configuration.directoryUrl, - accountKey - }; - - if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { - acmeClientOptions.externalAccountBinding = { - kid: acmeCa.configuration.eabKid, - hmacKey: acmeCa.configuration.eabHmacKey - }; - } - - const acmeClient = new acme.Client(acmeClientOptions); - const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); @@ -407,131 +591,28 @@ export const AcmeCertificateAuthorityFns = ({ skLeaf ); - const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId); - const connection = await decryptAppConnection(appConnection, kmsService); - - const pem = await acmeClient.auto({ - csr: certificateCsr, - email: acmeCa.configuration.accountEmail, - challengePriority: ["dns-01"], - termsOfServiceAgreed: true, - - challengeCreateFn: async (authz, challenge, keyAuthorization) => { - if (challenge.type !== "dns-01") { - throw new Error("Unsupported challenge type"); - } - - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted - - switch (acmeCa.configuration.dnsProviderConfig.provider) { - case AcmeDnsProvider.Route53: { - await route53InsertTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - case AcmeDnsProvider.Cloudflare: { - await cloudflareInsertTxtRecord( - connection as TCloudflareConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - default: { - throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); - } - } + await orderCertificate( + { + caId: subscriber.caId, + subscriberId: subscriber.id, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames, + csr: certificateCsr, + csrPrivateKey: skLeaf, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] }, - challengeRemoveFn: async (authz, challenge, keyAuthorization) => { - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted - - switch (acmeCa.configuration.dnsProviderConfig.provider) { - case AcmeDnsProvider.Route53: { - await route53DeleteTxtRecord( - connection as TAwsConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - case AcmeDnsProvider.Cloudflare: { - await cloudflareDeleteTxtRecord( - connection as TCloudflareConnection, - acmeCa.configuration.dnsProviderConfig.hostedZoneId, - recordName, - recordValue - ); - break; - } - default: { - throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); - } - } + { + appConnectionDAL, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL } - }); - - const [leafCert, parentCert] = acme.crypto.splitPemChain(pem); - const certObj = new x509.X509Certificate(leafCert); - - const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ - plainText: Buffer.from(new Uint8Array(certObj.rawData)) - }); - - const certificateChainPem = parentCert.trim(); - - const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ - plainText: Buffer.from(certificateChainPem) - }); - - const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ - plainText: Buffer.from(skLeaf) - }); - - await certificateDAL.transaction(async (tx) => { - const cert = await certificateDAL.create( - { - caId: ca.id, - pkiSubscriberId: subscriber.id, - status: CertStatus.ACTIVE, - friendlyName: subscriber.commonName, - commonName: subscriber.commonName, - altNames: subscriber.subjectAlternativeNames.join(","), - serialNumber: certObj.serialNumber, - notBefore: certObj.notBefore, - notAfter: certObj.notAfter, - keyUsages: subscriber.keyUsages as CertKeyUsage[], - extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], - projectId: ca.projectId - }, - tx - ); - - await certificateBodyDAL.create( - { - certId: cert.id, - encryptedCertificate, - encryptedCertificateChain - }, - tx - ); - - await certificateSecretDAL.create( - { - certId: cert.id, - encryptedPrivateKey - }, - tx - ); - }); - + ); await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue }); }; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 6415145ce..d2f468248 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -85,6 +85,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { try { const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) .leftJoin( TableName.CertificateAuthority, `${TableName.PkiCertificateProfile}.caId`, @@ -112,6 +113,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( + db.ref("id").withSchema(TableName.Project).as("projectId"), + db.ref("orgId").withSchema(TableName.Project).as("orgId"), db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), @@ -165,15 +168,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["acmeConfig"]) : undefined; - const certificateAuthority = - result.caId && result.caProjectId && result.caStatus && result.caName - ? ({ - id: result.caId, - projectId: result.caProjectId, - status: result.caStatus, - name: result.caName - } as TCertificateProfileWithConfigs["certificateAuthority"]) - : undefined; + const certificateAuthority = { + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]; const certificateTemplate = result.templateId && result.templateProjectId && result.templateName @@ -185,6 +185,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["certificateTemplate"]) : undefined; + const project = { + id: result.projectId, + orgId: result.orgId + } as TCertificateProfileWithConfigs["project"]; + const transformedResult: TCertificateProfileWithConfigs = { id: result.id, projectId: result.projectId, @@ -201,6 +206,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { estConfig, apiConfig, acmeConfig, + project, certificateAuthority, certificateTemplate }; 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 9d9ab5947..1e31d5788 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -5,8 +5,9 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; @@ -166,6 +167,10 @@ describe("CertificateProfileService", () => { }) } as unknown as Pick; + const mockLicenseService = { + getPlan: vi.fn() + } as unknown as Pick; + const mockKmsService = { encryptWithKmsKey: vi .fn() @@ -252,6 +257,7 @@ describe("CertificateProfileService", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, permissionService: mockPermissionService, + licenseService: mockLicenseService, kmsService: mockKmsService, projectDAL: mockProjectDAL }); @@ -275,6 +281,13 @@ describe("CertificateProfileService", () => { }; beforeEach(() => { + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); @@ -405,6 +418,24 @@ describe("CertificateProfileService", () => { expect(result).toEqual(sampleProfile); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); }); + + it("should throw BadRequestError when plan does not support ACME", async () => { + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: false + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrowError( + new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }) + ); + }); }); describe("updateProfile", () => { @@ -699,6 +730,13 @@ describe("CertificateProfileService", () => { } }; + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 66a23a0e7..87063c6da 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCertificateActions, @@ -152,6 +153,7 @@ type TCertificateProfileServiceFactoryDep = { certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; permissionService: Pick; + licenseService: Pick; kmsService: Pick; projectDAL: Pick; }; @@ -174,6 +176,7 @@ export const certificateProfileServiceFactory = ({ certificateBodyDAL, certificateSecretDAL, permissionService, + licenseService, kmsService, projectDAL }: TCertificateProfileServiceFactoryDep) => { @@ -205,6 +208,17 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiAcme) { + throw new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }); + } + // Validate that certificate template exists and belongs to the same project if (data.certificateTemplateId) { const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 4a3339857..030548e97 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -33,6 +33,10 @@ export type TCertificateProfileUpdate = Omit => { + try { + if (!password || password.trim() === "") { + throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" }); + } + + const cert = forge.pki.certificateFromPem(certificate); + const key = forge.pki.privateKeyFromPem(privateKey); + + const chainCerts = []; + if (certificateChain) { + const chainPems = splitPemChain(certificateChain); + for (const chainPem of chainPems) { + try { + const chainCert = forge.pki.certificateFromPem(chainPem); + chainCerts.push(chainCert); + } catch (error) { + // Skip invalid certificates in chain + } + } + } + + // Generate PKCS12 file + const p12Asn1 = forge.pkcs12.toPkcs12Asn1(key, [cert, ...chainCerts], password, { + algorithm: "aes256", // Modern AES-256 encryption + friendlyName: alias + }); + + const p12Der = forge.asn1.toDer(p12Asn1).getBytes(); + + return Buffer.from(p12Der, "binary"); + } catch (error) { + throw new BadRequestError({ + message: `Failed to generate PKCS12 keystore: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index eb8006f00..b632e76fb 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -29,7 +29,12 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { expandInternalCa, getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; -import { getCertificateCredentials, revocationReasonToCrlCode, splitPemChain } from "./certificate-fns"; +import { + generatePkcs12FromCertificate, + getCertificateCredentials, + revocationReasonToCrlCode, + splitPemChain +} from "./certificate-fns"; import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; import { CertExtendedKeyUsage, @@ -40,6 +45,7 @@ import { TGetCertBodyDTO, TGetCertBundleDTO, TGetCertDTO, + TGetCertPkcs12DTO, TGetCertPrivateKeyDTO, TImportCertDTO, TRevokeCertDTO @@ -656,6 +662,71 @@ export const certificateServiceFactory = ({ }; }; + const getCertPkcs12 = async ({ + serialNumber, + password, + alias, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetCertPkcs12DTO) => { + if (!password || password.trim() === "") { + throw new BadRequestError({ message: "Password is required for PKCS12 keystore generation" }); + } + + if (password.length < 6) { + throw new BadRequestError({ + message: "Password must be at least 6 characters long for PKCS12 keystore security" + }); + } + + if (!alias || alias.trim() === "") { + throw new BadRequestError({ message: "Alias is required for PKCS12 keystore generation" }); + } + const cert = await certificateDAL.findOne({ serialNumber }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: cert.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + // Get certificate bundle (certificate, chain, private key) + const { certificate, certificateChain, privateKey } = await getCertBundle({ + serialNumber, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + if (!privateKey) { + throw new BadRequestError({ message: "Certificate private key is required for PKCS12 export" }); + } + + const pkcs12Data = await generatePkcs12FromCertificate({ + certificate, + certificateChain: certificateChain || "", + privateKey, + password, + alias + }); + + return { + pkcs12Data, + cert + }; + }; + return { getCert, getCertPrivateKey, @@ -663,6 +734,7 @@ export const certificateServiceFactory = ({ revokeCert, getCertBody, importCert, - getCertBundle + getCertBundle, + getCertPkcs12 }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index d654c96ba..085bb9588 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -119,6 +119,12 @@ export type TGetCertBundleDTO = { serialNumber: string; } & Omit; +export type TGetCertPkcs12DTO = { + serialNumber: string; + password: string; + alias: string; +} & Omit; + export type TGetCertificateCredentialsDTO = { certId: string; projectId: string; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 3479d929e..244e98908 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -186,8 +186,8 @@ export const identityAccessTokenServiceFactory = ({ const fnValidateIdentityAccessToken = async ( token: TIdentityAccessTokenJwtPayload, - subOrganizationSelector?: string, - ipAddress?: string + ipAddress?: string, + subOrganizationSelector?: string ) => { const identityAccessToken = await identityAccessTokenDAL.findOne({ [`${TableName.IdentityAccessToken}.id` as "id"]: token.identityAccessTokenId, @@ -227,27 +227,25 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + throw new BadRequestError({ message: "Identity does not belong to this organization" }); } orgId = subOrganization.id; orgName = subOrganization.name; parentOrgId = subOrganization.parentOrgId as string; } else { - const organization = await orgDAL.findOne({ id: rootOrgId }); - const identityOrgMembership = await membershipIdentityDAL.findOne({ scope: AccessScope.Organization, actorIdentityId: identityAccessToken.identityId, - scopeOrgId: rootOrgId + scopeOrgId: identityOrgDetails.id }); if (!identityOrgMembership) { - throw new BadRequestError({ message: "Identity does not belong to any organization" }); + throw new BadRequestError({ message: "Identity does not belong to this organization" }); } - orgId = rootOrgId; - orgName = organization.name; + orgId = identityOrgDetails.id; + orgName = identityOrgDetails.name; parentOrgId = rootOrgId; } diff --git a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts index 525da1e10..fba7fee98 100644 --- a/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts +++ b/backend/src/services/identity-alicloud-auth/identity-alicloud-auth-service.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -11,6 +11,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; @@ -51,7 +52,7 @@ type TIdentityAliCloudAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -105,7 +106,18 @@ export const identityAliCloudAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, lastLoginTime: new Date() @@ -200,7 +212,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -214,16 +226,34 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -280,7 +310,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -300,16 +330,31 @@ export const identityAliCloudAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -350,7 +395,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -362,15 +407,31 @@ export const identityAliCloudAuthServiceFactory = ({ const alicloudIdentityAuth = await identityAliCloudAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...alicloudIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -389,7 +450,7 @@ export const identityAliCloudAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.ALICLOUD_AUTH)) { @@ -397,45 +458,61 @@ export const identityAliCloudAuthServiceFactory = ({ message: "The identity does not have Alibaba Cloud auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke Alibaba Cloud auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke Alibaba Cloud auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityAliCloudAuth = await identityAliCloudAuthDAL.transaction(async (tx) => { const deletedAliCloudAuth = await identityAliCloudAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.ALICLOUD_AUTH }, tx); diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts index 81fab3fde..b3b6bbfce 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-service.ts @@ -1,10 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import axios from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +13,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -50,7 +52,7 @@ type TIdentityAwsAuthServiceFactoryDep = { identityAwsAuthDAL: Pick; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -179,7 +181,18 @@ export const identityAwsAuthServiceFactory = ({ const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, lastLoginTime: new Date() @@ -286,7 +299,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -300,16 +313,34 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -370,7 +401,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -389,16 +420,31 @@ export const identityAwsAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -441,7 +487,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -453,15 +499,31 @@ export const identityAwsAuthServiceFactory = ({ const awsIdentityAuth = await identityAwsAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...awsIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -480,7 +542,7 @@ export const identityAwsAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AWS_AUTH)) { @@ -488,45 +550,60 @@ export const identityAwsAuthServiceFactory = ({ message: "The identity does not have aws auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke aws auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke aws auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityAwsAuth = await identityAwsAuthDAL.transaction(async (tx) => { const deletedAwsAuth = await identityAwsAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AWS_AUTH }, tx); diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts index 17f274e7c..3c05511d1 100644 --- a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -46,7 +47,7 @@ type TIdentityAzureAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -99,7 +100,18 @@ export const identityAzureAuthServiceFactory = ({ const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, lastLoginTime: new Date() @@ -192,7 +204,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -205,16 +217,34 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -275,7 +305,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -293,16 +323,31 @@ export const identityAzureAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -348,7 +393,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -359,16 +404,31 @@ export const identityAzureAuthServiceFactory = ({ const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityAzureAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -387,7 +447,7 @@ export const identityAzureAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.AZURE_AUTH)) { @@ -395,43 +455,59 @@ export const identityAzureAuthServiceFactory = ({ message: "The identity does not have azure auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke azure auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke azure auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { const deletedAzureAuth = await identityAzureAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.AZURE_AUTH }, tx); diff --git a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts index 3e0035e82..847abd81f 100644 --- a/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts +++ b/backend/src/services/identity-gcp-auth/identity-gcp-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -44,7 +45,7 @@ type TIdentityGcpAuthServiceFactoryDep = { identityGcpAuthDAL: Pick; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -139,7 +140,18 @@ export const identityGcpAuthServiceFactory = ({ const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, lastLoginTime: new Date() @@ -232,7 +244,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -246,16 +258,34 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -317,7 +347,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -336,16 +366,31 @@ export const identityGcpAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -392,7 +437,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -404,16 +449,31 @@ export const identityGcpAuthServiceFactory = ({ const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityGcpAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -432,7 +492,7 @@ export const identityGcpAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -441,43 +501,58 @@ export const identityGcpAuthServiceFactory = ({ message: "The identity does not have gcp auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke gcp auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke gcp auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityGcpAuth = await identityGcpAuthDAL.transaction(async (tx) => { const deletedGcpAuth = await identityGcpAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.GCP_AUTH }, tx); diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 3cf93fd16..82935e4a4 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -1,10 +1,16 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityJwtAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityJwtAuthsUpdate +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +18,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -50,7 +57,7 @@ type TIdentityJwtAuthServiceFactoryDep = { identityJwtAuthDAL: TIdentityJwtAuthDALFactory; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; orgDAL: Pick; @@ -213,8 +220,22 @@ export const identityJwtAuthServiceFactory = ({ const identityAccessToken = await identityJwtAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.JWT_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -309,7 +330,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.JWT_AUTH)) { @@ -322,16 +343,35 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -416,7 +456,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -435,17 +475,32 @@ export const identityJwtAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -524,7 +579,7 @@ export const identityJwtAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -534,17 +589,32 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ @@ -576,7 +646,7 @@ export const identityJwtAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -586,45 +656,60 @@ export const identityJwtAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke jwt auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke jwt auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityJwtAuth = await identityJwtAuthDAL.transaction(async (tx) => { const deletedJwtAuth = await identityJwtAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.JWT_AUTH }, tx); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index b633dc433..9322e48cb 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import axios, { AxiosError } from "axios"; import https from "https"; @@ -6,6 +6,7 @@ import RE2 from "re2"; import { AccessScope, + ActionProjectType, IdentityAuthMethod, OrganizationActionScope, TIdentityKubernetesAuthsUpdate @@ -25,6 +26,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -69,7 +71,7 @@ type TIdentityKubernetesAuthServiceFactoryDep = { >; identityAccessTokenDAL: Pick; membershipIdentityDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; gatewayService: TGatewayServiceFactory; @@ -448,8 +450,22 @@ export const identityKubernetesAuthServiceFactory = ({ const identityAccessToken = await identityKubernetesAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.KUBERNETES_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -549,7 +565,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -563,16 +579,34 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { if ( @@ -679,7 +713,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -699,16 +733,31 @@ export const identityKubernetesAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { if ( @@ -831,7 +880,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -846,16 +895,31 @@ export const identityKubernetesAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.scopeOrgId @@ -897,7 +961,7 @@ export const identityKubernetesAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -906,43 +970,58 @@ export const identityKubernetesAuthServiceFactory = ({ message: "The identity does not have kubernetes auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke kubernetes auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke kubernetes auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityKubernetesAuth = await identityKubernetesAuthDAL.transaction(async (tx) => { const deletedKubernetesAuth = await identityKubernetesAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.KUBERNETES_AUTH }, tx); diff --git a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts index d327dabee..455b52412 100644 --- a/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts +++ b/backend/src/services/identity-ldap-auth/identity-ldap-auth-service.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import slugify from "@sindresorhus/slugify"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TIdentityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template"; import { testLDAPConfig } from "@app/ee/services/ldap-config/ldap-fns"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -17,6 +17,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; @@ -61,7 +62,7 @@ type TIdentityLdapAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: TKmsServiceFactory; identityDAL: Pick; identityAuthTemplateDAL: TIdentityAuthTemplateDALFactory; @@ -177,8 +178,22 @@ export const identityLdapAuthServiceFactory = ({ try { const identityAccessToken = await identityLdapAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.LDAP_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -276,7 +291,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -290,7 +305,7 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ + const { permission: orgPermission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, actorId, @@ -298,10 +313,30 @@ export const identityLdapAuthServiceFactory = ({ actorAuthMethod, actorOrgId }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } if (templateId) { - ForbiddenError.from(permission).throwUnlessCan( + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate ); @@ -451,7 +486,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -470,7 +505,7 @@ export const identityLdapAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ + const { permission: orgPermission } = await permissionService.getOrgPermission({ scope: OrganizationActionScope.Any, actor, actorId, @@ -478,10 +513,30 @@ export const identityLdapAuthServiceFactory = ({ actorAuthMethod, actorOrgId }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission: projectPermission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(projectPermission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + ForbiddenError.from(orgPermission).throwUnlessCan( + OrgPermissionIdentityActions.Edit, + OrgPermissionSubjects.Identity + ); + } if (templateId) { - ForbiddenError.from(permission).throwUnlessCan( + ForbiddenError.from(orgPermission).throwUnlessCan( OrgPermissionMachineIdentityAuthTemplateActions.AttachTemplates, OrgPermissionSubjects.MachineIdentityAuthTemplate ); @@ -618,7 +673,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -630,14 +685,31 @@ export const identityLdapAuthServiceFactory = ({ const ldapIdentityAuth = await identityLdapAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, @@ -650,7 +722,6 @@ export const identityLdapAuthServiceFactory = ({ ? decryptor({ cipherTextBlob: ldapIdentityAuth.encryptedLdapCaCertificate }).toString() : undefined; - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); return { ...ldapIdentityAuth, orgId: identityMembershipOrg.scopeOrgId, bindDN, bindPass, ldapCaCertificate }; }; @@ -669,7 +740,7 @@ export const identityLdapAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.LDAP_AUTH)) { @@ -677,45 +748,62 @@ export const identityLdapAuthServiceFactory = ({ message: "The identity does not have LDAP Auth attached" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke LDAP auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke LDAP auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityLdapAuth = await identityLdapAuthDAL.transaction(async (tx) => { const [deletedLdapAuth] = await identityLdapAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.LDAP_AUTH }, tx); @@ -824,15 +912,31 @@ export const identityLdapAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.LDAP_AUTH}:*` diff --git a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts index c75abc76b..05e56c77d 100644 --- a/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts +++ b/backend/src/services/identity-oci-auth/identity-oci-auth-service.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import RE2 from "re2"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -12,6 +12,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; import { crypto } from "@app/lib/crypto"; @@ -49,7 +50,7 @@ type TIdentityOciAuthServiceFactoryDep = { identityOciAuthDAL: Pick; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; orgDAL: Pick; }; @@ -110,8 +111,22 @@ export const identityOciAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityOciAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.OCI_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -203,7 +218,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -217,15 +232,34 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -285,7 +319,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -304,15 +338,31 @@ export const identityOciAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -355,7 +405,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -367,15 +417,31 @@ export const identityOciAuthServiceFactory = ({ const ociIdentityAuth = await identityOciAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...ociIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -394,7 +460,7 @@ export const identityOciAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OCI_AUTH)) { @@ -402,45 +468,62 @@ export const identityOciAuthServiceFactory = ({ message: "The identity does not have OCI auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke OCI auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke OCI auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityOciAuth = await identityOciAuthDAL.transaction(async (tx) => { const deletedOciAuth = await identityOciAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OCI_AUTH }, tx); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 2464a2af6..a253c1e95 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -1,11 +1,17 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; import axios from "axios"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TIdentityOidcAuthsUpdate } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TIdentityOidcAuthsUpdate +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -13,6 +19,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -23,6 +30,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; @@ -50,7 +58,7 @@ type TIdentityOidcAuthServiceFactoryDep = { identityOidcAuthDAL: TIdentityOidcAuthDALFactory; membershipIdentityDAL: Pick; identityAccessTokenDAL: Pick; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; kmsService: Pick; orgDAL: Pick; @@ -111,38 +119,106 @@ export const identityOidcAuthServiceFactory = ({ requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined }); - const { kid } = decodedToken.header as { kid: string }; + const { kid } = decodedToken.header as { kid?: string }; - let oidcSigningKey; - try { - oidcSigningKey = await client.getSigningKey(kid); - } catch (error) { - if (error instanceof Error && error.name === "SigningKeyNotFoundError") { + let tokenData: Record | undefined; + + // If kid is provided, try to get the specific signing key + if (kid) { + let oidcSigningKey; + try { + oidcSigningKey = await client.getSigningKey(kid); + } catch (error) { + if (error instanceof Error && error.name === "SigningKeyNotFoundError") { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.` + }); + } throw new UnauthorizedError({ - message: `Access denied: Unable to verify JWT signature. The signing key '${kid}' was not found in the OIDC provider's JWKS endpoint. This may indicate an invalid token or misconfigured OIDC provider.` + message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}` }); } + + try { + tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + throw error; + } + } else { + // If kid is not provided, try all available signing keys + logger.warn( + `OIDC login without KID header [identityId=${identityOidcAuth.identityId}] [orgId=${org.id}] [ip=${requestContext.get("ip")}]` + ); + + let allSigningKeys; + try { + allSigningKeys = await client.getSigningKeys(); + } catch (error) { + throw new UnauthorizedError({ + message: `Access denied: Failed to retrieve signing keys from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + }); + } + + if (!allSigningKeys || allSigningKeys.length === 0) { + throw new UnauthorizedError({ + message: "Access denied: No signing keys available from OIDC provider's JWKS endpoint." + }); + } + + // Limit the number of keys to try to prevent abuse + const MAX_KEYS_TO_TRY = 10; + if (allSigningKeys.length > MAX_KEYS_TO_TRY) { + throw new UnauthorizedError({ + message: `Access denied: OIDC provider has ${allSigningKeys.length} signing keys. Tokens must include 'kid' header when provider has more than ${MAX_KEYS_TO_TRY} keys.` + }); + } + + let lastError: Error | null = null; + let verified = false; + + // Try each signing key until one works + for (const signingKey of allSigningKeys) { + try { + tokenData = crypto.jwt().verify(oidcJwt, signingKey.getPublicKey(), { + issuer: identityOidcAuth.boundIssuer + }) as Record; + verified = true; + break; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + lastError = error; + // Continue trying other keys + } else { + throw error; + } + } + } + + if (!verified) { + throw new UnauthorizedError({ + message: `Access denied: Unable to verify JWT signature with any available signing key. ${lastError ? lastError.message : "Invalid token"}` + }); + } + } + + // Ensure tokenData was successfully assigned + if (!tokenData) { throw new UnauthorizedError({ - message: `Access denied: Failed to retrieve signing key from OIDC provider: ${error instanceof Error ? error.message : String(error)}` + message: "Access denied: Failed to verify JWT token" }); } - let tokenData: Record; - try { - tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - throw new UnauthorizedError({ - message: `Access denied: ${error.message}` - }); - } - throw error; - } + const verifiedTokenData: Record = tokenData; if (identityOidcAuth.boundSubject) { - if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { + if (!doesFieldValueMatchOidcPolicy(verifiedTokenData.sub, identityOidcAuth.boundSubject)) { throw new ForbiddenRequestError({ message: "Access denied: OIDC subject not allowed." }); @@ -153,7 +229,7 @@ export const identityOidcAuthServiceFactory = ({ if ( !identityOidcAuth.boundAudiences .split(", ") - .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) + .some((policyValue) => doesAudValueMatchOidcPolicy(verifiedTokenData.aud, policyValue)) ) { throw new UnauthorizedError({ message: "Access denied: OIDC audience not allowed." @@ -164,7 +240,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -185,7 +261,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getValueByDot(tokenData, claimKey); + const value = getValueByDot(verifiedTokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` @@ -197,8 +273,22 @@ export const identityOidcAuthServiceFactory = ({ const identityAccessToken = await identityOidcAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.OIDC_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -249,7 +339,7 @@ export const identityOidcAuthServiceFactory = ({ }); } - return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: verifiedTokenData }; } catch (error) { if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { authAttemptCounter.add(1, { @@ -297,7 +387,7 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.OIDC_AUTH)) { @@ -310,16 +400,35 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -393,7 +502,7 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -412,16 +521,32 @@ export const identityOidcAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -486,7 +611,7 @@ export const identityOidcAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -496,15 +621,31 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); @@ -531,7 +672,7 @@ export const identityOidcAuthServiceFactory = ({ if (!identityMembershipOrg) { throw new NotFoundError({ message: "Failed to find identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -541,46 +682,62 @@ export const identityOidcAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke oidc auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke oidc auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityOidcAuth = await identityOidcAuthDAL.transaction(async (tx) => { const deletedOidcAuth = await identityOidcAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.OIDC_AUTH }, tx); diff --git a/backend/src/services/identity-project/identity-project-dal.ts b/backend/src/services/identity-project/identity-project-dal.ts index adcdd8be8..c264717d0 100644 --- a/backend/src/services/identity-project/identity-project-dal.ts +++ b/backend/src/services/identity-project/identity-project-dal.ts @@ -293,7 +293,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { db.ref("authMethod").as("identityAuthMethod").withSchema(TableName.Identity), db.ref("id").as("identityId").withSchema(TableName.Identity), db.ref("name").as("identityName").withSchema(TableName.Identity), + db.ref("orgId").as("identityOrgId").withSchema(TableName.Identity), + db.ref("projectId").as("identityProjectId").withSchema(TableName.Identity), db.ref("id").withSchema(TableName.Membership), + db.ref("lastLoginAuthMethod").withSchema(TableName.Membership), + db.ref("lastLoginTime").withSchema(TableName.Membership), db.ref("role").withSchema(TableName.MembershipRole), db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), db.ref("customRoleId").withSchema(TableName.MembershipRole), @@ -334,6 +338,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { parentMapper: ({ identityId, identityName, + identityOrgId, + identityProjectId, uaId, alicloudId, awsId, @@ -346,7 +352,9 @@ export const identityProjectDALFactory = (db: TDbClient) => { id, createdAt, updatedAt, - projectName + projectName, + lastLoginAuthMethod, + lastLoginTime }) => ({ id, identityId, @@ -355,6 +363,8 @@ export const identityProjectDALFactory = (db: TDbClient) => { identity: { id: identityId, name: identityName, + projectId: identityProjectId, + orgId: identityOrgId, authMethods: buildAuthMethods({ uaId, alicloudId, @@ -367,6 +377,11 @@ export const identityProjectDALFactory = (db: TDbClient) => { tokenId }) }, + // TODO: scott - not sure why these aren't properly typed? + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginAuthMethod, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + lastLoginTime, project: { id: projectId, name: projectName diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index abe0446da..363711e16 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -60,8 +60,14 @@ export const identityProjectServiceFactory = ({ }); const totalCount = await identityProjectDAL.getCountByProjectId(projectId, { search }); + const filteredMemberships = identityMemberships.filter((el) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: el.identity.id }) + ) + ); - return { identityMemberships, totalCount }; + return { identityMemberships: filteredMemberships, totalCount }; }; const getProjectIdentityByIdentityId = async ({ diff --git a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts index 60670d035..630638a06 100644 --- a/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts +++ b/backend/src/services/identity-tls-cert-auth/identity-tls-cert-auth-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -43,7 +44,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { >; membershipIdentityDAL: Pick; licenseService: Pick; - permissionService: Pick; + permissionService: Pick; kmsService: Pick; orgDAL: Pick; }; @@ -130,8 +131,22 @@ export const identityTlsCertAuthServiceFactory = ({ // Generate the token const identityAccessToken = await identityTlsCertAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, lastLoginTime: new Date() }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, + { + lastLoginAuthMethod: IdentityAuthMethod.TLS_CERT_AUTH, + lastLoginTime: new Date() + }, tx ); const newToken = await identityAccessTokenDAL.create( @@ -223,7 +238,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -237,15 +252,34 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -309,7 +343,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -329,15 +363,31 @@ export const identityTlsCertAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -392,7 +442,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -404,15 +454,32 @@ export const identityTlsCertAuthServiceFactory = ({ const identityAuth = await identityTlsCertAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } + const { decryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.Organization, orgId: identityMembershipOrg.scopeOrgId @@ -440,7 +507,7 @@ export const identityTlsCertAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TLS_CERT_AUTH)) { @@ -448,44 +515,61 @@ export const identityTlsCertAuthServiceFactory = ({ message: "The identity does not have TLS Certificate auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke TLS Certificate auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission, memberships } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const shouldUseNewPrivilegeSystem = Boolean(memberships?.[0]?.shouldUseNewPrivilegeSystem); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke TLS Certificate auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityTlsCertAuth = await identityTlsCertAuthDAL.transaction(async (tx) => { const deletedTlsCertAuth = await identityTlsCertAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ identityId, authMethod: IdentityAuthMethod.TLS_CERT_AUTH }, tx); diff --git a/backend/src/services/identity-token-auth/identity-token-auth-service.ts b/backend/src/services/identity-token-auth/identity-token-auth-service.ts index 97717c4f3..bdc8ab1c1 100644 --- a/backend/src/services/identity-token-auth/identity-token-auth-service.ts +++ b/backend/src/services/identity-token-auth/identity-token-auth-service.ts @@ -1,6 +1,12 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope, TableName } from "@app/db/schemas"; +import { + AccessScope, + ActionProjectType, + IdentityAuthMethod, + OrganizationActionScope, + TableName +} from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -8,6 +14,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { @@ -50,7 +57,7 @@ type TIdentityTokenAuthServiceFactoryDep = { TIdentityAccessTokenDALFactory, "create" | "find" | "update" | "findById" | "findOne" | "updateById" | "delete" >; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; }; @@ -88,7 +95,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -102,15 +109,34 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { @@ -168,7 +194,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -188,15 +214,31 @@ export const identityTokenAuthServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { @@ -240,7 +282,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -252,15 +294,31 @@ export const identityTokenAuthServiceFactory = ({ const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...identityTokenAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -283,7 +341,7 @@ export const identityTokenAuthServiceFactory = ({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -292,44 +350,61 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke token auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke token auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const revokedIdentityTokenAuth = await identityTokenAuthDAL.transaction(async (tx) => { const deletedTokenAuth = await identityTokenAuthDAL.delete({ identityId }, tx); await identityAccessTokenDAL.delete({ @@ -368,45 +443,61 @@ export const identityTokenAuthServiceFactory = ({ }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to create token for identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create token for identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const identityTokenAuth = await identityTokenAuthDAL.findOne({ identityId }); const identity = await identityDAL.findById(identityTokenAuth.identityId); @@ -414,7 +505,18 @@ export const identityTokenAuthServiceFactory = ({ const identityAccessToken = await identityTokenAuthDAL.transaction(async (tx) => { await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.TOKEN_AUTH, lastLoginTime: new Date() }, tx ); @@ -479,15 +581,32 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } const tokens = await identityAccessTokenDAL.find( { @@ -578,43 +697,60 @@ export const identityTokenAuthServiceFactory = ({ message: "The identity does not have Token Auth" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to update token for identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId: identityMembershipOrg.identity.id }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to update token for identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const [token] = await identityAccessTokenDAL.update( { authMethod: IdentityAuthMethod.TOKEN_AUTH, @@ -650,24 +786,43 @@ export const identityTokenAuthServiceFactory = ({ await validateIdentityUpdateForSuperAdminPrivileges(identityAccessToken.identityId, isActorSuperAdmin); - const identityOrgMembership = await membershipIdentityDAL.findOne({ - actorIdentityId: identityAccessToken.identityId, - scope: AccessScope.Organization + const identityOrgMembership = await membershipIdentityDAL.getIdentityById({ + scopeData: { + scope: AccessScope.Organization, + orgId: actorOrgId + }, + identityId: identityAccessToken.identityId }); if (!identityOrgMembership) { throw new NotFoundError({ message: `Failed to find identity with ID ${identityAccessToken.identityId}` }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityOrgMembership.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityOrgMembership.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityOrgMembership.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: identityOrgMembership.identity.id }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityOrgMembership.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const [revokedToken] = await identityAccessTokenDAL.update( { diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 26bd01627..5ea5c4a6e 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,7 +1,7 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { requestContext } from "@fastify/request-context"; -import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; +import { AccessScope, ActionProjectType, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { @@ -9,6 +9,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -51,7 +52,7 @@ type TIdentityUaServiceFactoryDep = { identityUaClientSecretDAL: TIdentityUaClientSecretDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; membershipIdentityDAL: TMembershipIdentityDALFactory; - permissionService: Pick; + permissionService: Pick; licenseService: Pick; orgDAL: Pick; keyStore: Pick< @@ -231,7 +232,18 @@ export const identityUaServiceFactory = ({ const identityAccessToken = await identityUaDAL.transaction(async (tx) => { const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + identity.projectId + ? { + scope: AccessScope.Project, + scopeOrgId: identity.orgId, + scopeProjectId: identity.projectId, + actorIdentityId: identity.id + } + : { + scope: AccessScope.Organization, + scopeOrgId: identity.orgId, + actorIdentityId: identity.id + }, { lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, lastLoginTime: new Date() @@ -343,7 +355,7 @@ export const identityUaServiceFactory = ({ message: "Failed to add universal auth to already configured identity" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -351,16 +363,35 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps.map((clientSecretTrustedIp) => { @@ -456,7 +487,7 @@ export const identityUaServiceFactory = ({ }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -467,15 +498,31 @@ export const identityUaServiceFactory = ({ throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const plan = await licenseService.getPlan(identityMembershipOrg.scopeOrgId); const reformattedClientSecretTrustedIps = clientSecretTrustedIps?.map((clientSecretTrustedIp) => { @@ -550,19 +597,35 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + } return { ...uaIdentityAuth, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -587,46 +650,62 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke universal auth of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.RevokeAuth, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.RevokeAuth, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke universal auth of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.RevokeAuth, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const revokedIdentityUniversalAuth = await identityUaDAL.transaction(async (tx) => { const deletedUniversalAuth = await identityUaDAL.delete({ identityId }, tx); return { ...deletedUniversalAuth?.[0], orgId: identityMembershipOrg.scopeOrgId }; @@ -658,47 +737,65 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to create client secret for identity.", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.CreateToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.CreateToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Create, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to create client secret for identity.", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.CreateToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } const appCfg = getConfig(); const clientSecret = crypto.randomBytes(32).toString("hex"); const clientSecretHash = await crypto.hashing().createHash(clientSecret, appCfg.SALT_ROUNDS); @@ -744,47 +841,63 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to get identity client secret with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.GetToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to get identity client secret with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + const identityUniversalAuth = await identityUaDAL.findOne({ identityId }); @@ -818,7 +931,7 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -828,43 +941,57 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to read identity client secret of identity with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.GetToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.GetToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to read identity client secret of identity with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.GetToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } return { ...clientSecret, identityId, orgId: identityMembershipOrg.scopeOrgId }; }; @@ -890,7 +1017,7 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } @@ -900,45 +1027,63 @@ export const identityUaServiceFactory = ({ const clientSecret = await identityUaClientSecretDAL.findOne({ id: clientSecretId, identityUAId: identityUa.id }); if (!clientSecret) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - - const { permission: rolePermission } = await permissionService.getOrgPermission({ - actor: ActorType.IDENTITY, - actorId: identityMembershipOrg.identity.id, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId, - scope: OrganizationActionScope.Any - }); - - const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); - const permissionBoundary = validatePrivilegeChangeOperation( - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.DeleteToken, - OrgPermissionSubjects.Identity, - permission, - rolePermission - ); - if (!permissionBoundary.isValid) { - throw new PermissionBoundaryError({ - message: constructPermissionErrorMessage( - "Failed to revoke identity client secret with more privileged role", - shouldUseNewPrivilegeSystem, - OrgPermissionIdentityActions.DeleteToken, - OrgPermissionSubjects.Identity - ), - details: { missingPermissions: permissionBoundary.missingPermissions } + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId }); - } + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.DeleteToken, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionIdentityActions.Delete, + OrgPermissionSubjects.Identity + ); + + const { permission: rolePermission } = await permissionService.getOrgPermission({ + actor: ActorType.IDENTITY, + actorId: identityMembershipOrg.identity.id, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId, + scope: OrganizationActionScope.Any + }); + + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(identityMembershipOrg.scopeOrgId); + const permissionBoundary = validatePrivilegeChangeOperation( + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity, + permission, + rolePermission + ); + if (!permissionBoundary.isValid) { + throw new PermissionBoundaryError({ + message: constructPermissionErrorMessage( + "Failed to revoke identity client secret with more privileged role", + shouldUseNewPrivilegeSystem, + OrgPermissionIdentityActions.DeleteToken, + OrgPermissionSubjects.Identity + ), + details: { missingPermissions: permissionBoundary.missingPermissions } + }); + } + } const updatedClientSecret = await identityUaClientSecretDAL.updateById(clientSecretId, { isClientSecretRevoked: true }); @@ -967,20 +1112,35 @@ export const identityUaServiceFactory = ({ message: "The identity does not have universal auth" }); } - if (identityMembershipOrg.identity.identityOrgId !== actorOrgId) { + if (identityMembershipOrg.identity.orgId !== actorOrgId) { throw new ForbiddenRequestError({ message: "Sub organization not authorized to access this identity" }); } - const { permission } = await permissionService.getOrgPermission({ - scope: OrganizationActionScope.Any, - actor, - actorId, - orgId: identityMembershipOrg.scopeOrgId, - actorAuthMethod, - actorOrgId - }); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + if (identityMembershipOrg.identity.projectId) { + const { permission } = await permissionService.getProjectPermission({ + actionProjectType: ActionProjectType.Any, + actor, + actorId, + projectId: identityMembershipOrg.identity.projectId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId }) + ); + } else { + const { permission } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor, + actorId, + orgId: identityMembershipOrg.scopeOrgId, + actorAuthMethod, + actorOrgId + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + } const deleted = await keyStore.deleteItems({ pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*` }); diff --git a/backend/src/services/identity-v2/identity-dal.ts b/backend/src/services/identity-v2/identity-dal.ts new file mode 100644 index 000000000..b150cc51e --- /dev/null +++ b/backend/src/services/identity-v2/identity-dal.ts @@ -0,0 +1,181 @@ +import { TDbClient } from "@app/db"; +import { AccessScope, AccessScopeData, IdentitiesSchema, TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +import { buildAuthMethods } from "../identity/identity-fns"; + +export type TIdentityV2DALFactory = ReturnType; + +export const identityV2DALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.Identity); + + const getIdentityById = async (scopeData: AccessScopeData, identityId: string) => { + const doc = await db + .replicaNode()(TableName.Identity) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder.on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`); + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin(TableName.IdentityGcpAuth, `${TableName.Identity}.id`, `${TableName.IdentityGcpAuth}.identityId`) + .leftJoin( + TableName.IdentityAliCloudAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityAliCloudAuth}.identityId` + ) + .leftJoin(TableName.IdentityAwsAuth, `${TableName.Identity}.id`, `${TableName.IdentityAwsAuth}.identityId`) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin(TableName.IdentityOciAuth, `${TableName.Identity}.id`, `${TableName.IdentityOciAuth}.identityId`) + .leftJoin(TableName.IdentityOidcAuth, `${TableName.Identity}.id`, `${TableName.IdentityOidcAuth}.identityId`) + .leftJoin(TableName.IdentityAzureAuth, `${TableName.Identity}.id`, `${TableName.IdentityAzureAuth}.identityId`) + .leftJoin(TableName.IdentityTokenAuth, `${TableName.Identity}.id`, `${TableName.IdentityTokenAuth}.identityId`) + .leftJoin( + TableName.IdentityTlsCertAuth, + `${TableName.Identity}.id`, + `${TableName.IdentityTlsCertAuth}.identityId` + ) + .leftJoin(TableName.IdentityLdapAuth, `${TableName.Identity}.id`, `${TableName.IdentityLdapAuth}.identityId`) + .leftJoin(TableName.IdentityJwtAuth, `${TableName.Identity}.id`, `${TableName.IdentityJwtAuth}.identityId`) + .where(`${TableName.Identity}.id`, identityId) + .where(`${TableName.Identity}.orgId`, scopeData.orgId) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Identity}.projectId`, scopeData.projectId); + } else { + void qb.whereNull(`${TableName.Identity}.projectId`); + } + }) + .select( + selectAllTableCols(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("alicloudId").withSchema(TableName.IdentityAliCloudAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("ociId").withSchema(TableName.IdentityOciAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth), + db.ref("id").as("ldapId").withSchema(TableName.IdentityLdapAuth), + db.ref("id").as("tlsCertId").withSchema(TableName.IdentityTlsCertAuth) + ); + + if (!doc) return doc; + + const formattedDoc = sqlNestRelationships({ + data: doc, + key: "id", + parentMapper: (el) => { + const { + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + alicloudId, + tokenId, + jwtId, + ociId, + ldapId, + tlsCertId + } = el; + return { + ...IdentitiesSchema.parse(el), + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + alicloudId, + jwtId, + ldapId, + ociId, + tlsCertId + }) + }; + }, + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return formattedDoc?.[0]; + }; + + const listIdentities = async ( + scopeData: AccessScopeData, + filter: { limit?: number; offset?: number; search?: string } = {} + ) => { + const query = db + .replicaNode()(TableName.Identity) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder.on(`${TableName.Identity}.id`, `${TableName.IdentityMetadata}.identityId`); + }) + .where(`${TableName.Identity}.orgId`, scopeData.orgId) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Identity}.projectId`, scopeData.projectId); + } else { + void qb.whereNull(`${TableName.Identity}.projectId`); + } + }) + .select( + selectAllTableCols(TableName.Identity), + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ) + .select(db.raw(`count(distinct ??) over () as ??`, [`${TableName.Identity}.id`, "count"])); + + if (filter.limit) void query.limit(filter.limit); + if (filter.offset) void query.offset(filter.offset || 0); + + if (filter.search) void query.whereILike(`${TableName.Identity}.name`, `%${filter.search}%`); + + const docs = await query; + + const formattedDoc = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (el) => IdentitiesSchema.parse(el), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return { docs: formattedDoc, count: Number((docs?.[0] as unknown as { count: number })?.count) }; + }; + + return { ...orm, listIdentities, getIdentityById }; +}; diff --git a/backend/src/services/identity-v2/identity-fns.ts b/backend/src/services/identity-v2/identity-fns.ts new file mode 100644 index 000000000..fde9ef2d3 --- /dev/null +++ b/backend/src/services/identity-v2/identity-fns.ts @@ -0,0 +1,24 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; + +export const getIdentityActiveLockoutAuthMethods = async ( + identityId: string, + keyStore: Pick +) => { + const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${identityId}:*`); + + const activeLockoutAuthMethods = new Set(); + for await (const key of activeLockouts) { + const parts = key.split(":"); + if (parts.length > 3) { + const lockoutRaw = await keyStore.getItem(key); + if (lockoutRaw) { + const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; + if (lockout.lockedOut) { + activeLockoutAuthMethods.add(parts[3]); + } + } + } + } + + return Array.from(activeLockoutAuthMethods); +}; diff --git a/backend/src/services/identity-v2/identity-service.ts b/backend/src/services/identity-v2/identity-service.ts new file mode 100644 index 000000000..8d161794b --- /dev/null +++ b/backend/src/services/identity-v2/identity-service.ts @@ -0,0 +1,251 @@ +import { AccessScope, OrgMembershipRole } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { getIdentityActiveLockoutAuthMethods } from "@app/services/identity-v2/identity-fns"; + +import { TIdentityMetadataDALFactory } from "../identity/identity-metadata-dal"; +import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; +import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TIdentityV2DALFactory } from "./identity-dal"; +import { + TCreateIdentityV2DTO, + TDeleteIdentityV2DTO, + TGetIdentityByIdV2DTO, + TListIdentityV2DTO, + TUpdateIdentityV2DTO +} from "./identity-types"; +import { newOrgIdentityFactory } from "./org/org-identity-factory"; +import { newProjectIdentityFactory } from "./project/project-identity-factory"; + +type TScopedIdentityV2ServiceFactoryDep = { + identityDAL: TIdentityV2DALFactory; + permissionService: TPermissionServiceFactory; + licenseService: Pick; + membershipIdentityDAL: TMembershipIdentityDALFactory; + membershipRoleDAL: TMembershipRoleDALFactory; + identityMetadataDAL: TIdentityMetadataDALFactory; + keyStore: Pick; +}; + +export type TScopedIdentityV2ServiceFactory = ReturnType; + +export const identityV2ServiceFactory = ({ + identityDAL, + permissionService, + licenseService, + membershipIdentityDAL, + membershipRoleDAL, + identityMetadataDAL, + keyStore +}: TScopedIdentityV2ServiceFactoryDep) => { + const orgFactory = newOrgIdentityFactory({ + permissionService + }); + const projectFactory = newProjectIdentityFactory({ + permissionService + }); + + const scopeFactory = { + [AccessScope.Organization]: orgFactory, + [AccessScope.Project]: projectFactory, + // namespace will get stripped off + [AccessScope.Namespace]: orgFactory + }; + + const createIdentity = async (dto: TCreateIdentityV2DTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onCreateIdentityGuard(dto); + + const plan = await licenseService.getPlan(dto.permission.orgId); + + if (plan?.slug !== "enterprise" && plan?.identityLimit && plan.identitiesUsed >= plan.identityLimit) { + // limit imposed on number of identities allowed / number of identities used exceeds the number of identities allowed + throw new BadRequestError({ + message: "Failed to create identity due to identity limit reached. Upgrade plan to create more identities." + }); + } + + const identity = await identityDAL.transaction(async (tx) => { + const newIdentity = await identityDAL.create( + { + name: data.name, + hasDeleteProtection: data.hasDeleteProtection, + orgId: dto.permission.orgId, + projectId: scopeData.scope === AccessScope.Project ? scopeData.projectId : null + }, + tx + ); + const orgMembership = await membershipIdentityDAL.create( + { + scope: AccessScope.Organization, + actorIdentityId: newIdentity.id, + scopeOrgId: dto.permission.orgId + }, + tx + ); + + const newMembershipIds = [orgMembership.id]; + if (scopeData.scope === AccessScope.Project) { + const projectMembership = await membershipIdentityDAL.create( + { + scope: AccessScope.Project, + actorIdentityId: newIdentity.id, + scopeOrgId: dto.permission.orgId, + scopeProjectId: scopeData.projectId + }, + tx + ); + newMembershipIds.push(projectMembership.id); + } + + await membershipRoleDAL.insertMany( + newMembershipIds.map((membershipId) => ({ + membershipId, + role: OrgMembershipRole.NoAccess + })), + tx + ); + + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + + if (data.metadata && data.metadata.length) { + const rowsToInsert = data.metadata.map(({ key, value }) => ({ + identityId: newIdentity.id, + orgId: newIdentity.orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); + } + + return { + ...newIdentity, + authMethods: [], + metadata: insertedMetadata + }; + }); + await licenseService.updateSubscriptionOrgMemberCount(dto.permission.orgId); + + return { identity }; + }; + + const updateIdentity = async (dto: TUpdateIdentityV2DTO) => { + const { scopeData, data } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onUpdateIdentityGuard(dto); + const existingIdentity = await identityDAL.findOne({ + id: dto.selector.identityId, + orgId: dto.permission.orgId, + projectId: dto.scopeData.scope === AccessScope.Project ? dto.scopeData.projectId : null + }); + if (!existingIdentity) + throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const identity = await identityDAL.transaction(async (tx) => { + const updatedIdentity = + data?.name || data?.hasDeleteProtection + ? await identityDAL.updateById( + dto.selector.identityId, + { name: data.name, hasDeleteProtection: data.hasDeleteProtection }, + tx + ) + : existingIdentity; + + let insertedMetadata: Array<{ + id: string; + key: string; + value: string; + }> = []; + + if (data.metadata) { + await identityMetadataDAL.delete({ orgId: dto.permission.orgId, identityId: dto.selector.identityId }, tx); + + if (data.metadata.length) { + const rowsToInsert = data.metadata.map(({ key, value }) => ({ + identityId: updatedIdentity.id, + orgId: updatedIdentity.orgId, + key, + value + })); + + insertedMetadata = await identityMetadataDAL.insertMany(rowsToInsert, tx); + } + } + + return { + ...updatedIdentity, + metadata: insertedMetadata + }; + }); + + return { identity }; + }; + + const deleteIdentity = async (dto: TDeleteIdentityV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onDeleteIdentityGuard(dto); + + const existingIdentity = await identityDAL.findOne({ + id: dto.selector.identityId, + orgId: dto.permission.orgId, + projectId: dto.scopeData.scope === AccessScope.Project ? dto.scopeData.projectId : null + }); + if (!existingIdentity) + throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const deletedIdentity = await identityDAL.deleteById(dto.selector.identityId); + + await licenseService.updateSubscriptionOrgMemberCount(scopeData.orgId); + + return { identity: deletedIdentity }; + }; + + const getIdentityById = async (dto: TGetIdentityByIdV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + await factory.onGetIdentityByIdGuard(dto); + + const identity = await identityDAL.getIdentityById(dto.scopeData, dto.selector.identityId); + if (!identity) throw new NotFoundError({ message: `Identity with id ${dto.selector.identityId} not found` }); + + const activeLockoutAuthMethods = await getIdentityActiveLockoutAuthMethods(identity.id, keyStore); + + return { identity: { ...identity, activeLockoutAuthMethods } }; + }; + + const listIdentities = async (dto: TListIdentityV2DTO) => { + const { scopeData } = dto; + const factory = scopeFactory[scopeData.scope]; + + const isIdentityAccessible = await factory.onListIdentityGuard(dto); + + const identities = await identityDAL.listIdentities(dto.scopeData, { + search: dto.data.search, + offset: dto.data.offset, + limit: dto.data.limit + }); + + return { ...identities, docs: identities.docs.filter((el) => isIdentityAccessible({ identityId: el.id })) }; + }; + + return { + createIdentity, + updateIdentity, + deleteIdentity, + getIdentityById, + listIdentities + }; +}; diff --git a/backend/src/services/identity-v2/identity-types.ts b/backend/src/services/identity-v2/identity-types.ts new file mode 100644 index 000000000..26bff3a97 --- /dev/null +++ b/backend/src/services/identity-v2/identity-types.ts @@ -0,0 +1,67 @@ +import { AccessScopeData } from "@app/db/schemas"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; + +export interface TIdentityV2Factory { + onCreateIdentityGuard: (arg: TCreateIdentityV2DTO) => Promise; + onUpdateIdentityGuard: (arg: TUpdateIdentityV2DTO) => Promise; + onDeleteIdentityGuard: (arg: TDeleteIdentityV2DTO) => Promise; + onListIdentityGuard: (arg: TListIdentityV2DTO) => Promise<(arg: { identityId: string }) => boolean>; + onGetIdentityByIdGuard: (arg: TGetIdentityByIdV2DTO) => Promise; + getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; +} + +export enum IdentityOrderBy { + Name = "name", + Role = "role" +} + +export type TCreateIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: { + name: string; + hasDeleteProtection: boolean; + metadata?: { key: string; value: string }[]; + }; +}; + +export type TUpdateIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; + data: Partial<{ + name: string; + hasDeleteProtection: boolean; + metadata?: { key: string; value: string }[]; + }>; +}; + +export type TDeleteIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TGetIdentityByIdV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + selector: { + identityId: string; + }; +}; + +export type TListIdentityV2DTO = { + permission: OrgServiceActor; + scopeData: AccessScopeData; + data: Partial<{ + limit: number; + offset: number; + orderBy: IdentityOrderBy; + orderDirection: OrderByDirection; + search: string; + }>; +}; diff --git a/backend/src/services/identity-v2/org/org-identity-factory.ts b/backend/src/services/identity-v2/org/org-identity-factory.ts new file mode 100644 index 000000000..264bdca82 --- /dev/null +++ b/backend/src/services/identity-v2/org/org-identity-factory.ts @@ -0,0 +1,92 @@ +import { ForbiddenError } from "@casl/ability"; + +import { AccessScope, OrganizationActionScope } from "@app/db/schemas"; +import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { InternalServerError } from "@app/lib/errors"; + +import { TIdentityV2Factory } from "../identity-types"; + +type TOrgIdentityFactoryDep = { + permissionService: Pick; +}; + +export const newOrgIdentityFactory = ({ permissionService }: TOrgIdentityFactoryDep): TIdentityV2Factory => { + const getScopeField: TIdentityV2Factory["getScopeField"] = (scopeData) => { + if (scopeData.scope === AccessScope.Organization) { + return { key: "orgId" as const, value: scopeData.orgId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the org factory" }); + }; + + const onCreateIdentityGuard: TIdentityV2Factory["onCreateIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Create, OrgPermissionSubjects.Identity); + }; + + const onUpdateIdentityGuard: TIdentityV2Factory["onUpdateIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + }; + + const onDeleteIdentityGuard: TIdentityV2Factory["onDeleteIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); + }; + + const onListIdentityGuard: TIdentityV2Factory["onListIdentityGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + return () => true; + }; + + const onGetIdentityByIdGuard: TIdentityV2Factory["onGetIdentityByIdGuard"] = async (dto) => { + const { permission } = await permissionService.getOrgPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + orgId: dto.permission.orgId, + actorAuthMethod: dto.permission.authMethod, + actorOrgId: dto.permission.orgId, + scope: OrganizationActionScope.Any + }); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + }; + + return { + onCreateIdentityGuard, + onUpdateIdentityGuard, + onDeleteIdentityGuard, + onListIdentityGuard, + onGetIdentityByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/identity-v2/project/project-identity-factory.ts b/backend/src/services/identity-v2/project/project-identity-factory.ts new file mode 100644 index 000000000..4d242fe02 --- /dev/null +++ b/backend/src/services/identity-v2/project/project-identity-factory.ts @@ -0,0 +1,116 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { AccessScope, ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { InternalServerError } from "@app/lib/errors"; + +import { TIdentityV2Factory } from "../identity-types"; + +type TProjectIdentityFactoryDep = { + permissionService: Pick; +}; + +export const newProjectIdentityFactory = ({ permissionService }: TProjectIdentityFactoryDep): TIdentityV2Factory => { + const getScopeField: TIdentityV2Factory["getScopeField"] = (scopeData) => { + if (scopeData.scope === AccessScope.Project) { + return { key: "projectId" as const, value: scopeData.projectId }; + } + throw new InternalServerError({ message: "Invalid scope provided for the project factory" }); + }; + + const onCreateIdentityGuard: TIdentityV2Factory["onCreateIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Create, + ProjectPermissionSub.Identity + ); + }; + + const onUpdateIdentityGuard: TIdentityV2Factory["onUpdateIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Edit, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + const onDeleteIdentityGuard: TIdentityV2Factory["onDeleteIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Delete, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + const onListIdentityGuard: TIdentityV2Factory["onListIdentityGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + ProjectPermissionSub.Identity + ); + + return (arg) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: arg.identityId }) + ); + }; + + const onGetIdentityByIdGuard: TIdentityV2Factory["onGetIdentityByIdGuard"] = async (dto) => { + const scope = getScopeField(dto.scopeData); + const { permission } = await permissionService.getProjectPermission({ + actor: dto.permission.type, + actorId: dto.permission.id, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: dto.permission.authMethod, + projectId: scope.value, + actorOrgId: dto.permission.orgId + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) + ); + }; + + return { + onCreateIdentityGuard, + onUpdateIdentityGuard, + onDeleteIdentityGuard, + onListIdentityGuard, + onGetIdentityByIdGuard, + getScopeField + }; +}; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 66556f5fa..117195ca7 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -159,6 +159,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .join(TableName.Membership, `${TableName.Membership}.actorIdentityId`, `${TableName.Identity}.id`) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) .select( selectAllTableCols(TableName.Membership), @@ -188,9 +189,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`paginatedIdentity.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`paginatedIdentity.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`paginatedIdentity.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .leftJoin( TableName.IdentityUniversalAuth, @@ -404,6 +403,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .whereNotNull(`${TableName.Membership}.actorIdentityId`) .where(`${TableName.Membership}.scopeOrgId`, orgId) .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) + .whereNull(`${TableName.Identity}.projectId`) .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .orderBy( @@ -447,9 +447,7 @@ export const identityOrgDALFactory = (db: TDbClient) => { .join(TableName.MembershipRole, `${TableName.MembershipRole}.membershipId`, `${TableName.Membership}.id`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .leftJoin( TableName.IdentityUniversalAuth, diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f6ec60e9e..cf8d185c0 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -11,6 +11,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; +import { getIdentityActiveLockoutAuthMethods } from "@app/services/identity-v2/identity-fns"; import { TAdditionalPrivilegeDALFactory } from "../additional-privilege/additional-privilege-dal"; import { TMembershipRoleDALFactory } from "../membership/membership-role-dal"; @@ -142,7 +143,7 @@ export const identityServiceFactory = ({ if (metadata && metadata.length) { const rowsToInsert = metadata.map(({ key, value }) => ({ identityId: newIdentity.id, - orgId, + orgId: newIdentity.orgId, key, value })); @@ -220,6 +221,11 @@ export const identityServiceFactory = ({ } const identityDetails = await identityDAL.findById(id); + + if (identityDetails.projectId) { + throw new BadRequestError({ message: `Identity is managed by project` }); + } + const identity = await identityDAL.transaction(async (tx) => { const newIdentity = identityDetails.orgId === actorOrgId && (name || hasDeleteProtection) @@ -243,13 +249,13 @@ export const identityServiceFactory = ({ value: string; }> = []; - if (metadata) { - await identityMetadataDAL.delete({ orgId: identityOrgMembership.scopeOrgId, identityId: id }, tx); + if (metadata && identityDetails.orgId === actorOrgId) { + await identityMetadataDAL.delete({ orgId: newIdentity.orgId, identityId: id }, tx); if (metadata.length) { const rowsToInsert = metadata.map(({ key, value }) => ({ identityId: newIdentity.id, - orgId: identityOrgMembership.scopeOrgId, + orgId: newIdentity.orgId, key, value })); @@ -286,25 +292,11 @@ export const identityServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); - - const activeLockoutAuthMethods = new Set(); - for await (const key of activeLockouts) { - const parts = key.split(":"); - if (parts.length > 3) { - const lockoutRaw = await keyStore.getItem(key); - if (lockoutRaw) { - const lockout = JSON.parse(lockoutRaw) as { lockedOut: boolean }; - if (lockout.lockedOut) { - activeLockoutAuthMethods.add(parts[3]); - } - } - } - } + const activeLockoutAuthMethods = await getIdentityActiveLockoutAuthMethods(id, keyStore); return { ...identity, - identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) } + identity: { ...identity.identity, activeLockoutAuthMethods } }; }; @@ -337,10 +329,14 @@ export const identityServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - if (identityOrgMembership.identity.hasDeleteProtection) - throw new BadRequestError({ message: "Identity has delete protection" }); + if (identityOrgMembership.identity.projectId) { + throw new BadRequestError({ message: `Identity is managed by project` }); + } + + if (identityOrgMembership.identity.orgId === actorOrgId) { + if (identityOrgMembership.identity.hasDeleteProtection) + throw new BadRequestError({ message: "Identity has delete protection" }); - if (identityOrgMembership.identity.identityOrgId === actorOrgId) { const deletedIdentity = await identityDAL.deleteById(id); await licenseService.updateSubscriptionOrgMemberCount(identityOrgMembership.scopeOrgId); return { ...deletedIdentity, orgId: identityOrgMembership.scopeOrgId }; diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 682bfef3e..4a90e1edd 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -40,9 +40,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .join(TableName.MembershipRole, `${TableName.Membership}.id`, `${TableName.MembershipRole}.membershipId`) .leftJoin(TableName.Role, `${TableName.MembershipRole}.customRoleId`, `${TableName.Role}.id`) .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { - void queryBuilder - .on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) - .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); + void queryBuilder.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`); }) .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) .where(`${TableName.Membership}.actorIdentityId`, identityId) @@ -92,6 +90,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -134,6 +133,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { const { identityId: actorIdentityId, identityOrgId, + identityProjectId, identityHasDeleteProtection, identityName, uaId, @@ -155,7 +155,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { name: identityName, id: actorIdentityId, hasDeleteProtection: identityHasDeleteProtection, - identityOrgId, + orgId: identityOrgId, + projectId: identityProjectId, authMethods: buildAuthMethods({ uaId, awsId, @@ -213,7 +214,10 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { ] }); - return data?.[0]; + const el = data?.[0]; + if (!el) return el; + + return { ...el, identity: { ...el.identity, metadata: el.metadata } }; } catch (error) { throw new DatabaseError({ error, name: "MembershipGetByIdentityId" }); } @@ -281,6 +285,8 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .select( db.ref("name").withSchema(TableName.Identity).as("identityName"), db.ref("id").withSchema(TableName.Identity).as("identityId"), + db.ref("orgId").withSchema(TableName.Identity).as("identityOrgId"), + db.ref("projectId").withSchema(TableName.Identity).as("identityProjectId"), db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), @@ -310,13 +316,22 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { data: docs, key: "id", parentMapper: (el) => { - const { identityId: actorIdentityId, identityHasDeleteProtection, identityName } = el; + const { + identityId: actorIdentityId, + identityHasDeleteProtection, + identityName, + identityProjectId, + identityOrgId + } = el; return { ...MembershipsSchema.parse(el), + identityId: actorIdentityId, identity: { name: identityName, id: actorIdentityId, - hasDeleteProtection: identityHasDeleteProtection + hasDeleteProtection: identityHasDeleteProtection, + orgId: identityOrgId, + projectId: identityProjectId } }; }, @@ -356,14 +371,20 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { } }; - // this right now only support sub organization - const listAvailableIdentities = async (orgId: string, rootOrgId: string) => { + const listAvailableIdentities = async (scopeData: AccessScopeData, rootOrgId: string) => { + // TODO (akhil/scott): need to implement filters + try { - const usersConnectedToOrg = db + const identitiesConnectedToOrg = db .replicaNode()(TableName.Membership) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scope`, AccessScope.Organization) - .where(`${TableName.Membership}.scopeOrgId`, orgId) + .where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId) + .where(`${TableName.Membership}.scope`, scopeData.scope) + .where((qb) => { + if (scopeData.scope === AccessScope.Project) { + void qb.where(`${TableName.Membership}.scopeProjectId`, scopeData.projectId); + } + }) .select("actorIdentityId"); const docs = await db @@ -371,8 +392,16 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.Membership}.actorIdentityId`) .where(`${TableName.Membership}.scope`, AccessScope.Organization) .whereNotNull(`${TableName.Membership}.actorIdentityId`) - .where(`${TableName.Membership}.scopeOrgId`, rootOrgId) - .whereNotIn(`${TableName.Membership}.actorIdentityId`, usersConnectedToOrg) + .whereNull(`${TableName.Identity}.projectId`) + .where((qb) => { + // if sub org pick from root and if project pick from org of project + if (scopeData.scope === AccessScope.Organization) { + void qb.where(`${TableName.Membership}.scopeOrgId`, rootOrgId); + } else { + void qb.where(`${TableName.Membership}.scopeOrgId`, scopeData.orgId); + } + }) + .whereNotIn(`${TableName.Membership}.actorIdentityId`, identitiesConnectedToOrg) .select( db.ref("id").withSchema(TableName.Identity), db.ref("name").withSchema(TableName.Identity), diff --git a/backend/src/services/membership-identity/membership-identity-service.ts b/backend/src/services/membership-identity/membership-identity-service.ts index 16292ea82..ab63c4508 100644 --- a/backend/src/services/membership-identity/membership-identity-service.ts +++ b/backend/src/services/membership-identity/membership-identity-service.ts @@ -55,7 +55,8 @@ export const membershipIdentityServiceFactory = ({ [AccessScope.Project]: newProjectMembershipIdentityFactory({ membershipIdentityDAL, orgDAL, - permissionService + permissionService, + identityDAL }), [AccessScope.Namespace]: newNamespaceMembershipIdentityFactory({}) }; @@ -298,7 +299,7 @@ export const membershipIdentityServiceFactory = ({ const { scopeData } = dto; const factory = scopeFactory[scopeData.scope]; - await factory.onListMembershipIdentityGuard(dto); + const listFilter = await factory.onListMembershipIdentityGuard(dto); const memberships = await membershipIdentityDAL.findIdentities({ scopeData, filter: { @@ -316,7 +317,7 @@ export const membershipIdentityServiceFactory = ({ : undefined } }); - return memberships; + return { ...memberships, data: memberships.data.filter((el) => listFilter({ identityId: el.identity.id })) }; }; const getMembershipByIdentityId = async (dto: TGetMembershipIdentityByIdentityIdDTO) => { @@ -339,13 +340,10 @@ export const membershipIdentityServiceFactory = ({ await factory.onListMembershipIdentityGuard(dto); - const organizationDetails = await orgDAL.findById(dto.scopeData.orgId); - if (!organizationDetails.rootOrgId) return { identities: [] }; + if (scopeData.scope !== AccessScope.Project && dto.permission.rootOrgId === dto.permission.orgId) + return { identities: [] }; - const identities = await membershipIdentityDAL.listAvailableIdentities( - organizationDetails.id, - organizationDetails.rootOrgId - ); + const identities = await membershipIdentityDAL.listAvailableIdentities(dto.scopeData, dto.permission.rootOrgId); return { identities }; }; diff --git a/backend/src/services/membership-identity/membership-identity-types.ts b/backend/src/services/membership-identity/membership-identity-types.ts index 78923cb14..365a83db4 100644 --- a/backend/src/services/membership-identity/membership-identity-types.ts +++ b/backend/src/services/membership-identity/membership-identity-types.ts @@ -6,7 +6,7 @@ export interface TMembershipIdentityScopeFactory { onUpdateMembershipIdentityGuard: (arg: TUpdateMembershipIdentityDTO) => Promise; onDeleteMembershipIdentityGuard: (arg: TDeleteMembershipIdentityDTO) => Promise; - onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise; + onListMembershipIdentityGuard: (arg: TListMembershipIdentityDTO) => Promise<(arg: { identityId: string }) => boolean>; onGetMembershipIdentityByIdentityIdGuard: (arg: TGetMembershipIdentityByIdentityIdDTO) => Promise; getScopeField: (scope: AccessScopeData) => { key: "orgId" | "namespaceId" | "projectId"; value: string }; getScopeDatabaseFields: (scope: AccessScopeData) => { diff --git a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts index 8b3bdf6d5..8a95a55f4 100644 --- a/backend/src/services/membership-identity/org/org-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/org/org-membership-identity-factory.ts @@ -60,6 +60,10 @@ export const newOrgMembershipIdentityFactory = ({ throw new BadRequestError({ message: "Only identities from parent organization can be invited" }); } + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } + const permissionRoles = await permissionService.getOrgPermissionByRoles( dto.data.roles.map((el) => el.role), dto.permission.orgId @@ -129,6 +133,11 @@ export const newOrgMembershipIdentityFactory = ({ }); } } + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } }; const onDeleteMembershipIdentityGuard: TMembershipIdentityScopeFactory["onDeleteMembershipIdentityGuard"] = async ( @@ -153,6 +162,10 @@ export const newOrgMembershipIdentityFactory = ({ if (identityDetails.orgId === dto.permission.orgId) { throw new BadRequestError({ message: "Identity cannot exist as orphan" }); } + + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create organization membership for a project scoped identity" }); + } }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( @@ -167,6 +180,8 @@ export const newOrgMembershipIdentityFactory = ({ scope: OrganizationActionScope.Any }); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + return () => true; }; const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = diff --git a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts index f2896047f..82cfd0777 100644 --- a/backend/src/services/membership-identity/project/project-membership-identity-factory.ts +++ b/backend/src/services/membership-identity/project/project-membership-identity-factory.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { AccessScope, ActionProjectType, ProjectMembershipRole } from "@app/db/schemas"; import { @@ -12,6 +12,7 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, InternalServerError, PermissionBoundaryError } from "@app/lib/errors"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TMembershipIdentityDALFactory } from "../membership-identity-dal"; @@ -19,6 +20,8 @@ import { TMembershipIdentityScopeFactory } from "../membership-identity-types"; type TProjectMembershipIdentityScopeFactoryDep = { permissionService: Pick; + + identityDAL: Pick; orgDAL: Pick; membershipIdentityDAL: Pick; }; @@ -26,7 +29,8 @@ type TProjectMembershipIdentityScopeFactoryDep = { export const newProjectMembershipIdentityFactory = ({ permissionService, orgDAL, - membershipIdentityDAL + membershipIdentityDAL, + identityDAL }: TProjectMembershipIdentityScopeFactoryDep): TMembershipIdentityScopeFactory => { const getScopeField: TMembershipIdentityScopeFactory["getScopeField"] = (dto) => { if (dto.scope === AccessScope.Project) { @@ -68,6 +72,11 @@ export const newProjectMembershipIdentityFactory = ({ if (!orgMembership) throw new BadRequestError({ message: `Identity ${dto.data.identityId} is missing organization membership` }); + const identityDetails = await identityDAL.findById(dto.data.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to create project membership for a project scoped identity" }); + } + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); const permissionRoles = await permissionService.getProjectPermissionByRoles( dto.data.roles.map((el) => el.role), @@ -110,9 +119,14 @@ export const newProjectMembershipIdentityFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Edit, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId && identityDetails.projectId !== scope.value) { + throw new BadRequestError({ message: "Failed to update project membership for a project scoped identity" }); + } + const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(dto.permission.orgId); const permissionRoles = await permissionService.getProjectPermissionByRoles( dto.data.roles.filter((el) => el.role !== ProjectMembershipRole.NoAccess).map((el) => el.role), @@ -154,8 +168,13 @@ export const newProjectMembershipIdentityFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Delete, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); + + const identityDetails = await identityDAL.findById(dto.selector.identityId); + if (identityDetails.projectId) { + throw new BadRequestError({ message: "Failed to delete project membership for a project scoped identity" }); + } }; const onListMembershipIdentityGuard: TMembershipIdentityScopeFactory["onListMembershipIdentityGuard"] = async ( @@ -175,6 +194,12 @@ export const newProjectMembershipIdentityFactory = ({ ProjectPermissionIdentityActions.Read, ProjectPermissionSub.Identity ); + + return (arg) => + permission.can( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: arg.identityId }) + ); }; const onGetMembershipIdentityByIdentityIdGuard: TMembershipIdentityScopeFactory["onGetMembershipIdentityByIdentityIdGuard"] = @@ -191,7 +216,7 @@ export const newProjectMembershipIdentityFactory = ({ ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionIdentityActions.Read, - ProjectPermissionSub.Identity + subject(ProjectPermissionSub.Identity, { identityId: dto.selector.identityId }) ); }; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts index 3e07420b5..af210c0c5 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -1,5 +1,5 @@ /* eslint-disable no-await-in-loop */ -import * as AWS from "aws-sdk"; +import AWS from "aws-sdk"; import RE2 from "re2"; import { z } from "zod"; diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts index 8b2b8b87e..e86ecaab9 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -1,4 +1,4 @@ -import * as AWS from "aws-sdk"; +import AWS from "aws-sdk"; import { z } from "zod"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; diff --git a/backend/src/services/role/role-service.ts b/backend/src/services/role/role-service.ts index 3387dc96b..653a00b5c 100644 --- a/backend/src/services/role/role-service.ts +++ b/backend/src/services/role/role-service.ts @@ -6,6 +6,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { UnpackedPermissionSchema, unpackPermissions } from "@app/server/routes/sanitizedSchema/permission"; +import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { ActorType } from "../auth/auth-type"; import { TExternalGroupOrgRoleMappingDALFactory } from "../external-group-org-role-mapping/external-group-org-role-mapping-dal"; @@ -33,6 +34,7 @@ type TRoleServiceFactoryDep = { permissionService: Pick; projectDAL: Pick; externalGroupOrgRoleMappingDAL: Pick; + membershipRoleDAL: Pick; }; export type TRoleServiceFactory = ReturnType; @@ -43,7 +45,8 @@ export const roleServiceFactory = ({ projectDAL, identityDAL, userDAL, - externalGroupOrgRoleMappingDAL + externalGroupOrgRoleMappingDAL, + membershipRoleDAL }: TRoleServiceFactoryDep) => { const orgRoleFactory = newOrgRoleFactory({ permissionService, @@ -137,6 +140,23 @@ export const roleServiceFactory = ({ }); if (!existingRole) throw new NotFoundError({ message: `Role with ${dto.selector.id} not found` }); + const [roleUsageData] = await membershipRoleDAL.find( + { + customRoleId: dto.selector.id + }, + { count: true } + ); + + if (roleUsageData) { + const count = Number.parseInt(roleUsageData.count, 10); + if (count > 0) { + const plural = count > 1 ? "s" : ""; + throw new BadRequestError({ + message: `Role is assigned to ${count} identity membership${plural}. Re-assign membership role${plural} to delete this role.` + }); + } + } + const [role] = await roleDAL.delete({ id: existingRole.id, [scope.key]: scope.value diff --git a/backend/src/services/secret-sync/1password/1password-sync-schemas.ts b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts index 9ee4ca496..70aff62f4 100644 --- a/backend/src/services/secret-sync/1password/1password-sync-schemas.ts +++ b/backend/src/services/secret-sync/1password/1password-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const OnePassSyncDestinationConfigSchema = z.object({ vaultId: z.string().trim().min(1, "Vault required").describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.vaultId), valueLabel: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.ONEPASS.valueLabel) @@ -17,10 +19,12 @@ const OnePassSyncDestinationConfigSchema = z.object({ const OnePassSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const OnePassSyncSchema = BaseSecretSyncSchema(SecretSync.OnePass, OnePassSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.OnePass), - destinationConfig: OnePassSyncDestinationConfigSchema -}); +export const OnePassSyncSchema = BaseSecretSyncSchema(SecretSync.OnePass, OnePassSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.OnePass), + destinationConfig: OnePassSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OnePass] })); export const CreateOnePassSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.OnePass, @@ -36,9 +40,11 @@ export const UpdateOnePassSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: OnePassSyncDestinationConfigSchema.optional() }); -export const OnePassSyncListItemSchema = z.object({ - name: z.literal("1Password"), - connection: z.literal(AppConnection.OnePass), - destination: z.literal(SecretSync.OnePass), - canImportSecrets: z.literal(true) -}); +export const OnePassSyncListItemSchema = z + .object({ + name: z.literal("1Password"), + connection: z.literal(AppConnection.OnePass), + destination: z.literal(SecretSync.OnePass), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.OnePass] })); diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts index 324b78130..587ac13e8 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const tagFieldCharacterValidator = characterValidator([ CharacterType.AlphaNumeric, CharacterType.Spaces, @@ -105,10 +107,12 @@ export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema( SecretSync.AWSParameterStore, AwsParameterStoreSyncOptionsConfig, AwsParameterStoreSyncOptionsSchema -).extend({ - destination: z.literal(SecretSync.AWSParameterStore), - destinationConfig: AwsParameterStoreSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.AWSParameterStore), + destinationConfig: AwsParameterStoreSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AWSParameterStore] })); export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.AWSParameterStore, @@ -126,9 +130,11 @@ export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSc destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional() }); -export const AwsParameterStoreSyncListItemSchema = z.object({ - name: z.literal("AWS Parameter Store"), - connection: z.literal(AppConnection.AWS), - destination: z.literal(SecretSync.AWSParameterStore), - canImportSecrets: z.literal(true) -}); +export const AwsParameterStoreSyncListItemSchema = z + .object({ + name: z.literal("AWS Parameter Store"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(SecretSync.AWSParameterStore), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AWSParameterStore] })); diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts index e80964721..44984e1b3 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts @@ -12,6 +12,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const AwsSecretsManagerSyncDestinationConfigSchema = z .discriminatedUnion("mappingBehavior", [ z.object({ @@ -119,10 +121,12 @@ export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema( SecretSync.AWSSecretsManager, AwsSecretsManagerSyncOptionsConfig, AwsSecretsManagerSyncOptionsSchema -).extend({ - destination: z.literal(SecretSync.AWSSecretsManager), - destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.AWSSecretsManager), + destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AWSSecretsManager] })); export const CreateAwsSecretsManagerSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.AWSSecretsManager, @@ -164,9 +168,11 @@ export const UpdateAwsSecretsManagerSyncSchema = GenericUpdateSecretSyncFieldsSc } }); -export const AwsSecretsManagerSyncListItemSchema = z.object({ - name: z.literal("AWS Secrets Manager"), - connection: z.literal(AppConnection.AWS), - destination: z.literal(SecretSync.AWSSecretsManager), - canImportSecrets: z.literal(true) -}); +export const AwsSecretsManagerSyncListItemSchema = z + .object({ + name: z.literal("AWS Secrets Manager"), + connection: z.literal(AppConnection.AWS), + destination: z.literal(SecretSync.AWSSecretsManager), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AWSSecretsManager] })); diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts index c39581fda..b6f2a77d6 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const AzureAppConfigurationSyncDestinationConfigSchema = z.object({ configurationUrl: z .string() @@ -23,10 +25,12 @@ const AzureAppConfigurationSyncOptionsConfig: TSyncOptionsConfig = { canImportSe export const AzureAppConfigurationSyncSchema = BaseSecretSyncSchema( SecretSync.AzureAppConfiguration, AzureAppConfigurationSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.AzureAppConfiguration), - destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.AzureAppConfiguration), + destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureAppConfiguration] })); export const CreateAzureAppConfigurationSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.AzureAppConfiguration, @@ -42,9 +46,11 @@ export const UpdateAzureAppConfigurationSyncSchema = GenericUpdateSecretSyncFiel destinationConfig: AzureAppConfigurationSyncDestinationConfigSchema.optional() }); -export const AzureAppConfigurationSyncListItemSchema = z.object({ - name: z.literal("Azure App Configuration"), - connection: z.literal(AppConnection.AzureAppConfiguration), - destination: z.literal(SecretSync.AzureAppConfiguration), - canImportSecrets: z.literal(true) -}); +export const AzureAppConfigurationSyncListItemSchema = z + .object({ + name: z.literal("Azure App Configuration"), + connection: z.literal(AppConnection.AzureAppConfiguration), + destination: z.literal(SecretSync.AzureAppConfiguration), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureAppConfiguration] })); diff --git a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts index 69bc22447..2dc178b2b 100644 --- a/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts +++ b/backend/src/services/secret-sync/azure-devops/azure-devops-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + export const AzureDevOpsSyncDestinationConfigSchema = z.object({ devopsProjectId: z .string() @@ -23,10 +25,12 @@ export const AzureDevOpsSyncDestinationConfigSchema = z.object({ const AzureDevOpsSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const AzureDevOpsSyncSchema = BaseSecretSyncSchema(SecretSync.AzureDevOps, AzureDevOpsSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.AzureDevOps), - destinationConfig: AzureDevOpsSyncDestinationConfigSchema -}); +export const AzureDevOpsSyncSchema = BaseSecretSyncSchema(SecretSync.AzureDevOps, AzureDevOpsSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.AzureDevOps), + destinationConfig: AzureDevOpsSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureDevOps] })); export const CreateAzureDevOpsSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.AzureDevOps, @@ -42,9 +46,11 @@ export const UpdateAzureDevOpsSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: AzureDevOpsSyncDestinationConfigSchema.optional() }); -export const AzureDevOpsSyncListItemSchema = z.object({ - name: z.literal("Azure DevOps"), - connection: z.literal(AppConnection.AzureDevOps), - destination: z.literal(SecretSync.AzureDevOps), - canImportSecrets: z.literal(false) -}); +export const AzureDevOpsSyncListItemSchema = z + .object({ + name: z.literal("Azure DevOps"), + connection: z.literal(AppConnection.AzureDevOps), + destination: z.literal(SecretSync.AzureDevOps), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureDevOps] })); diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts index d528f531e..daf936a75 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const AzureKeyVaultSyncDestinationConfigSchema = z.object({ vaultBaseUrl: z .string() @@ -20,13 +22,12 @@ const AzureKeyVaultSyncDestinationConfigSchema = z.object({ const AzureKeyVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const AzureKeyVaultSyncSchema = BaseSecretSyncSchema( - SecretSync.AzureKeyVault, - AzureKeyVaultSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.AzureKeyVault), - destinationConfig: AzureKeyVaultSyncDestinationConfigSchema -}); +export const AzureKeyVaultSyncSchema = BaseSecretSyncSchema(SecretSync.AzureKeyVault, AzureKeyVaultSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.AzureKeyVault), + destinationConfig: AzureKeyVaultSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureKeyVault] })); export const CreateAzureKeyVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.AzureKeyVault, @@ -42,9 +43,11 @@ export const UpdateAzureKeyVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema destinationConfig: AzureKeyVaultSyncDestinationConfigSchema.optional() }); -export const AzureKeyVaultSyncListItemSchema = z.object({ - name: z.literal("Azure Key Vault"), - connection: z.literal(AppConnection.AzureKeyVault), - destination: z.literal(SecretSync.AzureKeyVault), - canImportSecrets: z.literal(true) -}); +export const AzureKeyVaultSyncListItemSchema = z + .object({ + name: z.literal("Azure Key Vault"), + connection: z.literal(AppConnection.AzureKeyVault), + destination: z.literal(SecretSync.AzureKeyVault), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.AzureKeyVault] })); diff --git a/backend/src/services/secret-sync/bitbucket/bitbucket-sync-schemas.ts b/backend/src/services/secret-sync/bitbucket/bitbucket-sync-schemas.ts index 985d86e8d..bfd2ac9bc 100644 --- a/backend/src/services/secret-sync/bitbucket/bitbucket-sync-schemas.ts +++ b/backend/src/services/secret-sync/bitbucket/bitbucket-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const BitbucketSyncDestinationConfigSchema = z.object({ repositorySlug: z.string().describe(SecretSyncs.DESTINATION_CONFIG.BITBUCKET.repositorySlug), environmentId: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.BITBUCKET.environmentId), @@ -18,10 +20,12 @@ const BitbucketSyncDestinationConfigSchema = z.object({ const BitbucketSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const BitbucketSyncSchema = BaseSecretSyncSchema(SecretSync.Bitbucket, BitbucketSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Bitbucket), - destinationConfig: BitbucketSyncDestinationConfigSchema -}); +export const BitbucketSyncSchema = BaseSecretSyncSchema(SecretSync.Bitbucket, BitbucketSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Bitbucket), + destinationConfig: BitbucketSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Bitbucket] })); export const CreateBitbucketSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Bitbucket, @@ -37,9 +41,11 @@ export const UpdateBitbucketSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: BitbucketSyncDestinationConfigSchema.optional() }); -export const BitbucketSyncListItemSchema = z.object({ - name: z.literal("Bitbucket"), - connection: z.literal(AppConnection.Bitbucket), - destination: z.literal(SecretSync.Bitbucket), - canImportSecrets: z.literal(false) -}); +export const BitbucketSyncListItemSchema = z + .object({ + name: z.literal("Bitbucket"), + connection: z.literal(AppConnection.Bitbucket), + destination: z.literal(SecretSync.Bitbucket), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Bitbucket] })); diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts b/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts index 726b5dfac..468563c89 100644 --- a/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts +++ b/backend/src/services/secret-sync/camunda/camunda-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const CamundaSyncDestinationConfigSchema = z.object({ scope: z.string().trim().min(1, "Camunda scope required").describe(SecretSyncs.DESTINATION_CONFIG.CAMUNDA.scope), clusterUUID: z @@ -20,10 +22,12 @@ const CamundaSyncDestinationConfigSchema = z.object({ const CamundaSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const CamundaSyncSchema = BaseSecretSyncSchema(SecretSync.Camunda, CamundaSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Camunda), - destinationConfig: CamundaSyncDestinationConfigSchema -}); +export const CamundaSyncSchema = BaseSecretSyncSchema(SecretSync.Camunda, CamundaSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Camunda), + destinationConfig: CamundaSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Camunda] })); export const CreateCamundaSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Camunda, @@ -39,9 +43,11 @@ export const UpdateCamundaSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: CamundaSyncDestinationConfigSchema.optional() }); -export const CamundaSyncListItemSchema = z.object({ - name: z.literal("Camunda"), - connection: z.literal(AppConnection.Camunda), - destination: z.literal(SecretSync.Camunda), - canImportSecrets: z.literal(true) -}); +export const CamundaSyncListItemSchema = z + .object({ + name: z.literal("Camunda"), + connection: z.literal(AppConnection.Camunda), + destination: z.literal(SecretSync.Camunda), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Camunda] })); diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts index fc511b2d7..276e3702f 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts @@ -9,6 +9,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const ChecklySyncDestinationConfigSchema = z.object({ accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), accountName: z @@ -26,10 +28,12 @@ const ChecklySyncDestinationConfigSchema = z.object({ const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const ChecklySyncSchema = BaseSecretSyncSchema(SecretSync.Checkly, ChecklySyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Checkly), - destinationConfig: ChecklySyncDestinationConfigSchema -}); +export const ChecklySyncSchema = BaseSecretSyncSchema(SecretSync.Checkly, ChecklySyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Checkly), + destinationConfig: ChecklySyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Checkly] })); export const CreateChecklySyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Checkly, @@ -45,9 +49,11 @@ export const UpdateChecklySyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: ChecklySyncDestinationConfigSchema.optional() }); -export const ChecklySyncListItemSchema = z.object({ - name: z.literal("Checkly"), - connection: z.literal(AppConnection.Checkly), - destination: z.literal(SecretSync.Checkly), - canImportSecrets: z.literal(false) -}); +export const ChecklySyncListItemSchema = z + .object({ + name: z.literal("Checkly"), + connection: z.literal(AppConnection.Checkly), + destination: z.literal(SecretSync.Checkly), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Checkly] })); diff --git a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts index f0d814fb6..66fc2ca82 100644 --- a/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts +++ b/backend/src/services/secret-sync/cloudflare-pages/cloudflare-pages-schema.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const CloudflarePagesSyncDestinationConfigSchema = z.object({ projectName: z .string() @@ -26,10 +28,12 @@ const CloudflarePagesSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: export const CloudflarePagesSyncSchema = BaseSecretSyncSchema( SecretSync.CloudflarePages, CloudflarePagesSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.CloudflarePages), - destinationConfig: CloudflarePagesSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.CloudflarePages), + destinationConfig: CloudflarePagesSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.CloudflarePages] })); export const CreateCloudflarePagesSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.CloudflarePages, @@ -45,9 +49,11 @@ export const UpdateCloudflarePagesSyncSchema = GenericUpdateSecretSyncFieldsSche destinationConfig: CloudflarePagesSyncDestinationConfigSchema.optional() }); -export const CloudflarePagesSyncListItemSchema = z.object({ - name: z.literal("Cloudflare Pages"), - connection: z.literal(AppConnection.Cloudflare), - destination: z.literal(SecretSync.CloudflarePages), - canImportSecrets: z.literal(false) -}); +export const CloudflarePagesSyncListItemSchema = z + .object({ + name: z.literal("Cloudflare Pages"), + connection: z.literal(AppConnection.Cloudflare), + destination: z.literal(SecretSync.CloudflarePages), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.CloudflarePages] })); diff --git a/backend/src/services/secret-sync/cloudflare-workers/cloudflare-workers-schemas.ts b/backend/src/services/secret-sync/cloudflare-workers/cloudflare-workers-schemas.ts index b5698867a..414014322 100644 --- a/backend/src/services/secret-sync/cloudflare-workers/cloudflare-workers-schemas.ts +++ b/backend/src/services/secret-sync/cloudflare-workers/cloudflare-workers-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const CloudflareWorkersSyncDestinationConfigSchema = z.object({ scriptId: z .string() @@ -28,10 +30,12 @@ const CloudflareWorkersSyncOptionsConfig: TSyncOptionsConfig = { canImportSecret export const CloudflareWorkersSyncSchema = BaseSecretSyncSchema( SecretSync.CloudflareWorkers, CloudflareWorkersSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.CloudflareWorkers), - destinationConfig: CloudflareWorkersSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.CloudflareWorkers), + destinationConfig: CloudflareWorkersSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.CloudflareWorkers] })); export const CreateCloudflareWorkersSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.CloudflareWorkers, @@ -47,9 +51,11 @@ export const UpdateCloudflareWorkersSyncSchema = GenericUpdateSecretSyncFieldsSc destinationConfig: CloudflareWorkersSyncDestinationConfigSchema.optional() }); -export const CloudflareWorkersSyncListItemSchema = z.object({ - name: z.literal("Cloudflare Workers"), - connection: z.literal(AppConnection.Cloudflare), - destination: z.literal(SecretSync.CloudflareWorkers), - canImportSecrets: z.literal(false) -}); +export const CloudflareWorkersSyncListItemSchema = z + .object({ + name: z.literal("Cloudflare Workers"), + connection: z.literal(AppConnection.Cloudflare), + destination: z.literal(SecretSync.CloudflareWorkers), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.CloudflareWorkers] })); diff --git a/backend/src/services/secret-sync/databricks/databricks-sync-schemas.ts b/backend/src/services/secret-sync/databricks/databricks-sync-schemas.ts index c0f148244..934334eee 100644 --- a/backend/src/services/secret-sync/databricks/databricks-sync-schemas.ts +++ b/backend/src/services/secret-sync/databricks/databricks-sync-schemas.ts @@ -10,16 +10,20 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const DatabricksSyncDestinationConfigSchema = z.object({ scope: z.string().trim().min(1, "Databricks scope required").describe(SecretSyncs.DESTINATION_CONFIG.DATABRICKS.scope) }); const DatabricksSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const DatabricksSyncSchema = BaseSecretSyncSchema(SecretSync.Databricks, DatabricksSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Databricks), - destinationConfig: DatabricksSyncDestinationConfigSchema -}); +export const DatabricksSyncSchema = BaseSecretSyncSchema(SecretSync.Databricks, DatabricksSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Databricks), + destinationConfig: DatabricksSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Databricks] })); export const CreateDatabricksSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Databricks, @@ -35,9 +39,11 @@ export const UpdateDatabricksSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: DatabricksSyncDestinationConfigSchema.optional() }); -export const DatabricksSyncListItemSchema = z.object({ - name: z.literal("Databricks"), - connection: z.literal(AppConnection.Databricks), - destination: z.literal(SecretSync.Databricks), - canImportSecrets: z.literal(false) -}); +export const DatabricksSyncListItemSchema = z + .object({ + name: z.literal("Databricks"), + connection: z.literal(AppConnection.Databricks), + destination: z.literal(SecretSync.Databricks), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Databricks] })); diff --git a/backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts b/backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts index 09b943d16..26014f83b 100644 --- a/backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts +++ b/backend/src/services/secret-sync/digital-ocean-app-platform/digital-ocean-app-platform-sync-schemas.ts @@ -9,6 +9,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const DigitalOceanAppPlatformSyncDestinationConfigSchema = z.object({ appId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), appName: z.string().min(1, "Account Name is required").max(255, "Account Name must be less than 255 characters") @@ -19,10 +21,12 @@ const DigitalOceanAppPlatformSyncOptionsConfig: TSyncOptionsConfig = { canImport export const DigitalOceanAppPlatformSyncSchema = BaseSecretSyncSchema( SecretSync.DigitalOceanAppPlatform, DigitalOceanAppPlatformSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.DigitalOceanAppPlatform), - destinationConfig: DigitalOceanAppPlatformSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.DigitalOceanAppPlatform), + destinationConfig: DigitalOceanAppPlatformSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.DigitalOceanAppPlatform] })); export const CreateDigitalOceanAppPlatformSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.DigitalOceanAppPlatform, @@ -38,9 +42,11 @@ export const UpdateDigitalOceanAppPlatformSyncSchema = GenericUpdateSecretSyncFi destinationConfig: DigitalOceanAppPlatformSyncDestinationConfigSchema.optional() }); -export const DigitalOceanAppPlatformSyncListItemSchema = z.object({ - name: z.literal("Digital Ocean App Platform"), - connection: z.literal(AppConnection.DigitalOcean), - destination: z.literal(SecretSync.DigitalOceanAppPlatform), - canImportSecrets: z.literal(false) -}); +export const DigitalOceanAppPlatformSyncListItemSchema = z + .object({ + name: z.literal("Digital Ocean App Platform"), + connection: z.literal(AppConnection.DigitalOcean), + destination: z.literal(SecretSync.DigitalOceanAppPlatform), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.DigitalOceanAppPlatform] })); diff --git a/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts b/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts index b353f94b4..4d74269d7 100644 --- a/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts +++ b/backend/src/services/secret-sync/flyio/flyio-sync-schemas.ts @@ -10,16 +10,20 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const FlyioSyncDestinationConfigSchema = z.object({ appId: z.string().trim().min(1, "App required").max(255).describe(SecretSyncs.DESTINATION_CONFIG.FLYIO.appId) }); const FlyioSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const FlyioSyncSchema = BaseSecretSyncSchema(SecretSync.Flyio, FlyioSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Flyio), - destinationConfig: FlyioSyncDestinationConfigSchema -}); +export const FlyioSyncSchema = BaseSecretSyncSchema(SecretSync.Flyio, FlyioSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Flyio), + destinationConfig: FlyioSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Flyio] })); export const CreateFlyioSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Flyio, @@ -35,9 +39,11 @@ export const UpdateFlyioSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: FlyioSyncDestinationConfigSchema.optional() }); -export const FlyioSyncListItemSchema = z.object({ - name: z.literal("Fly.io"), - connection: z.literal(AppConnection.Flyio), - destination: z.literal(SecretSync.Flyio), - canImportSecrets: z.literal(false) -}); +export const FlyioSyncListItemSchema = z + .object({ + name: z.literal("Fly.io"), + connection: z.literal(AppConnection.Flyio), + destination: z.literal(SecretSync.Flyio), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Flyio] })); diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts index 875ceaf70..eeef3e4a7 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts @@ -10,6 +10,7 @@ import { import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; import { SecretSync } from "../secret-sync-enums"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { GCPSecretManagerLocation, GcpSyncScope } from "./gcp-sync-enums"; const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; @@ -38,10 +39,12 @@ const GcpSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ ) ]); -export const GcpSyncSchema = BaseSecretSyncSchema(SecretSync.GCPSecretManager, GcpSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.GCPSecretManager), - destinationConfig: GcpSyncDestinationConfigSchema -}); +export const GcpSyncSchema = BaseSecretSyncSchema(SecretSync.GCPSecretManager, GcpSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.GCPSecretManager), + destinationConfig: GcpSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GCPSecretManager] })); export const CreateGcpSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.GCPSecretManager, @@ -57,9 +60,11 @@ export const UpdateGcpSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: GcpSyncDestinationConfigSchema.optional() }); -export const GcpSyncListItemSchema = z.object({ - name: z.literal("GCP Secret Manager"), - connection: z.literal(AppConnection.GCP), - destination: z.literal(SecretSync.GCPSecretManager), - canImportSecrets: z.literal(true) -}); +export const GcpSyncListItemSchema = z + .object({ + name: z.literal("GCP Secret Manager"), + connection: z.literal(AppConnection.GCP), + destination: z.literal(SecretSync.GCPSecretManager), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GCPSecretManager] })); diff --git a/backend/src/services/secret-sync/github/github-sync-schemas.ts b/backend/src/services/secret-sync/github/github-sync-schemas.ts index 76bbc63a7..79f7b18f1 100644 --- a/backend/src/services/secret-sync/github/github-sync-schemas.ts +++ b/backend/src/services/secret-sync/github/github-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const GitHubSyncDestinationConfigSchema = z .discriminatedUnion("scope", [ z.object({ @@ -55,10 +57,12 @@ const GitHubSyncDestinationConfigSchema = z const GitHubSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.GitHub), - destinationConfig: GitHubSyncDestinationConfigSchema -}); +export const GitHubSyncSchema = BaseSecretSyncSchema(SecretSync.GitHub, GitHubSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.GitHub), + destinationConfig: GitHubSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GitHub] })); export const CreateGitHubSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.GitHub, @@ -74,9 +78,11 @@ export const UpdateGitHubSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: GitHubSyncDestinationConfigSchema.optional() }); -export const GitHubSyncListItemSchema = z.object({ - name: z.literal("GitHub"), - connection: z.literal(AppConnection.GitHub), - destination: z.literal(SecretSync.GitHub), - canImportSecrets: z.literal(false) -}); +export const GitHubSyncListItemSchema = z + .object({ + name: z.literal("GitHub"), + connection: z.literal(AppConnection.GitHub), + destination: z.literal(SecretSync.GitHub), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GitHub] })); diff --git a/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts b/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts index 8797cc98d..d96d945d8 100644 --- a/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts +++ b/backend/src/services/secret-sync/gitlab/gitlab-sync-schemas.ts @@ -10,6 +10,7 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { GitLabSyncScope } from "./gitlab-sync-enums"; const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ @@ -70,10 +71,12 @@ const GitLabSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ const GitLabSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const GitLabSyncSchema = BaseSecretSyncSchema(SecretSync.GitLab, GitLabSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.GitLab), - destinationConfig: GitLabSyncDestinationConfigSchema -}); +export const GitLabSyncSchema = BaseSecretSyncSchema(SecretSync.GitLab, GitLabSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.GitLab), + destinationConfig: GitLabSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GitLab] })); export const CreateGitLabSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.GitLab, @@ -89,9 +92,11 @@ export const UpdateGitLabSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: GitLabSyncDestinationConfigSchema.optional() }); -export const GitLabSyncListItemSchema = z.object({ - name: z.literal("GitLab"), - connection: z.literal(AppConnection.GitLab), - destination: z.literal(SecretSync.GitLab), - canImportSecrets: z.literal(false) -}); +export const GitLabSyncListItemSchema = z + .object({ + name: z.literal("GitLab"), + connection: z.literal(AppConnection.GitLab), + destination: z.literal(SecretSync.GitLab), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.GitLab] })); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts index f9096ac71..43c517432 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const HCVaultSyncDestinationConfigSchema = z.object({ mount: z .string() @@ -33,10 +35,12 @@ const HCVaultSyncDestinationConfigSchema = z.object({ const HCVaultSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const HCVaultSyncSchema = BaseSecretSyncSchema(SecretSync.HCVault, HCVaultSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.HCVault), - destinationConfig: HCVaultSyncDestinationConfigSchema -}); +export const HCVaultSyncSchema = BaseSecretSyncSchema(SecretSync.HCVault, HCVaultSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.HCVault), + destinationConfig: HCVaultSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.HCVault] })); export const CreateHCVaultSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.HCVault, @@ -52,9 +56,11 @@ export const UpdateHCVaultSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: HCVaultSyncDestinationConfigSchema.optional() }); -export const HCVaultSyncListItemSchema = z.object({ - name: z.literal("Hashicorp Vault"), - connection: z.literal(AppConnection.HCVault), - destination: z.literal(SecretSync.HCVault), - canImportSecrets: z.literal(true) -}); +export const HCVaultSyncListItemSchema = z + .object({ + name: z.literal("Hashicorp Vault"), + connection: z.literal(AppConnection.HCVault), + destination: z.literal(SecretSync.HCVault), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.HCVault] })); diff --git a/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts b/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts index 5c9ba570e..fce757389 100644 --- a/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts +++ b/backend/src/services/secret-sync/heroku/heroku-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const HerokuSyncDestinationConfigSchema = z.object({ app: z.string().trim().min(1, "App required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.app), appName: z.string().trim().min(1, "App name required").describe(SecretSyncs.DESTINATION_CONFIG.HEROKU.appName) @@ -17,10 +19,12 @@ const HerokuSyncDestinationConfigSchema = z.object({ const HerokuSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const HerokuSyncSchema = BaseSecretSyncSchema(SecretSync.Heroku, HerokuSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Heroku), - destinationConfig: HerokuSyncDestinationConfigSchema -}); +export const HerokuSyncSchema = BaseSecretSyncSchema(SecretSync.Heroku, HerokuSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Heroku), + destinationConfig: HerokuSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Heroku] })); export const CreateHerokuSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Heroku, @@ -36,9 +40,11 @@ export const UpdateHerokuSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: HerokuSyncDestinationConfigSchema.optional() }); -export const HerokuSyncListItemSchema = z.object({ - name: z.literal("Heroku"), - connection: z.literal(AppConnection.Heroku), - destination: z.literal(SecretSync.Heroku), - canImportSecrets: z.literal(true) -}); +export const HerokuSyncListItemSchema = z + .object({ + name: z.literal("Heroku"), + connection: z.literal(AppConnection.Heroku), + destination: z.literal(SecretSync.Heroku), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Heroku] })); diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts index cd90ecfdc..7fcf5e2bc 100644 --- a/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const HumanitecSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ z.object({ scope: z.literal(HumanitecSyncScope.Application).describe(SecretSyncs.DESTINATION_CONFIG.HUMANITEC.scope), @@ -27,10 +29,12 @@ const HumanitecSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ const HumanitecSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const HumanitecSyncSchema = BaseSecretSyncSchema(SecretSync.Humanitec, HumanitecSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Humanitec), - destinationConfig: HumanitecSyncDestinationConfigSchema -}); +export const HumanitecSyncSchema = BaseSecretSyncSchema(SecretSync.Humanitec, HumanitecSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Humanitec), + destinationConfig: HumanitecSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Humanitec] })); export const CreateHumanitecSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Humanitec, @@ -46,9 +50,11 @@ export const UpdateHumanitecSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: HumanitecSyncDestinationConfigSchema.optional() }); -export const HumanitecSyncListItemSchema = z.object({ - name: z.literal("Humanitec"), - connection: z.literal(AppConnection.Humanitec), - destination: z.literal(SecretSync.Humanitec), - canImportSecrets: z.literal(false) -}); +export const HumanitecSyncListItemSchema = z + .object({ + name: z.literal("Humanitec"), + connection: z.literal(AppConnection.Humanitec), + destination: z.literal(SecretSync.Humanitec), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Humanitec] })); diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts index 168ebde3b..207b7b8cd 100644 --- a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const slugValidator = (val: string) => { return new RE2("^[a-z0-9.-]+$").test(val) && !new RE2(".[-]$").test(val); }; @@ -38,13 +40,12 @@ const LaravelForgeSyncDestinationConfigSchema = z.object({ const LaravelForgeSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const LaravelForgeSyncSchema = BaseSecretSyncSchema( - SecretSync.LaravelForge, - LaravelForgeSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.LaravelForge), - destinationConfig: LaravelForgeSyncDestinationConfigSchema -}); +export const LaravelForgeSyncSchema = BaseSecretSyncSchema(SecretSync.LaravelForge, LaravelForgeSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: LaravelForgeSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.LaravelForge] })); export const CreateLaravelForgeSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.LaravelForge, @@ -60,9 +61,11 @@ export const UpdateLaravelForgeSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: LaravelForgeSyncDestinationConfigSchema.optional() }); -export const LaravelForgeSyncListItemSchema = z.object({ - name: z.literal("Laravel Forge"), - connection: z.literal(AppConnection.LaravelForge), - destination: z.literal(SecretSync.LaravelForge), - canImportSecrets: z.literal(true) -}); +export const LaravelForgeSyncListItemSchema = z + .object({ + name: z.literal("Laravel Forge"), + connection: z.literal(AppConnection.LaravelForge), + destination: z.literal(SecretSync.LaravelForge), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.LaravelForge] })); diff --git a/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts index cd9a985c9..7a40710ff 100644 --- a/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts +++ b/backend/src/services/secret-sync/netlify/netlify-sync-schemas.ts @@ -10,6 +10,7 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { NetlifySyncContext } from "./netlify-sync-constants"; const NetlifySyncDestinationConfigSchema = z.object({ @@ -41,10 +42,12 @@ const NetlifySyncDestinationConfigSchema = z.object({ const NetlifySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Netlify), - destinationConfig: NetlifySyncDestinationConfigSchema -}); +export const NetlifySyncSchema = BaseSecretSyncSchema(SecretSync.Netlify, NetlifySyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Netlify), + destinationConfig: NetlifySyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Netlify] })); export const CreateNetlifySyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Netlify, @@ -60,9 +63,11 @@ export const UpdateNetlifySyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: NetlifySyncDestinationConfigSchema.optional() }); -export const NetlifySyncListItemSchema = z.object({ - name: z.literal("Netlify"), - connection: z.literal(AppConnection.Netlify), - destination: z.literal(SecretSync.Netlify), - canImportSecrets: z.literal(true) -}); +export const NetlifySyncListItemSchema = z + .object({ + name: z.literal("Netlify"), + connection: z.literal(AppConnection.Netlify), + destination: z.literal(SecretSync.Netlify), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Netlify] })); diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts index 55cdeae23..97c6e4c45 100644 --- a/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts +++ b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const NorthflankSyncDestinationConfigSchema = z.object({ projectId: z .string() @@ -27,10 +29,12 @@ const NorthflankSyncDestinationConfigSchema = z.object({ const NorthflankSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const NorthflankSyncSchema = BaseSecretSyncSchema(SecretSync.Northflank, NorthflankSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Northflank), - destinationConfig: NorthflankSyncDestinationConfigSchema -}); +export const NorthflankSyncSchema = BaseSecretSyncSchema(SecretSync.Northflank, NorthflankSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: NorthflankSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Northflank] })); export const CreateNorthflankSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Northflank, @@ -46,9 +50,11 @@ export const UpdateNorthflankSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: NorthflankSyncDestinationConfigSchema.optional() }); -export const NorthflankSyncListItemSchema = z.object({ - name: z.literal("Northflank"), - connection: z.literal(AppConnection.Northflank), - destination: z.literal(SecretSync.Northflank), - canImportSecrets: z.literal(true) -}); +export const NorthflankSyncListItemSchema = z + .object({ + name: z.literal("Northflank"), + connection: z.literal(AppConnection.Northflank), + destination: z.literal(SecretSync.Northflank), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Northflank] })); diff --git a/backend/src/services/secret-sync/railway/railway-sync-schemas.ts b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts index 56cea0408..2a5bec1ab 100644 --- a/backend/src/services/secret-sync/railway/railway-sync-schemas.ts +++ b/backend/src/services/secret-sync/railway/railway-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const RailwaySyncDestinationConfigSchema = z.object({ projectId: z .string() @@ -29,10 +31,12 @@ const RailwaySyncDestinationConfigSchema = z.object({ const RailwaySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const RailwaySyncSchema = BaseSecretSyncSchema(SecretSync.Railway, RailwaySyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Railway), - destinationConfig: RailwaySyncDestinationConfigSchema -}); +export const RailwaySyncSchema = BaseSecretSyncSchema(SecretSync.Railway, RailwaySyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Railway), + destinationConfig: RailwaySyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Railway] })); export const CreateRailwaySyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Railway, @@ -48,9 +52,11 @@ export const UpdateRailwaySyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: RailwaySyncDestinationConfigSchema.optional() }); -export const RailwaySyncListItemSchema = z.object({ - name: z.literal("Railway"), - connection: z.literal(AppConnection.Railway), - destination: z.literal(SecretSync.Railway), - canImportSecrets: z.literal(true) -}); +export const RailwaySyncListItemSchema = z + .object({ + name: z.literal("Railway"), + connection: z.literal(AppConnection.Railway), + destination: z.literal(SecretSync.Railway), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Railway] })); diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts index 0bc116255..266f093f8 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -10,6 +10,7 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { RenderSyncScope, RenderSyncType } from "./render-sync-enums"; const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ @@ -38,10 +39,12 @@ export const RenderSyncSchema = BaseSecretSyncSchema( SecretSync.Render, RenderSyncOptionsConfig, RenderSyncOptionsSchema -).extend({ - destination: z.literal(SecretSync.Render), - destinationConfig: RenderSyncDestinationConfigSchema -}); +) + .extend({ + destination: z.literal(SecretSync.Render), + destinationConfig: RenderSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Render] })); export const CreateRenderSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Render, @@ -59,9 +62,11 @@ export const UpdateRenderSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: RenderSyncDestinationConfigSchema.optional() }); -export const RenderSyncListItemSchema = z.object({ - name: z.literal("Render"), - connection: z.literal(AppConnection.Render), - destination: z.literal(SecretSync.Render), - canImportSecrets: z.literal(true) -}); +export const RenderSyncListItemSchema = z + .object({ + name: z.literal("Render"), + connection: z.literal(AppConnection.Render), + destination: z.literal(SecretSync.Render), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Render] })); diff --git a/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts b/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts index 633b40dab..cd888b556 100644 --- a/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts +++ b/backend/src/services/secret-sync/supabase/supabase-sync-schemas.ts @@ -9,6 +9,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const SupabaseSyncDestinationConfigSchema = z.object({ projectId: z.string().max(255).min(1, "Project ID is required"), projectName: z.string().max(255).min(1, "Project Name is required") @@ -16,10 +18,12 @@ const SupabaseSyncDestinationConfigSchema = z.object({ const SupabaseSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const SupabaseSyncSchema = BaseSecretSyncSchema(SecretSync.Supabase, SupabaseSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Supabase), - destinationConfig: SupabaseSyncDestinationConfigSchema -}); +export const SupabaseSyncSchema = BaseSecretSyncSchema(SecretSync.Supabase, SupabaseSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Supabase), + destinationConfig: SupabaseSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Supabase] })); export const CreateSupabaseSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Supabase, @@ -35,9 +39,11 @@ export const UpdateSupabaseSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: SupabaseSyncDestinationConfigSchema.optional() }); -export const SupabaseSyncListItemSchema = z.object({ - name: z.literal("Supabase"), - connection: z.literal(AppConnection.Supabase), - destination: z.literal(SecretSync.Supabase), - canImportSecrets: z.literal(false) -}); +export const SupabaseSyncListItemSchema = z + .object({ + name: z.literal("Supabase"), + connection: z.literal(AppConnection.Supabase), + destination: z.literal(SecretSync.Supabase), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Supabase] })); diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts index 21c09092f..ed56ae124 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-schemas.ts @@ -10,6 +10,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const TeamCitySyncDestinationConfigSchema = z.object({ project: z.string().trim().min(1, "Project required").describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.project), buildConfig: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.TEAMCITY.buildConfig) @@ -17,10 +19,12 @@ const TeamCitySyncDestinationConfigSchema = z.object({ const TeamCitySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const TeamCitySyncSchema = BaseSecretSyncSchema(SecretSync.TeamCity, TeamCitySyncOptionsConfig).extend({ - destination: z.literal(SecretSync.TeamCity), - destinationConfig: TeamCitySyncDestinationConfigSchema -}); +export const TeamCitySyncSchema = BaseSecretSyncSchema(SecretSync.TeamCity, TeamCitySyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.TeamCity), + destinationConfig: TeamCitySyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TeamCity] })); export const CreateTeamCitySyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.TeamCity, @@ -36,9 +40,11 @@ export const UpdateTeamCitySyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: TeamCitySyncDestinationConfigSchema.optional() }); -export const TeamCitySyncListItemSchema = z.object({ - name: z.literal("TeamCity"), - connection: z.literal(AppConnection.TeamCity), - destination: z.literal(SecretSync.TeamCity), - canImportSecrets: z.literal(true) -}); +export const TeamCitySyncListItemSchema = z + .object({ + name: z.literal("TeamCity"), + connection: z.literal(AppConnection.TeamCity), + destination: z.literal(SecretSync.TeamCity), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TeamCity] })); diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts index 359d7f4c5..1254b06ea 100644 --- a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-schemas.ts @@ -14,6 +14,8 @@ import { TerraformCloudSyncScope } from "@app/services/secret-sync/terraform-cloud/terraform-cloud-sync-enums"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const TerraformCloudSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ z.object({ scope: z @@ -47,13 +49,12 @@ const TerraformCloudSyncDestinationConfigSchema = z.discriminatedUnion("scope", const TerraformCloudSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; -export const TerraformCloudSyncSchema = BaseSecretSyncSchema( - SecretSync.TerraformCloud, - TerraformCloudSyncOptionsConfig -).extend({ - destination: z.literal(SecretSync.TerraformCloud), - destinationConfig: TerraformCloudSyncDestinationConfigSchema -}); +export const TerraformCloudSyncSchema = BaseSecretSyncSchema(SecretSync.TerraformCloud, TerraformCloudSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.TerraformCloud), + destinationConfig: TerraformCloudSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TerraformCloud] })); export const CreateTerraformCloudSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.TerraformCloud, @@ -69,9 +70,11 @@ export const UpdateTerraformCloudSyncSchema = GenericUpdateSecretSyncFieldsSchem destinationConfig: TerraformCloudSyncDestinationConfigSchema.optional() }); -export const TerraformCloudSyncListItemSchema = z.object({ - name: z.literal("Terraform Cloud"), - connection: z.literal(AppConnection.TerraformCloud), - destination: z.literal(SecretSync.TerraformCloud), - canImportSecrets: z.literal(false) -}); +export const TerraformCloudSyncListItemSchema = z + .object({ + name: z.literal("Terraform Cloud"), + connection: z.literal(AppConnection.TerraformCloud), + destination: z.literal(SecretSync.TerraformCloud), + canImportSecrets: z.literal(false) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.TerraformCloud] })); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts index 84d7a6da4..06cdb6cc1 100644 --- a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -10,6 +10,7 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { VercelEnvironmentType } from "./vercel-sync-enums"; const VercelSyncDestinationConfigSchema = z.object({ @@ -22,10 +23,12 @@ const VercelSyncDestinationConfigSchema = z.object({ const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Vercel), - destinationConfig: VercelSyncDestinationConfigSchema -}); +export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: VercelSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Vercel] })); export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Vercel, @@ -41,9 +44,11 @@ export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: VercelSyncDestinationConfigSchema.optional() }); -export const VercelSyncListItemSchema = z.object({ - name: z.literal("Vercel"), - connection: z.literal(AppConnection.Vercel), - destination: z.literal(SecretSync.Vercel), - canImportSecrets: z.literal(true) -}); +export const VercelSyncListItemSchema = z + .object({ + name: z.literal("Vercel"), + connection: z.literal(AppConnection.Vercel), + destination: z.literal(SecretSync.Vercel), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Vercel] })); diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts index 5740e21c9..38fdfda15 100644 --- a/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts +++ b/backend/src/services/secret-sync/windmill/windmill-sync-schemas.ts @@ -11,6 +11,8 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; + const pathCharacterValidator = characterValidator([ CharacterType.AlphaNumeric, CharacterType.Underscore, @@ -39,10 +41,12 @@ const WindmillSyncDestinationConfigSchema = z.object({ const WindmillSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const WindmillSyncSchema = BaseSecretSyncSchema(SecretSync.Windmill, WindmillSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Windmill), - destinationConfig: WindmillSyncDestinationConfigSchema -}); +export const WindmillSyncSchema = BaseSecretSyncSchema(SecretSync.Windmill, WindmillSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Windmill), + destinationConfig: WindmillSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Windmill] })); export const CreateWindmillSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Windmill, @@ -58,9 +62,11 @@ export const UpdateWindmillSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: WindmillSyncDestinationConfigSchema.optional() }); -export const WindmillSyncListItemSchema = z.object({ - name: z.literal("Windmill"), - connection: z.literal(AppConnection.Windmill), - destination: z.literal(SecretSync.Windmill), - canImportSecrets: z.literal(true) -}); +export const WindmillSyncListItemSchema = z + .object({ + name: z.literal("Windmill"), + connection: z.literal(AppConnection.Windmill), + destination: z.literal(SecretSync.Windmill), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Windmill] })); diff --git a/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts index 94a729cb6..09cfbecdf 100644 --- a/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts +++ b/backend/src/services/secret-sync/zabbix/zabbix-sync-schemas.ts @@ -10,6 +10,7 @@ import { } from "@app/services/secret-sync/secret-sync-schemas"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; +import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; import { ZabbixSyncScope } from "./zabbix-sync-enums"; const ZabbixSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ @@ -40,10 +41,12 @@ const ZabbixSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ const ZabbixSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const ZabbixSyncSchema = BaseSecretSyncSchema(SecretSync.Zabbix, ZabbixSyncOptionsConfig).extend({ - destination: z.literal(SecretSync.Zabbix), - destinationConfig: ZabbixSyncDestinationConfigSchema -}); +export const ZabbixSyncSchema = BaseSecretSyncSchema(SecretSync.Zabbix, ZabbixSyncOptionsConfig) + .extend({ + destination: z.literal(SecretSync.Zabbix), + destinationConfig: ZabbixSyncDestinationConfigSchema + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Zabbix] })); export const CreateZabbixSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Zabbix, @@ -59,9 +62,11 @@ export const UpdateZabbixSyncSchema = GenericUpdateSecretSyncFieldsSchema( destinationConfig: ZabbixSyncDestinationConfigSchema.optional() }); -export const ZabbixSyncListItemSchema = z.object({ - name: z.literal("Zabbix"), - connection: z.literal(AppConnection.Zabbix), - destination: z.literal(SecretSync.Zabbix), - canImportSecrets: z.literal(true) -}); +export const ZabbixSyncListItemSchema = z + .object({ + name: z.literal("Zabbix"), + connection: z.literal(AppConnection.Zabbix), + destination: z.literal(SecretSync.Zabbix), + canImportSecrets: z.literal(true) + }) + .describe(JSON.stringify({ title: SECRET_SYNC_NAME_MAP[SecretSync.Zabbix] })); diff --git a/docker-compose.bdd.yml b/docker-compose.bdd.yml new file mode 100644 index 000000000..b73683867 --- /dev/null +++ b/docker-compose.bdd.yml @@ -0,0 +1,103 @@ +version: "3.9" + +services: + nginx: + container_name: infisical-bdd-nginx + image: nginx + restart: "always" + ports: + - 8080:80 + - 8443:443 + volumes: + - ./nginx/default.dev.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - backend + - frontend + + db: + image: postgres:14-alpine + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + POSTGRES_PASSWORD: infisical + POSTGRES_USER: infisical + POSTGRES_DB: infisical + + redis: + image: redis + container_name: infisical-bdd-redis + environment: + - ALLOW_EMPTY_PASSWORD=yes + ports: + - 6379:6379 + volumes: + - redis_data:/data + + + backend: + container_name: infisical-bdd-api + build: + context: ./backend + dockerfile: Dockerfile.dev + depends_on: + db: + condition: service_started + redis: + condition: service_started + env_file: + - .env + ports: + - 4000:4000 + - 9464:9464 # for OTEL collection of Prometheus metrics + environment: + - NODE_ENV=development + - DB_CONNECTION_URI=postgres://infisical:infisical@db/infisical?sslmode=disable + - TELEMETRY_ENABLED=false + # This is needed to trust the Pebble CA certificate, which is used for the BDD tests + - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/pebble.minica.crt + volumes: + - ./backend/src:/app/src + # This is needed to trust the Pebble CA certificate, which is used for the BDD tests + - ./backend/bdd/pebble/pebble.minica.pem:/usr/local/share/ca-certificates/pebble.minica.crt:ro + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts + extra_hosts: + - "host.docker.internal:host-gateway" + + # TODO: not really needed, but it seems like nginx needs it to be present + frontend: + container_name: infisical-bdd-frontend + restart: unless-stopped + depends_on: + - backend + build: + context: ./frontend + dockerfile: Dockerfile.dev + volumes: + - ./frontend/src:/app/src/ # mounted whole src to avoid missing reload on new files + - ./frontend/public:/app/public + env_file: .env + + # ACME server for BDD tests + pebble: + image: ghcr.io/letsencrypt/pebble:2.8.0 + command: -config /var/data/pebble/pebble-config.json + ports: + - 14000:14000 # ACME port + - 15000:15000 # Management port + environment: + # Do not perform validation sleep to make the BDD tests faster + - PEBBLE_VA_NOSLEEP=1 + # Skip validation for now to make the BDD tests easier to write + - PEBBLE_VA_ALWAYS_VALID=1 + volumes: + - ./backend/bdd/pebble/:/var/data/pebble:ro + +volumes: + postgres-data: + driver: local + redis_data: + driver: local + softhsm_tokens: + driver: local \ No newline at end of file diff --git a/docs/api-reference/endpoints/project-identities/add-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/add-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership.mdx diff --git a/docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/delete-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership.mdx diff --git a/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx new file mode 100644 index 000000000..801b0d422 --- /dev/null +++ b/docs/api-reference/endpoints/deprecated/project-identities-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v1/projects/{projectId}/identity-memberships/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/list-identity-memberships.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships.mdx diff --git a/docs/api-reference/endpoints/project-identities/update-identity-membership.mdx b/docs/api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership.mdx similarity index 100% rename from docs/api-reference/endpoints/project-identities/update-identity-membership.mdx rename to docs/api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership.mdx diff --git a/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx new file mode 100644 index 000000000..004588713 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/add-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity Membership" +openapi: "POST /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx new file mode 100644 index 000000000..c170dfecf --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/delete-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity Membership" +openapi: "DELETE /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx b/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx new file mode 100644 index 000000000..69e0317d1 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Identity by ID" +openapi: "GET /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx b/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx new file mode 100644 index 000000000..647f9f032 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/list-identity-memberships.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identity Memberships" +openapi: "GET /api/v1/projects/{projectId}/memberships/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx b/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx new file mode 100644 index 000000000..3e8ddbf37 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities-membership/update-identity-membership.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity Membership" +openapi: "PATCH /api/v1/projects/{projectId}/memberships/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/add-identity.mdx b/docs/api-reference/endpoints/project-identities/add-identity.mdx new file mode 100644 index 000000000..a00d12c97 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/add-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Create Identity" +openapi: "POST /api/v1/projects/{projectId}/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities/delete-identity.mdx b/docs/api-reference/endpoints/project-identities/delete-identity.mdx new file mode 100644 index 000000000..3c4ebfa04 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/delete-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete Identity" +openapi: "DELETE /api/v1/projects/{projectId}/identities/{identityId}" +--- diff --git a/docs/api-reference/endpoints/project-identities/get-by-id.mdx b/docs/api-reference/endpoints/project-identities/get-by-id.mdx index 801b0d422..bd9d0dd6f 100644 --- a/docs/api-reference/endpoints/project-identities/get-by-id.mdx +++ b/docs/api-reference/endpoints/project-identities/get-by-id.mdx @@ -1,4 +1,4 @@ --- title: "Get Identity by ID" -openapi: "GET /api/v1/projects/{projectId}/identity-memberships/{identityId}" +openapi: "GET /api/v1/projects/{projectId}/identities/{identityId}" --- diff --git a/docs/api-reference/endpoints/project-identities/list-identity.mdx b/docs/api-reference/endpoints/project-identities/list-identity.mdx new file mode 100644 index 000000000..3070b39e7 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/list-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "List Identities" +openapi: "GET /api/v1/projects/{projectId}/identities" +--- diff --git a/docs/api-reference/endpoints/project-identities/update-identity.mdx b/docs/api-reference/endpoints/project-identities/update-identity.mdx new file mode 100644 index 000000000..683642a12 --- /dev/null +++ b/docs/api-reference/endpoints/project-identities/update-identity.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Identity" +openapi: "PATCH /api/v1/projects/{projectId}/identities/{identityId}" +--- diff --git a/docs/api-reference/overview/usage.mdx b/docs/api-reference/overview/usage.mdx deleted file mode 100644 index 9f23080c7..000000000 --- a/docs/api-reference/overview/usage.mdx +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: "Usage" ---- - -Prerequisites: - -- Set up and add envars to [Infisical Cloud](https://app.infisical.com) or your self-hosted instance. -- Obtain an API Key in your user settings to be included in requests to the Infisical API. - -Using Infisical's API to manage secrets requires a basic understanding of the system and its underlying cryptography detailed [here](/security/overview). - -## Concepts - -- Each user has a public/private key pair that is stored with the platform; private keys are encrypted locally by the user's password before being sent off to the server during the account signup process. -- Each (encrypted) secret belongs to a project and environment. -- Each project has an (encrypted) project key used to encrypt the secrets within that project; Infisical stores copies of the project key, for each member of that project, encrypted under each member's public key. -- Secrets are encrypted symmetrically by your copy of the project key belonging to the project containing. -- Infisical uses AES256-GCM and [TweetNaCl.js](https://tweetnacl.js.org/#/) for symmetric and asymmetric encryption/decryption operations. diff --git a/docs/contributing/getting-started/faq.mdx b/docs/contributing/getting-started/faq.mdx deleted file mode 100644 index f34382c64..000000000 --- a/docs/contributing/getting-started/faq.mdx +++ /dev/null @@ -1,93 +0,0 @@ ---- -title: "FAQ" -description: "Frequently Asked Questions about contributing to Infisical" ---- - -Frequently asked questions about contributing to Infisical can be found on this page. -If you can't find the answer you are looking for, please create an issue on our GitHub repository or join our Slack channel for additional support. - - -The Alpine Linux CDN may be unavailable/down in your region infrequently (eg. there is an unplanned outage). One possible fix is to add a retry mechanism and a fallback mirrors array to the Dockerfile. You can also use this as an opportunity to pin the Alpine Linux version for Docker to use in case there are issues with the latest version. Ensure to use https for the mirrors. - -#### Make the following changes to the backend Dockerfile -```bash -# Pin Alpine version from list: https://dl-cdn.alpinelinux.org/alpine/ -ARG ALPINE_VERSION=3.17 -ARG ALPINE_APPEND=v3.17/main - -# Specify number of retries for each mirror -ARG MAX_RETRIES=3 - -# Define base Alpine mirror URLs in attempt order from list: https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt -ARG BASE_ALPINE_MIRRORS="https://dl-cdn.alpinelinux.org/alpine https://ftp.halifax.rwth-aachen.de/alpine https://uk.alpinelinux.org/alpine" - -# Build stage -# Add the Alpine version arg -FROM node:16-alpine$ALPINE_VERSION AS build - -WORKDIR /app - -COPY package*.json ./ -RUN npm ci --only-production - -COPY . . -RUN npm run build - -# Production stage -# Add the Alpine version arg -FROM node:16-alpine$ALPINE_VERSION - -WORKDIR /app - -ENV npm_config_cache /home/node/.npm - -COPY package*.json ./ -RUN npm ci --only-production - -COPY --from=build /app . - -# Add retry mechanism and loop through the specified mirrors -RUN retries_left=$MAX_RETRIES; \ - for mirror in $ALPINE_MIRRORS; do \ - full_mirror="$mirror/$ALPINE_APPEND"; \ - echo "Trying mirror: $full_mirror"; \ - echo >>/etc/apk/repositories "$full_mirror"; \ - for i in $(seq $retries_left); do \ - echo "Retrying... Attempt $i (Retries Left: $((retries_left - i)))"; \ - if apk add --no-cache bash curl git && \ - curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash && \ - apk add --no-cache infisical=0.8.1; then \ - break; \ - fi; \ - sleep 10; \ - done; \ - if [ $? -eq 0 ]; then \ - break; \ - fi; \ - done - -HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ - CMD node healthcheck.js - -EXPOSE 4000 - -CMD ["npm", "run", "start"] - ``` - - - [Alpine Linux (mirrors) - official site](https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) - - - - [Alpine Linux (mirrors) - archived site](https://web.archive.org/web/20230914123159/https://dl-cdn.alpinelinux.org/alpine/MIRRORS.txt) - - - - [Alpine Linux (versions) - official site](https://dl-cdn.alpinelinux.org/alpine/) - - - - [Alpine Linux (versions) - archived site](https://web.archive.org/web/20230914123455/https://dl-cdn.alpinelinux.org/alpine/) - - - diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 1784b77e8..e34f715b0 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -20,7 +20,6 @@ Infisical has two major code-bases. One for the platform code, and one for SDKs. - [C++ SDK](https://github.com/Infisical/infisical-cpp-sdk) - [PHP SDK](https://github.com/Infisical/php-sdk) - [Rust SDK](https://github.com/Infisical/rust-sdk) - - [Ruby SDK](https://github.com/infisical/sdk) ## Community diff --git a/docs/contributing/getting-started/pull-requests.mdx b/docs/contributing/getting-started/pull-requests.mdx index 2f3163478..dfdf05c18 100644 --- a/docs/contributing/getting-started/pull-requests.mdx +++ b/docs/contributing/getting-started/pull-requests.mdx @@ -29,13 +29,7 @@ Feel free to add a short video or screenshots of what your PR achieves. ## Getting your PR reviewed -Once your PR is reviewed, one or two relevant members of the Infisical team should review and approve the PR before it is merged. You should coordinate and ping the team member closest to the submitted functionality via our [Slack](https://infisical.com/slack) to review your PR. - -- Vlad: Frontend, Web UI -- Tony: Backend, SDKs, Security -- Maidul: Backend, CI/CD, CLI, Kubernetes Operator -- Daniel: Frontend, UI/UX, Backend, SDKs - +One or two relevant members of the Infisical team should review and approve the PR before it is merged. You can ping someone from the team in our [Slack](https://infisical.com/slack) to review your PR. The team member(s) will start by enabling baseline checks to ensure that there are no leaked secrets, new dependencies are clear, and the frontend/backend services start up. Afterward, they will review your PR thoroughly by testing the code and leave any feedback or work in with you to revise the PR up to standard. diff --git a/docs/contributing/platform/backend/folder-structure.mdx b/docs/contributing/platform/backend/folder-structure.mdx index abfe0f69d..ec0bd72e4 100644 --- a/docs/contributing/platform/backend/folder-structure.mdx +++ b/docs/contributing/platform/backend/folder-structure.mdx @@ -5,28 +5,47 @@ title: 'Backend folder structure' ``` ├── scripts ├── e2e-test +├── bdd └── src/ ├── @types/ │ ├── knex.d.ts - │ └── fastify.d.ts + │ ├── fastify.d.ts + │ ├── ... ├── db/ │ ├── migrations │ ├── schemas - │ └── seed + │ └── seeds + ├── keystore/ ├── lib/ + │ ├── api-docs + │ ├── aws + │ ├── axios + │ ├── base64 + │ ├── casl + │ ├── certificates + │ ├── config + │ ├── crypto + │ ├── dates + │ ├── delay + │ ├── error-codes + │ ├── errors + │ ├── files │ ├── fn - │ ├── date - │ └── config + │ ├── ... ├── queue ├── server/ │ ├── routes/ │ │ ├── v1 - │ │ └── v2 + │ │ ├── v2 + │ │ ├── v3 + │ │ └── v4 │ ├── plugins - │ └── config + │ ├── config + │ └── lib ├── services/ │ ├── auth │ ├── org + │ ├── ... │ └── project/ │ ├── project-service.ts │ ├── project-types.ts @@ -42,19 +61,23 @@ Contains reusable scripts for backend automation, like running migrations and ge ### `backend/e2e-test` Integration tests for the APIs. +### `backend/bdd` +Behavior-Driven Development (BDD) tests using Python and Gherkin feature files. + ### `backend/src` The source code of the backend. -- `@types`: Type definitions for libraries like Fastify and Knex. -- `db`: Knex.js configuration for the database, including migration, seed files, and SQL type schemas. -- `lib`: Stateless, reusable functions used across the codebase. +- `@types`: Type definitions for libraries like Fastify, Knex, and other third-party dependencies. +- `db`: Knex.js configuration for the database, including migrations, seed files, and SQL type schemas. +- `keystore`: Key-value store abstraction layer supporting Redis and PostgreSQL for application caching, distributed locking, and coordination. +- `lib`: Stateless, reusable functions used across the codebase, organized by functionality (crypto, config, dates, etc.). - `queue`: Infisical's queue system based on BullMQ. ### `src/server` - Scope anything related to Fastify/service here. -- Includes routes, Fastify plugins, and server configurations. -- The routes folder contains various versions of routes separated into v1, v2, etc. +- Includes routes, Fastify plugins, server configurations, and server-specific utilities. +- The routes folder contains various versions of routes separated into v1, v2, v3, v4, etc. ### `src/services` diff --git a/docs/contributing/platform/backend/how-to-create-a-feature.mdx b/docs/contributing/platform/backend/how-to-create-a-feature.mdx index f02040cfa..52fc5aad0 100644 --- a/docs/contributing/platform/backend/how-to-create-a-feature.mdx +++ b/docs/contributing/platform/backend/how-to-create-a-feature.mdx @@ -8,7 +8,7 @@ Suppose you're interested in implementing a new feature in Infisical's backend, If your feature involves a change in the database, you need to first address this by generating the necessary database schemas. 1. If you're adding a new table, update the `TableName` enum in `/src/db/schemas/models.ts` to include the new table name. -2. Create a new migration file by running `npm run migration:new` and give it a relevant name, such as `feature-x`. +2. Create a new migration file by going to the `/backend` folder and running `npm run migration:new` and give it a relevant name, such as `feature-x`. 3. Navigate to `/src/db/migrations/_.ts`. 4. Modify both the `up` and `down` functions to create or alter Postgres fields on migration up and to revert these changes on migration down, ensuring idempotency as outlined [here](https://github.com/graphile/migrate/blob/main/docs/idempotent-examples.md). @@ -16,10 +16,11 @@ If your feature involves a change in the database, you need to first address thi While typically you would need to manually write TS types for Knex type-sense, we have automated this process: -1. Start the server. -2. Run `npm run migration:latest` to apply all database changes. -3. Execute `npm run generate:schema` to automatically generate types and schemas using [zod](https://github.com/colinhacks/zod) in the `/src/db/schemas` folder. -4. Update the barrel export in `schema/index` and include the new tables in `/src/@types/knex.d.ts` to enable type-sensing in Knex.js. +1. If you haven't done it yet, create a new `.env.migration` file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.migration.example) +2. Start the server. +3. Go to the `/backend` folder and run `npm run migration:latest-dev` to apply all database changes. +4. Execute `npm run generate:schema` to automatically generate types and schemas using [zod](https://github.com/colinhacks/zod) in the `/src/db/schemas` folder. +5. Update the barrel export in `schema/index` and include the new tables in `/src/@types/knex.d.ts` to enable type-sensing in Knex.js. ## Business Logic @@ -38,10 +39,10 @@ Use the custom Infisical function `ormify` in `src/lib/knex` for simple database ## Connecting the Service Layer to the Server Layer -Server-related logic is handled in `/src/server`. To connect the service layer to the server layer, we use Fastify plugins for dependency injection: +Server-related logic is handled in `/src/server`. To connect the service layer to the server layer, we use Fastify's dependency injection pattern: -1. Add the service type in the `fastify.d.ts` file under the `service` namespace of a FastifyServerInstance type. -2. In `/src/server/routes/index.ts`, instantiate the required dependencies for `feature-x`, such as the DAL and service layers, and then pass them to `fastify.register("service,{...dependencies})`. +1. Add the service type in `/src/@types/fastify.d.ts` under the `services` namespace of the `FastifyInstance` interface. +2. In `/src/server/routes/index.ts`, instantiate the required dependencies for `feature-x` (such as the DAL and service layers), and then add the service instance to the `server.decorate()` call, where all services are registered for dependency injection. 3. This makes the service layer accessible within all routes under the Fastify service instance, accessed via `server.services..`. ## Writing API Routes diff --git a/docs/contributing/platform/developing.mdx b/docs/contributing/platform/developing.mdx index 69710baf3..5a1af52c4 100644 --- a/docs/contributing/platform/developing.mdx +++ b/docs/contributing/platform/developing.mdx @@ -15,7 +15,7 @@ git checkout -b MY_BRANCH_NAME ## Set up environment variables -Start by creating a .env file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.example). View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. +Start by creating a `.env` file at the root of the Infisical directory then copy the contents of the file linked [here](https://github.com/Infisical/infisical/blob/main/.env.example). View all available [environment variables](https://infisical.com/docs/self-hosting/configuration/envars) and guidance for each. ## Starting Infisical for development diff --git a/docs/docs.json b/docs/docs.json index 97d35e8e9..aea022fd4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -377,8 +377,7 @@ "pages": [ "contributing/getting-started/overview", "contributing/getting-started/code-of-conduct", - "contributing/getting-started/pull-requests", - "contributing/getting-started/faq" + "contributing/getting-started/pull-requests" ] }, { @@ -753,7 +752,12 @@ "group": "Infrastructure Integrations", "pages": [ "documentation/platform/pki/pki-issuer", - "documentation/platform/pki/integration-guides/gloo-mesh" + "documentation/platform/pki/integration-guides/gloo-mesh", + "documentation/platform/pki/integration-guides/windows-server-acme", + "documentation/platform/pki/integration-guides/nginx-certbot", + "documentation/platform/pki/integration-guides/apache-certbot", + "documentation/platform/pki/integration-guides/tomcat-certbot", + "documentation/platform/pki/integration-guides/jboss-certbot" ] }, { @@ -894,7 +898,7 @@ "pages": ["api-reference/endpoints/events/project-events"] }, { - "group": "Identities", + "group": "Organization Identities", "pages": [ "api-reference/endpoints/identities/create", "api-reference/endpoints/identities/update", @@ -1150,21 +1154,31 @@ ] }, { - "group": "Project Identities", + "group": "Project Managed Identities", "pages": [ - "api-reference/endpoints/project-identities/add-identity-membership", - "api-reference/endpoints/project-identities/list-identity-memberships", + "api-reference/endpoints/project-identities/add-identity", + "api-reference/endpoints/project-identities/list-identity", "api-reference/endpoints/project-identities/get-by-id", - "api-reference/endpoints/project-identities/update-identity-membership", - "api-reference/endpoints/project-identities/delete-identity-membership", + "api-reference/endpoints/project-identities/update-identity", + "api-reference/endpoints/project-identities/delete-identity" + ] + }, + { + "group": "Project Identity Memberships", + "pages": [ + "api-reference/endpoints/project-identities-membership/add-identity-membership", + "api-reference/endpoints/project-identities-membership/list-identity-memberships", + "api-reference/endpoints/project-identities-membership/get-by-id", + "api-reference/endpoints/project-identities-membership/update-identity-membership", + "api-reference/endpoints/project-identities-membership/delete-identity-membership", { "group": "Legacy", "pages": [ - "api-reference/endpoints/deprecated/project-identities/add-identity-membership", - "api-reference/endpoints/deprecated/project-identities/list-identity-memberships", - "api-reference/endpoints/deprecated/project-identities/get-by-id", - "api-reference/endpoints/deprecated/project-identities/update-identity-membership", - "api-reference/endpoints/deprecated/project-identities/delete-identity-membership" + "api-reference/endpoints/deprecated/project-identities-v2/add-identity-membership", + "api-reference/endpoints/deprecated/project-identities-v2/list-identity-memberships", + "api-reference/endpoints/deprecated/project-identities-v2/get-by-id", + "api-reference/endpoints/deprecated/project-identities-v2/update-identity-membership", + "api-reference/endpoints/deprecated/project-identities-v2/delete-identity-membership" ] } ] diff --git a/docs/documentation/getting-started/api.mdx b/docs/documentation/getting-started/api.mdx deleted file mode 100644 index c638c4d70..000000000 --- a/docs/documentation/getting-started/api.mdx +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: "REST API" ---- - -Infisical's REST API is the most flexible way to read/write secrets for your application. - -In this brief, we'll explore how to fetch a secret back from a project on [Infisical Cloud](https://app.infisical.com) via the REST API. - - - - To create a project, head to your Organization Overview and press **Add New Project**; we'll call the project **Demo App**. - ![create project](../../images/getting-started/api/org-create-project-1.png) - - ![create project](../../images/getting-started/api/org-create-project-2.png) - - Next, let's head to the **Development** environment of the project and add a secret `FOO=BAR` to it. - - ![explore project env](../../images/getting-started/api/project-explore-env.png) - - ![create secret](../../images/getting-started/api/project-create-secret.png) - - ![project dashboard](../../images/getting-started/api/project-dashboard.png) - - - For this brief, you'll need to disable end-to-end encryption in your Project Settings - - - - Next, we need to create an identity to represent your application. To create one, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. - - ![identities organization](../../images/platform/identities/identities-org.png) - - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - - ![identities organization create](../../images/platform/identities/identities-org-create.png) - - Once you've created an identity, you'll be prompted to configure the **Universal Auth** authentication method for it. - - ![identities organization create auth method](../../images/platform/identities/identities-org-create-auth-method.png) - - - - In order to use the identity, you'll need the non-sensitive **Client ID** - of the identity and a **Client Secret** for it; you can think of these credentials akin to a username - and password used to authenticate with the Infisical API. With that, press on the key icon on the identity to generate a **Client Secret** - for it. - - ![identities client secret create](../../images/platform/identities/identities-org-client-secret.png) - ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-1.png) - ![identities client secret create](../../images/platform/identities/identities-org-client-secret-create-2.png) - - - To enable the identity to access your project, we need to add it to the project. To do this, head over to the **Demo App** Project Settings > Access Control > Machine Identities and press **Add identity**. - - Next, select the identity you want to add to the project and the role you want to assign it. - - ![identities project](../../images/platform/identities/identities-project.png) - - ![identities project create](../../images/platform/identities/identities-project-create.png) - - - To access the Infisical API as the identity, you should first perform a login operation - that is to exchange the **Client ID** and **Client Secret** of the identity for an access token - by making a request to the `/api/v1/auth/universal-auth/login` endpoint. - - #### Sample request - - ``` - curl --location --request POST 'https://app.infisical.com/api/v1/auth/universal-auth/login' \ - --header 'Content-Type: application/x-www-form-urlencoded' \ - --data-urlencode 'clientSecret=' \ - --data-urlencode 'clientId=' - ``` - - #### Sample response - - ``` - { - "accessToken": "...", - "expiresIn": 7200, - "tokenType": "Bearer" - } - ``` - - Next, we can use the access token to authenticate with the [Infisical API](/api-reference/overview/introduction) to read/write secrets - - - Each identity access token has a time-to-live (TTL) which you can infer from the response of the login operation; - the default TTL is `7200` seconds which can be adjusted. - - If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, - a new access token should be obtained from the aforementioned login operation. - - - - Finally, you can fetch the secret `FOO=BAR` back from **Step 1** by including the access token in the previous step in another request to the `/api/v3/secrets/raw/{secretName}` endpoint. - - ### Sample request - - ``` - curl --location --request GET 'http://localhost:8080/api/v3/secrets/raw/FOO?workspaceId=657830d579cfc8415d06ce5b&environment=dev' \ - --header 'Authorization: Bearer ' - ``` - - ### Sample response - - ``` - { - "secret": { - "_id": "6564234b934d634e1fcd6cdf", - "version": 1, - "workspace": "6564173e934d634e1fcd6950", - "type": "shared", - "environment": "dev", - "secretKey": "FOO2", - "secretValue": "BAR2", - "secretComment": "" - } - } - ``` - - Note that you can fetch a list of secrets back by making a request to the `/api/v3/secrets/raw` endpoint. - - - -See also: - -- [API Reference](/api-reference/overview/introduction) diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx index 05ca88b5f..e0b0788ca 100644 --- a/docs/documentation/getting-started/overview.mdx +++ b/docs/documentation/getting-started/overview.mdx @@ -46,12 +46,11 @@ description: "The open source platform for managing secrets, certificates, and s > Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. - - - - - Encrypt and decrypt sensitive data using a centralized key management - system. + + Encrypt and decrypt sensitive data using a centralized key management system. diff --git a/docs/documentation/getting-started/platform.mdx b/docs/documentation/getting-started/platform.mdx deleted file mode 100644 index 7ce96a0ed..000000000 --- a/docs/documentation/getting-started/platform.mdx +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: "Platform" ---- - -This quickstart provides an overview of functionalities offered by Infisical. - -## Managing your Organization - -When you first make an account with Infisical, you also create a new **organization** where you are assigned the `admin` role by default. -From there, you can invite external members to the organization and start creating **projects** to house secrets. - -### Projects - -The **Projects** page shows you all the projects that you have access to within your organization. -Here, you can also create a new project. - -![organization overview](../../images/organization-overview.png) - -### Members - -The **Members** page lets you add or remove external members to your organization. -Note that you can configure your organization in Infisical to have members authenticate with the platform via protocols like SAML 2.0 and OpenID Connect. - -![organization members](../../images/organization/platform/organization-members.png) - -## Managing your Projects - -As mentioned before, projects house secrets which are further organized into environments such as development, testing and production. -A project can be anything from a single application to a collection of micro-services that you wish to manage secrets for. - -### Secrets Overview - -The **Secrets Overview** screen provides a bird's-eye view of all the secrets in a project and is useful for comparing secrets and identifying missing ones across environments. - -![dashboard secrets overview](../../images/dashboard-secrets-overview.png) - -In the above image, you can already see that: - -- `STRIPE_API_KEY` is missing from the **Staging** environment. -- `JWT_SECRET` is missing from the **Production** environment. -- `BAR` is `EMPTY` in the **Production** environment. - -### Dashboard - -The secrets dashboard lets you manage secrets for a specific environment in a project. -Here, developers can override secrets, version secrets, rollback projects to any point in time and much more. - -![dashboard](../../images/dashboard.png) - -### Integrations - -The integrations page provides native integrations to sync secrets from a project environment to a [host of ever-expanding integrations](/integrations/overview). - -![integrations](../../images/integrations.png) - -### Members - -The members page lets you add/remove members to/from a project and provision them access to environments via roles. By default, Infisical provides the `admin`, `developer`, and `viewer` roles -which you can assign to members. - -![project members](../../images/project-members.png) - -That's it for the platform quickstart! — We encourage you to continue exploring the documentation to gain a deeper understanding of the extensive features and functionalities that Infisical has to offer. - -Next, head back to [Getting Started > Introduction](/documentation/getting-started/overview) to explore ways to fetch secrets from Infisical to your apps and infrastructure. diff --git a/docs/documentation/guides/nextjs-vercel.mdx b/docs/documentation/guides/nextjs-vercel.mdx index f4dd9ceca..ecefb0f2e 100644 --- a/docs/documentation/guides/nextjs-vercel.mdx +++ b/docs/documentation/guides/nextjs-vercel.mdx @@ -199,7 +199,7 @@ Next, navigate to your project's integrations tab in Infisical and press on the Opting in for the Infisical-Vercel integration will break end-to-end encryption since Infisical will be able to read your secrets. This is, however, necessary for Infisical to sync the secrets to Vercel. - Your secrets remain encrypted at rest following our [security guide mechanics](/security/mechanics). + Your secrets remain encrypted at rest following our [security guide mechanics](/internals/security). Now select **Production** for (the source) **Environment** and sync it to the **Production Environment** of the (target) application in Vercel. @@ -238,7 +238,7 @@ At this stage, you know how to use the Infisical-Vercel integration to sync prod Yes. Your secrets are still encrypted at rest. To note, most secret managers actually don't support end-to-end encryption. - Check out the [security guide](/security/overview). + Check out the [security guide](/internals/security). diff --git a/docs/documentation/guides/organization-structure.mdx b/docs/documentation/guides/organization-structure.mdx index 752c06ccc..8693c8c1c 100644 --- a/docs/documentation/guides/organization-structure.mdx +++ b/docs/documentation/guides/organization-structure.mdx @@ -75,7 +75,7 @@ Infisical’s access control framework is unified for both human users and machi ### 7.3 Attribute-Based Access Control (ABAC) -[Attribute-based Access Controls](/documentation/platform/access-controls/attribute-based-access-controls) allow restrictions based on tags or attributes linked to secrets. These can be integrated with SAML assertions and other security frameworks for dynamic access management. +[Attribute-based Access Controls](/documentation/platform/access-controls/abac/overview) allow restrictions based on tags or attributes linked to secrets. These can be integrated with SAML assertions and other security frameworks for dynamic access management. ### 7.4 User Groups diff --git a/docs/documentation/platform/groups.mdx b/docs/documentation/platform/groups.mdx index 18bfe6fa5..df4f11436 100644 --- a/docs/documentation/platform/groups.mdx +++ b/docs/documentation/platform/groups.mdx @@ -31,7 +31,7 @@ In the following steps, we explore how to create and use user groups to provisio ![groups org](/images/platform/groups/groups-org.png) - When creating a group, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating a group, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![groups org create](/images/platform/groups/groups-org-create.png) diff --git a/docs/documentation/platform/identities/alicloud-auth.mdx b/docs/documentation/platform/identities/alicloud-auth.mdx index 2f54ef71b..059adc32d 100644 --- a/docs/documentation/platform/identities/alicloud-auth.mdx +++ b/docs/documentation/platform/identities/alicloud-auth.mdx @@ -88,7 +88,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index ab2b5cd3a..1d277a0c0 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -66,7 +66,7 @@ access the Infisical API using the AWS Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx index 7a7c112ef..dd73f1783 100644 --- a/docs/documentation/platform/identities/azure-auth.mdx +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -66,7 +66,7 @@ access the Infisical API using the Azure Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/gcp-auth.mdx b/docs/documentation/platform/identities/gcp-auth.mdx index 8d6a1f177..cdc4a86a1 100644 --- a/docs/documentation/platform/identities/gcp-auth.mdx +++ b/docs/documentation/platform/identities/gcp-auth.mdx @@ -72,7 +72,7 @@ access the Infisical API using the GCP ID Token authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -241,7 +241,7 @@ access the Infisical API using the GCP IAM authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/jwt-auth.mdx b/docs/documentation/platform/identities/jwt-auth.mdx index 339138881..29a37a5d4 100644 --- a/docs/documentation/platform/identities/jwt-auth.mdx +++ b/docs/documentation/platform/identities/jwt-auth.mdx @@ -61,7 +61,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index e357ba75e..f9412b92b 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -218,7 +218,7 @@ In the following steps, we explore how to create and use identities for your app ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oci-auth.mdx b/docs/documentation/platform/identities/oci-auth.mdx index ef5fafa4c..e07917c72 100644 --- a/docs/documentation/platform/identities/oci-auth.mdx +++ b/docs/documentation/platform/identities/oci-auth.mdx @@ -102,7 +102,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/azure.mdx b/docs/documentation/platform/identities/oidc-auth/azure.mdx index a9f244794..c4dd44152 100644 --- a/docs/documentation/platform/identities/oidc-auth/azure.mdx +++ b/docs/documentation/platform/identities/oidc-auth/azure.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/circleci.mdx b/docs/documentation/platform/identities/oidc-auth/circleci.mdx index bb5999f55..09616a5b4 100644 --- a/docs/documentation/platform/identities/oidc-auth/circleci.mdx +++ b/docs/documentation/platform/identities/oidc-auth/circleci.mdx @@ -56,7 +56,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/general.mdx b/docs/documentation/platform/identities/oidc-auth/general.mdx index f847f51fe..26f291734 100644 --- a/docs/documentation/platform/identities/oidc-auth/general.mdx +++ b/docs/documentation/platform/identities/oidc-auth/general.mdx @@ -60,7 +60,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/github.mdx b/docs/documentation/platform/identities/oidc-auth/github.mdx index 567f38d05..d7c2da280 100644 --- a/docs/documentation/platform/identities/oidc-auth/github.mdx +++ b/docs/documentation/platform/identities/oidc-auth/github.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx index b52d2f894..c3aeb9dac 100644 --- a/docs/documentation/platform/identities/oidc-auth/gitlab.mdx +++ b/docs/documentation/platform/identities/oidc-auth/gitlab.mdx @@ -59,7 +59,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/oidc-auth/spire.mdx b/docs/documentation/platform/identities/oidc-auth/spire.mdx index b402a1d10..6fb387391 100644 --- a/docs/documentation/platform/identities/oidc-auth/spire.mdx +++ b/docs/documentation/platform/identities/oidc-auth/spire.mdx @@ -94,7 +94,7 @@ In the following steps, we explore how to create and use identities to access th ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/tls-cert-auth.mdx b/docs/documentation/platform/identities/tls-cert-auth.mdx index 0ecb60b99..f52ad3c51 100644 --- a/docs/documentation/platform/identities/tls-cert-auth.mdx +++ b/docs/documentation/platform/identities/tls-cert-auth.mdx @@ -68,7 +68,7 @@ To create an identity, head to your Organization Settings > Access Control > [Id ![identities organization](/images/platform/identities/identities-org.png) -When creating an identity, you specify an organization-level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). +When creating an identity, you specify an organization-level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > [Organization Roles](https://app.infisical.com/organization/access-management?selectedTab=roles). ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/token-auth.mdx b/docs/documentation/platform/identities/token-auth.mdx index f31e86517..c9a06d110 100644 --- a/docs/documentation/platform/identities/token-auth.mdx +++ b/docs/documentation/platform/identities/token-auth.mdx @@ -42,7 +42,7 @@ using the Token Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/universal-auth.mdx b/docs/documentation/platform/identities/universal-auth.mdx index 3a87f6da9..18b847a40 100644 --- a/docs/documentation/platform/identities/universal-auth.mdx +++ b/docs/documentation/platform/identities/universal-auth.mdx @@ -47,7 +47,7 @@ using the Universal Auth authentication method. ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index 31e4bf242..0ee4614a4 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -7,7 +7,7 @@ description: "Read more about the concept of user identities in Infisical." A **user identity** (also known as **user**) represents a developer, admin, or any other human entity interacting with resources in Infisical. -Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). +Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../project). Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. diff --git a/docs/documentation/platform/kms/overview.mdx b/docs/documentation/platform/kms/overview.mdx index 577373ab8..bf2cf69f0 100644 --- a/docs/documentation/platform/kms/overview.mdx +++ b/docs/documentation/platform/kms/overview.mdx @@ -10,7 +10,7 @@ Infisical can be used as a Key Management System (KMS), referred to as Infisical By default your Infisical data such as projects and the data within them are encrypted at rest using Infisical's own KMS. This ensures that your data is secure and protected from unauthorized access. -If you are on-premise, your KMS root key will be created at random with the `ROOT_ENCRYPTION_KEY` environment variable. You can also use a Hardware Security Module (HSM), to create the root key. Read more about [HSM](/docs/documentation/platform/kms/encryption-strategies). +If you are on-premise, your KMS root key will be created at random with the `ROOT_ENCRYPTION_KEY` environment variable. You can also use a Hardware Security Module (HSM), to create the root key. Read more about [HSM](/documentation/platform/kms/hsm-integration). Keys managed in KMS are not extractable from the platform. Additionally, data @@ -109,7 +109,7 @@ In the following steps, we explore how to generate a key and use it to encrypt d To encrypt data, make an API request to the [Encrypt - Data](/api-reference/endpoints/kms/keys/encrypt) API endpoint, + Data](/api-reference/endpoints/kms/encryption/encrypt) API endpoint, specifying the key to use. @@ -168,7 +168,7 @@ In the following steps, we explore how to use decrypt data using an existing key To decrypt data, make an API request to the [Decrypt - Data](/api-reference/endpoints/kms/keys/decrypt) API endpoint, + Data](/api-reference/endpoints/kms/encryption/decrypt) API endpoint, specifying the key to use. ### Sample request diff --git a/docs/documentation/platform/pam/session-recording.mdx b/docs/documentation/platform/pam/session-recording.mdx index 7e560d5c2..e9061430c 100644 --- a/docs/documentation/platform/pam/session-recording.mdx +++ b/docs/documentation/platform/pam/session-recording.mdx @@ -21,7 +21,7 @@ The content captured during a session depends on the type of resource being acce For database connections, Infisical captures all queries executed and their corresponding responses. -Support for additional resource types like SSH and RDP is coming soon. +Support for additional resource types like SSH, RDP, Kubernetes, and MCP is coming soon. ## Viewing Recordings diff --git a/docs/documentation/platform/pki/ca/acme-ca.mdx b/docs/documentation/platform/pki/ca/acme-ca.mdx index bcdd4f4a9..76f5cdc8f 100644 --- a/docs/documentation/platform/pki/ca/acme-ca.mdx +++ b/docs/documentation/platform/pki/ca/acme-ca.mdx @@ -255,7 +255,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte The issued certificate and private key are now available through Infisical and can be: - Downloaded directly from the Infisical UI - - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) + - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx index f1c434e9c..de8de4541 100644 --- a/docs/documentation/platform/pki/certificates.mdx +++ b/docs/documentation/platform/pki/certificates.mdx @@ -109,49 +109,126 @@ In the following steps, we explore how to issue a X.509 certificate under a CA. With certificate templates, you can specify, for example, that issued certificates must have a common name (CN) adhering to a specific format like .*.acme.com or perhaps that the max TTL cannot be more than 1 year. - To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates/create) API endpoint, specifying the issuing CA. + To create a certificate template, make an API request to the [Create Certificate Template](/api-reference/endpoints/certificate-templates-v2/create) API endpoint, specifying the issuing CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificate-templates' \ + curl --request POST \ + --url https://us.infisical.com/api/v2/certificate-templates \ --header 'Content-Type: application/json' \ - --data-raw '{ - "caId": "", - "name": "My Certificate Template", - "commonName": ".*.acme.com", - "subjectAlternativeName": ".*.acme.com", - "ttl": "1y", - }' + --data '{ + "projectId": "", + "name": "", + "description": "", + "subject": [ + { + "type": "common_name", + "allowed": [ + "*.infisical.com" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*.sample.com" + ] + } + ], + "keyUsages": { + "allowed": [ + "digital_signature" + ] + }, + "extendedKeyUsages": { + "allowed": [ + "client_auth" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA" + ], + "keyAlgorithm": [ + "RSA-2048" + ] + }, + "validity": { + "max": "365d" + } + }' ``` ### Sample response ```bash Response { - id: "...", - caId: "...", - name: "...", - commonName: "...", - subjectAlternativeName: "...", - ttl: "...", + "certificateTemplate": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "", + "description": "", + "subject": [ + { + "type": "common_name", + "allowed": [ + "*.infisical.com" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*.sample.com" + ] + } + ], + "keyUsages": { + "allowed": [ + "digital_signature" + ] + }, + "extendedKeyUsages": { + "allowed": [ + "client_auth" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA" + ], + "keyAlgorithm": [ + "RSA-2048" + ] + }, + "validity": { + "max": "365d" + }, + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z" + } } ``` - To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-cert) API endpoint, + To create a certificate under the certificate template, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint, specifying the issuing CA. ### Sample request ```bash Request - curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates/issue-certificate' \ + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ --header 'Content-Type: application/json' \ --data-raw '{ - "certificateTemplateId": "", + "profileId": "", "commonName": "service.acme.com", "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048" }' ``` @@ -221,16 +298,16 @@ In the following steps, we explore how to revoke a X.509 certificate under a CA selecting the **Revoke Certificate** option for it and specifying the reason for revocation. - ![pki revoke certificate](/images/platform/pki/cert-revoke.png) + ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) - ![pki revoke certificate modal](/images/platform/pki/cert-revoke-modal.png) + ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) In order to check the revocation status of a certificate, you can check it against the CRL of a CA by heading to its Issuing CA and downloading the CRL. - ![pki view crl](/images/platform/pki/ca-crl.png) + ![pki view crl](/images/platform/pki/ca/ca-crl.png) To verify a certificate against the downloaded CRL with OpenSSL, you can use the following command: @@ -254,7 +331,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem - Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, specifying the serial number of the certificate and the reason for revocation. ### Sample request @@ -280,7 +357,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crls) API endpoint. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. ### Sample request diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index b30a53091..05703beee 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -60,6 +60,107 @@ The following examples demonstrate different approaches to certificate renewal: - Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets. - Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination. +## Guide to Exporting Certificates + +In the following steps, we explore how to export certificates from Infisical in different formats for use in your applications and infrastructure. + +### Accessing the Export Certificate Modal + +To export any certificate, first navigate to your project's certificate inventory and locate the certificate you want to export. Click on the **Export Certificate** option from the certificate's action menu. + +![pki export certificate option](/images/platform/pki/certificate/cert-export-option.png) + + + + + + In the export modal, choose **PEM** as the format and click **Export**. + + ![pki export certificate pem](/images/platform/pki/certificate/cert-export-pem.png) + + The PEM export modal will display the certificate details including: + - **Serial Number**: The unique identifier for the certificate + - **Certificate Body**: The X.509 certificate in PEM format + - **Certificate Chain**: The intermediate and root CA certificates + - **Private Key**: The private key associated with the certificate (if available) + + ![pki export certificate pem modal](/images/platform/pki/certificate/cert-export-pem-modal.png) + + You can copy each component individually or use the **Copy All** button to copy the complete certificate bundle. + + + PEM format certificates can be used directly with most web servers and applications: + + - **Apache HTTP Server**: Configure SSL certificates in your virtual host + - **Nginx**: Use the certificate and private key files in your server configuration + - **Docker containers**: Mount certificate files for TLS-enabled applications + - **Load balancers**: Upload PEM certificates to AWS ALB, Azure Application Gateway, etc. + + Example Nginx configuration: + ```nginx + server { + listen 443 ssl; + server_name example.com; + + ssl_certificate /path/to/certificate.pem; + ssl_certificate_key /path/to/private-key.pem; + } + ``` + + + + + + + In the export modal, choose **PKCS12** as the format and provide the required configuration: + + ![pki export certificate pkcs12](/images/platform/pki/certificate/cert-export-pkcs12.png) + + - **Password**: A secure password to protect the PKCS12 keystore + - **Alias**: A friendly name for the certificate within the keystore + + Click **Export** to generate and download the `.p12` file containing the certificate, certificate chain, and private key. + + + PKCS12 files (`.p12` extension) are binary keystore files that contain the certificate, certificate chain, and private key in a single encrypted file: + + - **Java applications**: Import directly into Java KeyStore (JKS) or use with SSL/TLS + - **Windows IIS**: Import the PKCS12 file for web server SSL configuration + - **Browser certificates**: Install client certificates for authentication + - **Mobile applications**: Deploy certificates to iOS and Android applications + + To verify the contents of a PKCS12 file: + ```bash + openssl pkcs12 -in certificate.p12 -nokeys -clcerts + ``` + + To extract the private key: + ```bash + openssl pkcs12 -in certificate.p12 -nocerts -out private-key.pem + ``` + + + If you need to convert the PKCS12 file to Java KeyStore (JKS) format for applications running on Java 8 or earlier, use the following keytool command: + + ```bash + keytool -importkeystore \ + -srckeystore certificate.p12 \ + -srcstoretype PKCS12 \ + -srcstorepass \ + -destkeystore certificate.jks \ + -deststoretype JKS \ + -deststorepass + ``` + + Replace `` with the password you used when exporting the PKCS12 file, and `` with your desired JKS keystore password. + + The resulting `.jks` file can then be used with Java applications that require JKS format keystores. + + + + + + ## Guide to Revoking Certificates In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA. @@ -105,7 +206,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem - Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificates/revoke) API endpoint, specifying the serial number of the certificate and the reason for revocation. ### Sample request @@ -131,7 +232,7 @@ openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. - To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crls) API endpoint. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crl) API endpoint. ### Sample request diff --git a/docs/documentation/platform/pki/certificates/overview.mdx b/docs/documentation/platform/pki/certificates/overview.mdx index a4688388a..ea559a0df 100644 --- a/docs/documentation/platform/pki/certificates/overview.mdx +++ b/docs/documentation/platform/pki/certificates/overview.mdx @@ -12,4 +12,4 @@ There are three components to understand: - [Certificate Template](/documentation/platform/pki/certificates/templates): A policy structure specifying the permitted attributes for requested certificates including subject naming conventions, SAN fields, key usages, and extended key usages. -- [Certificate](/documentation/platform/pki/certificates/certificate): The actual X.509 certificate issued for a profile. Once issued, a certificate kept track of in the certificate inventory. +- [Certificate](/documentation/platform/pki/certificates/certificates): The actual X.509 certificate issued for a profile. Once issued, a certificate kept track of in the certificate inventory. diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx index dac7b6386..4adcdc01b 100644 --- a/docs/documentation/platform/pki/enrollment-methods/api.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -56,7 +56,7 @@ Here, select the certificate profile from step 1 that will be used to issue the - To create a certificate [profile](/documentation/platform/pki/certificates/profiles), make an API request to the [Create Certificate Profile](/docs/api-reference/endpoints/certificate-profiles/create) API endpoint. + To create a certificate [profile](/documentation/platform/pki/certificates/profiles), make an API request to the [Create Certificate Profile](/api-reference/endpoints/certificate-profiles/create) API endpoint. ### Sample request diff --git a/docs/documentation/platform/pki/est.mdx b/docs/documentation/platform/pki/est.mdx deleted file mode 100644 index ecbd98dd9..000000000 --- a/docs/documentation/platform/pki/est.mdx +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "Enrollment over Secure Transport (EST)" -sidebarTitle: "Enrollment over Secure Transport (EST)" -description: "Learn how to manage certificate enrollment of clients using EST" ---- - -## Concept - -Enrollment over Secure Transport (EST) is a protocol used to automate the secure provisioning of digital certificates for devices and applications over a secure HTTPS connection. It is primarily used when a client device needs to obtain or renew a certificate from a Certificate Authority (CA) on Infisical in a secure and standardized manner. EST is commonly employed in environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services. - -Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints: - -- **cacerts** - provides the necessary CA chain for the client to validate certificates issued by the CA. -- **simpleenroll** - allows an EST client to request a new certificate from Infisical's EST server -- **simplereenroll** - similar to the /simpleenroll endpoint but is used for renewing an existing certificate. - -These endpoints are exposed on port 8443 under the .well-known/est path e.g. -`https://app.infisical.com:8443/.well-known/est/estLabel/cacerts` - -## Prerequisites - -- You need to have an existing [CA hierarchy](/documentation/platform/pki/private-ca). -- The client devices need to have a bootstrap/pre-installed certificate. -- The client devices must trust the server certificates used by Infisical's EST server. If the devices are new or lack existing trust configurations, you need to manually establish trust for the appropriate certificates. - - For Infisical Cloud users, the devices must be configured to trust the [Amazon root CA certificates](https://www.amazontrust.com/repository). - -## Guide to configuring EST - -1. Set up a certificate template with your selected issuing CA. This template will define the policies and parameters for certificates issued through EST. For detailed instructions on configuring a certificate template, refer to the certificate templates [documentation](/documentation/platform/pki/certificates#guide-to-issuing-certificates). - -2. Proceed to the certificate template's enrollment settings - ![est enrollment dashboard](/images/platform/pki/est/template-enroll-hover.png) - -3. Select **EST** as the client enrollment method and fill up the remaining fields. - - ![est enrollment modal create](/images/platform/pki/est/template-enrollment-modal.png) - - - **Disable Bootstrap Certificate Validation** - Enable this if your devices are not configured with a bootstrap certificate. - - **Certificate Authority Chain** - This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. - - **Passphrase** - This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. - - For security reasons, Infisical authenticates EST clients using both client certificate and passphrase. - -4. Once the configuration of enrollment options is completed, a new **EST Label** field appears in the enrollment settings. This is the value to use as label in the URL when configuring the connection of EST clients to Infisical. - ![est enrollment modal create](/images/platform/pki/est/template-enrollment-est-label.png) - - The complete URL of the supported EST endpoints will look like the following: - - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/cacerts - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simpleenroll - - https://app.infisical.com:8443/.well-known/est/f110f308-9888-40ab-b228-237b12de8b96/simplereenroll - -## Setting up EST clients - -- To use the EST passphrase in your clients, configure it as the EST password. The EST username can be set to any arbitrary value. -- Use the appropriate client certificates for invoking the EST endpoints. - - For `simpleenroll`, use the bootstrapped/manufacturer client certificate. - - For `simplereenroll`, use a valid EST-issued client certificate. -- When configuring the PKCS#12 objects for the client certificates, only include the leaf certificate and the private key. diff --git a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx new file mode 100644 index 000000000..78f0301e1 --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx @@ -0,0 +1,185 @@ +--- +title: "Apache Server" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Apache Server with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache HTTP Server](https://httpd.apache.org/). + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Apache benefits from excellent Certbot integration, allowing both certificate-only mode and automatic SSL configuration. + +## Prerequisites + +Before you begin, make sure you have: + +- An [Apache HTTP Server](https://httpd.apache.org/) running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Apache server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot with the Apache plugin on the server where Apache is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version and proper Apache plugin integration. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --apache \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your Apache configuration files; this mode is recommended if you prefer to manage your Apache SSL configuration manually or have a complex setup. + - `--apache`: Specifies the Apache plugin so Certbot can solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by creating temporary files served by Apache. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + If `--certonly` is used: Certbot does **not** modify your Apache configuration, so you must manually update your Apache virtual host to reference the new certificate files and reload the server to apply the changes. + + Here's an example SSL virtual host configuration for Apache: + + ```apache + + ServerName example.infisical.com + DocumentRoot /var/www/html + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/example.infisical.com/cert.pem + SSLCertificateKeyFile /etc/letsencrypt/live/example.infisical.com/privkey.pem + SSLCertificateChainFile /etc/letsencrypt/live/example.infisical.com/chain.pem + + # Your existing configuration... + + ``` + + After updating the virtual host configuration, test and reload Apache to apply the changes: + + ```bash + sudo apache2ctl configtest + sudo systemctl reload apache2 + ``` + + If `--certonly` was **not** used: Certbot uses installer mode, which attempts to automatically configure HTTPS by updating your Apache virtual host configuration and reloading the server if needed. + + At this point, your Apache server should be successfully serving HTTPS using the certificate issued by Infisical. + + + + After configuring Apache SSL, verify that your certificate was issued correctly and Apache is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Certbot automatically installs a `systemd` timer during installation. This timer runs twice per day and checks whether any certificates are due for renewal. Because Certbot stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configuration—no additional settings are required. + + Note that Certbot automatically renews certificates when they are within 30 days of expiration; renewal settings can be adjusted in `/etc/letsencrypt/renewal/{domain-name}.conf`. + + ```ini + # ... your existing configuration ... + + renew_before_expiry = 30 days + ``` + + To test the renewal process, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, automatic renewal will work as expected. + + To trigger an actual renewal immediately, run the following command: + + ```bash + sudo certbot renew --force-renewal + ``` + + Note that after a certificate is renewed, Apache must be reloaded so it can begin using the new certificate. To do this, run the following command: + + ```bash + sudo systemctl reload apache2 + ``` + + To automate the process of renewing a certificate and reloading Apache, you can create a simple deploy hook that Certbot will run after every successful renewal. + + Inside `/etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh`, add the following: + + ```bash + #!/bin/sh + systemctl reload apache2 + ``` + + Then make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-apache.sh + ``` + + Alternatively, you can use the `--post-hook` option when manually renewing: + + ```bash + sudo certbot renew --post-hook "systemctl reload apache2" + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the Apache reload automatically. Apache has native Certbot plugin integration, so no additional configuration is typically needed. + + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx new file mode 100644 index 000000000..c0e1c896b --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx @@ -0,0 +1,226 @@ +--- +title: "JBoss/WildFly" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on JBoss/WildFly with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). JBoss/WildFly requires certificates in Java keystore format, which this guide addresses through the certificate conversion process. + +## Prerequisites + +Before you begin, make sure you have: + +- A [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your JBoss/WildFly server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. +- [Java Development Kit (JDK)](https://openjdk.org/) installed for keystore management tools. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where JBoss/WildFly is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version of Certbot. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Since JBoss/WildFly doesn't have a native Certbot plugin, use the standalone authenticator to obtain certificates. **Important**: You must stop JBoss/WildFly before running this command as Certbot needs to bind to port 80 for the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + + Stop your JBoss/WildFly server: + + ```bash + sudo systemctl stop wildfly + # or for older JBoss versions + # sudo systemctl stop jboss + ``` + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --standalone \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your JBoss/WildFly configuration; this mode is recommended because JBoss/WildFly requires certificates in Java keystore format rather than the PEM format that Certbot provides. + - `--standalone`: Uses Certbot's standalone authenticator to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by starting a temporary web server on port 80. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + Because JBoss/WildFly requires certificates in Java keystore format, you'll need to convert the PEM certificates provided by Certbot in the next step. + + + + JBoss/WildFly requires certificates in Java keystore format rather than the PEM format provided by Certbot. Convert the PEM certificates to PKCS#12 format, which is supported by modern JBoss/WildFly versions. + + Create a PKCS#12 keystore from the PEM files: + + ```bash + sudo openssl pkcs12 -export \ + -out /opt/wildfly/standalone/configuration/keystore.p12 \ + -inkey /etc/letsencrypt/live/example.infisical.com/privkey.pem \ + -in /etc/letsencrypt/live/example.infisical.com/cert.pem \ + -certfile /etc/letsencrypt/live/example.infisical.com/chain.pem \ + -passout pass:changeit + ``` + + Set appropriate file permissions for security: + + ```bash + sudo chown wildfly:wildfly /opt/wildfly/standalone/configuration/keystore.p12 + sudo chmod 600 /opt/wildfly/standalone/configuration/keystore.p12 + ``` + + You will need to configure JBoss/WildFly to use the new keystore. This process varies depending on your JBoss/WildFly version and security configuration (legacy security realms vs. Elytron subsystem). Refer to your [JBoss](https://access.redhat.com/documentation/en-us/red_hat_jboss_enterprise_application_platform)/[WildFly](https://docs.wildfly.org/) administration guide for specific SSL/TLS configuration steps. + + + Replace `changeit` with a strong password and adjust the WildFly installation path based on your environment. Modern WildFly versions support PKCS#12 keystores directly, while older versions may require conversion to JKS format using the [keytool](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/keytool.html) utility. + + + + + After configuring JBoss/WildFly SSL, verify that your certificate was issued correctly and the keystore was created properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + Verify the PKCS#12 keystore was created: + + ```bash + sudo ls -la /opt/wildfly/standalone/configuration/keystore.p12 + ``` + + Test the keystore contents (optional): + + ```bash + sudo keytool -list -storetype PKCS12 -keystore /opt/wildfly/standalone/configuration/keystore.p12 -storepass changeit + ``` + + Once you've configured JBoss/WildFly to use the keystore and restarted the service, you can verify HTTPS is working by accessing your application over SSL. + + + + Unlike standard web servers, JBoss/WildFly certificate renewal requires additional steps because certificates must be converted to Java keystore format and the application server must be restarted to use the new certificates. + + To test the renewal process without affecting your live certificates, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, the renewal mechanism itself will work as expected. + + For actual renewal, since JBoss/WildFly requires the standalone authenticator, you'll need to stop the server, perform the renewal, convert the certificate, and restart: + + ```bash + # Stop JBoss/WildFly + sudo systemctl stop wildfly + + # Renew the certificate + sudo certbot renew --quiet + + # Convert to keystore format + sudo openssl pkcs12 -export \ + -out /opt/wildfly/standalone/configuration/keystore.p12 \ + -inkey /etc/letsencrypt/live/example.infisical.com/privkey.pem \ + -in /etc/letsencrypt/live/example.infisical.com/cert.pem \ + -certfile /etc/letsencrypt/live/example.infisical.com/chain.pem \ + -passout pass:changeit + + # Set permissions + sudo chown wildfly:wildfly /opt/wildfly/standalone/configuration/keystore.p12 + sudo chmod 600 /opt/wildfly/standalone/configuration/keystore.p12 + + # Start JBoss/WildFly + sudo systemctl start wildfly + ``` + + To automate this process, you can create a renewal script. Create `/etc/letsencrypt/renewal-hooks/deploy/jboss-renewal.sh`: + + ```bash + #!/bin/bash + # JBoss/WildFly certificate renewal hook + + DOMAIN="example.infisical.com" + KEYSTORE_PATH="/opt/wildfly/standalone/configuration/keystore.p12" + KEYSTORE_PASSWORD="changeit" + + # Convert certificate to keystore format + openssl pkcs12 -export \ + -out "$KEYSTORE_PATH" \ + -inkey "/etc/letsencrypt/live/$DOMAIN/privkey.pem" \ + -in "/etc/letsencrypt/live/$DOMAIN/cert.pem" \ + -certfile "/etc/letsencrypt/live/$DOMAIN/chain.pem" \ + -passout "pass:$KEYSTORE_PASSWORD" + + # Set permissions + chown wildfly:wildfly "$KEYSTORE_PATH" + chmod 600 "$KEYSTORE_PATH" + + # Restart WildFly to load new certificate + systemctl restart wildfly + ``` + + Make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/jboss-renewal.sh + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the keystore conversion and service restart automatically. Because JBoss/WildFly requires the standalone authenticator (which stops the service temporarily), plan for brief service interruptions during renewal. + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx new file mode 100644 index 000000000..f28e5ee09 --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx @@ -0,0 +1,175 @@ +--- +title: "Nginx" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Nginx with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Nginx](https://nginx.org/) server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). + +## Prerequisites + +Before you begin, make sure you have: + +- An [Nginx](https://nginx.org/) web server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Nginx server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where Nginx is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version and proper Nginx plugin integration. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --nginx \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying and reloading your Nginx configuration file(s); this mode is recommended if you prefer to manage your Nginx TLS configuration manually, use automation tools, or integrate certificates into an existing deployment workflow. + - `--nginx`: Specifies the Nginx plugin so Certbot can solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by creating temporary files served by Nginx. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server’s Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + If `--certonly` is used: Certbot does **not** modify your Nginx configuration, so you must manually update your Nginx server block to reference the new certificate files and reload the server to apply the changes. + + Here's how you can configure your server block: + + ```nginx + server { + listen 443 ssl; + server_name example.infisical.com; + + ssl_certificate /etc/letsencrypt/live/example.infisical.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.infisical.com/privkey.pem; + + # ...your existing configuration... + } + + ``` + + After updating the server block, you should test and reload Nginx to apply the changes: + + ```bash + sudo nginx -t + sudo systemctl reload nginx + ``` + + If `--certonly` was **not** used: Certbot uses Nginx installer mode, which attempts to automatically configure HTTPS by updating your Nginx server block and reloading the server if needed. + + At this point, your Nginx server should be successfully serving HTTPS using the certificate issued by Infisical. + + + + After configuring Nginx SSL, verify that your certificate was issued correctly and Nginx is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Certbot automatically installs a `systemd` timer during installation. This timer runs twice per day and checks whether any certificates are due for renewal. Because Certbot stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configuration—no additional settings are required. + + Note that Certbot automatically renews certificates when they are within 30 days of expiration; renewal settings can be adjusted in `/etc/letsencrypt/renewal/{domain-name}.conf`. + + ```ini + # ... your existing configuration ... + + renew_before_expiry = 30 days + ``` + + To test the renewal process, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, automatic renewal will work as expected. + + To trigger an actual renewal immediately, run the following command: + + ```bash + sudo certbot renew --force-renewal + ``` + + Note that after a certificate is renewed, Nginx must be reloaded so it can begin using the new certificate. To do this, run the following command: + + ```bash + systemctl reload nginx + ``` + + To automate the process of renewing a certificate and reloading Nginx, you can create a simple deploy hook that Certbot will run after every successful renewal. + + Inside `/etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh`, add the following: + + ```bash + #!/bin/sh + systemctl reload nginx + ``` + + Then make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + ``` + + + + diff --git a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx new file mode 100644 index 000000000..ffb07bf1b --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx @@ -0,0 +1,251 @@ +--- +title: "Tomcat" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Tomcat with Certbot" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache Tomcat](https://tomcat.apache.org/) application server. + +It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Unlike web servers with native Certbot plugins, Tomcat requires certificates to be manually configured after issuance. + +## Prerequisites + +Before you begin, make sure you have: + +- An [Apache Tomcat](https://tomcat.apache.org/) application server running on a Linux system with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Tomcat server to Infisical. +- Port 80 open and reachable for ACME [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) validation. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that Certbot will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install Certbot on the server where Tomcat is running by following the official Certbot [installation guide](https://certbot.eff.org/instructions). + + The installation guide provides up-to-date instructions for various Linux distributions and package managers, ensuring you get the most current version of Certbot. + + After installation, you can verify that Certbot has been installed correctly by running: + + ```bash + certbot --version + ``` + + + + Since Tomcat doesn't have a native Certbot plugin, use the standalone authenticator to obtain certificates. **Important**: You must stop Tomcat before running this command as Certbot needs to bind to port 80 for the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + + Stop your Tomcat server: + + ```bash + sudo systemctl stop tomcat + ``` + + Run the following command to request a certificate from Infisical: + + ```bash + sudo certbot certonly \ + --standalone \ + --server "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" \ + --eab-kid "your-eab-key-identifier" \ + --eab-hmac-key "your-eab-secret" \ + -d example.infisical.com \ + --email admin@example.com \ + --agree-tos \ + --non-interactive + ``` + + For guidance on each parameter: + + - `certonly`: Instructs Certbot to request a certificate without modifying your Tomcat configuration; this mode is recommended because Tomcat requires manual SSL connector configuration in its server.xml file. + - `--standalone`: Uses Certbot's standalone authenticator to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge by starting a temporary web server on port 80. + - `--server`: The Infisical ACME directory URL from Step 1. This instructs Certbot to communicate with Infisical's ACME server instead of Let's Encrypt. + - `--eab-kid`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-hmac-key`: The EAB secret associated with the KID from Step 1. + - `-d`: Specifies the domain name for which the certificate is being requested. + - `--email`: The contact email for expiration notices and account recovery. + - `--agree-tos`: Accepts the ACME server's Terms of Service. + - `--non-interactive`: Runs Certbot without prompting for user input (recommended for automation). + + The Certbot command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Certbot stores the private key and resulting leaf certificate and full certificate chain in `/etc/letsencrypt/live/{domain-name}/`. + + Because Tomcat requires manual SSL configuration, you'll need to configure the SSL connector in your Tomcat server.xml file to reference these certificate files. You can restart Tomcat after the certificate is issued, but SSL won't be enabled until you complete the server configuration. + + ```bash + sudo systemctl start tomcat + ``` + + + + To enable SSL/TLS in Tomcat, you need to configure an SSL connector in the server.xml configuration file. Tomcat can use the PEM certificates directly without conversion to Java keystore format (available in Tomcat 8.5+ with the NIO or NIO2 connector). + + Edit your Tomcat server.xml file (typically located at `/opt/tomcat/conf/server.xml` or `/usr/share/tomcat/conf/server.xml`): + + ```xml + + + + + + ``` + + Restart Tomcat to apply the SSL configuration: + + ```bash + sudo systemctl restart tomcat + ``` + + You can verify SSL is working by accessing your Tomcat application at `https://example.infisical.com:8443`. For production deployments, consider configuring a reverse proxy (like [Apache HTTP Server](https://httpd.apache.org/) or [Nginx](https://nginx.org/)) to handle SSL termination on standard port 443. + + + The certificate paths must be readable by the Tomcat user. You may need to adjust file permissions or copy the certificates to a location accessible by Tomcat. For security, ensure the private key file has restricted permissions (600) and is owned by the Tomcat user. + + + + + After configuring Tomcat SSL, verify that your certificate was issued correctly and Tomcat is serving it properly. + + Check that the certificate files were created by Certbot: + + ```bash + sudo ls -la /etc/letsencrypt/live/example.infisical.com/ + ``` + + You should see files like: + - `cert.pem` (your certificate) + - `chain.pem` (certificate chain) + - `fullchain.pem` (certificate + chain) + - `privkey.pem` (private key) + + + + Unlike web servers with native Certbot plugins, Tomcat certificate renewal requires stopping the server, renewing the certificate, and restarting to load the new certificates. + + To test the renewal process without affecting your live certificates, run the following command: + + ```bash + sudo certbot renew --dry-run + ``` + + This command simulates the full renewal process without modifying your active certificate. If the dry run succeeds, the renewal mechanism will work as expected. + + For actual renewal, since Tomcat requires the standalone authenticator, you'll need to stop the server, perform the renewal, and restart: + + ```bash + # Stop Tomcat + sudo systemctl stop tomcat + + # Renew the certificate + sudo certbot renew --quiet + + # Start Tomcat + sudo systemctl start tomcat + ``` + + **Important considerations for Tomcat renewal:** + + Because Tomcat uses the standalone authenticator, the server must be stopped during renewal. This creates a service interruption that requires manual coordination: + + 1. **Plan maintenance windows** for certificate renewals (typically every 60-90 days) + 2. **Monitor renewal dates** to schedule downtime appropriately + 3. **Consider load balancers** or multiple instances for high availability during renewals + + Create a deploy hook to automate post-renewal tasks. Create `/etc/letsencrypt/renewal-hooks/deploy/tomcat-renewal.sh`: + + ```bash + #!/bin/bash + # Tomcat certificate renewal hook + # This runs AFTER Certbot successfully renews certificates + + DOMAIN="example.infisical.com" + TOMCAT_USER="tomcat" + + # Ensure certificate files are readable by Tomcat + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/cert.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/privkey.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/chain.pem" + chown root:$TOMCAT_USER "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" + + # Set appropriate permissions + chmod 640 "/etc/letsencrypt/live/$DOMAIN/cert.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/privkey.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/chain.pem" + chmod 640 "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" + + # Start Tomcat (it was stopped for renewal) + systemctl start tomcat + + # Wait for startup and verify service is running + sleep 10 + if ! systemctl is-active --quiet tomcat; then + echo "ERROR: Tomcat failed to start after certificate renewal" + exit 1 + fi + + echo "Tomcat certificate renewal completed successfully" + ``` + + Make the hook executable: + + ```bash + sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/tomcat-renewal.sh + ``` + + + Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the certificate permissions and service restart automatically. Because Tomcat requires the standalone authenticator (which stops the service temporarily), plan for brief service interruptions during renewal. + + + + + If you need to manually apply renewed certificates to Tomcat (when the deploy hook isn't used), follow these steps: + + **Step 1: Set certificate file permissions** + + After Certbot renews your certificates, ensure they're readable by Tomcat: + + ```bash + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/cert.pem + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/privkey.pem + sudo chown root:tomcat /etc/letsencrypt/live/example.infisical.com/chain.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/cert.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/privkey.pem + sudo chmod 640 /etc/letsencrypt/live/example.infisical.com/chain.pem + ``` + + **Step 2: Restart Tomcat to load new certificates** + + ```bash + sudo systemctl restart tomcat + ``` + + That's it! Tomcat will automatically use the renewed certificates since your `server.xml` already points to the Let's Encrypt certificate files. + + + Since Tomcat reads certificates from the file system on startup, you only need to restart the service after certificate renewal. The certificate file paths in `/etc/letsencrypt/live/` are symbolic links that automatically point to the latest certificates. + + + \ No newline at end of file diff --git a/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx new file mode 100644 index 000000000..2aab0870d --- /dev/null +++ b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx @@ -0,0 +1,194 @@ +--- +title: "Windows Server" +description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Windows Server with win-acme" +--- + +This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Windows Server](https://www.microsoft.com/en-us/windows-server) environments. + +It uses [win-acme](https://www.win-acme.com/), a feature-rich [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client designed specifically for Windows, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Win-acme offers excellent integration with IIS, Windows Certificate Store, and various certificate storage options. + +## Prerequisites + +Before you begin, make sure you have: + +- A [Windows Server](https://www.microsoft.com/en-us/windows-server) instance running with administrative access. +- A [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) in Infisical. +- Network connectivity from your Windows Server to Infisical. + +## Guide + + + + Navigate to your certificate management project in Infisical and locate your [certificate profile](/documentation/platform/pki/certificates/profiles) configured with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme). + ![Certificate profile with ACME enrollment option](/images/platform/pki/acme/certificate-profile-acme-option.png) + + Click the **Reveal ACME EAB** option to view the ACME configuration details. + + ![ACME configuration modal showing directory URL and EAB credentials](/images/platform/pki/acme/acme-configuration-modal.png) + + From the ACME configuration, gather the following values: + + - ACME Directory URL: The URL that win-acme will use to communicate with Infisical's ACME server. This takes the form `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`. + - EAB Key Identifier (KID): A unique identifier that tells Infisical which ACME account is making the request. + - EAB Secret: A secret key that authenticates your ACME client with Infisical. + + + Keep your EAB credentials secure as they authenticate your ACME client with Infisical PKI. These credentials are unique to each [certificate profile](/documentation/platform/pki/certificates/profiles) and should not be shared. + + + + + Install win-acme on your Windows Server using one of the following methods. + + + 1. Visit the [win-acme releases page](https://github.com/win-acme/win-acme/releases). + 2. Download the latest stable release ZIP file. + 3. Extract the contents to a folder (e.g., `C:\win-acme`). + 4. Open Command Prompt or PowerShell as Administrator. + 5. Navigate to the win-acme folder. + + ```powershell + cd C:\win-acme + ``` + + + If you have [.NET Core](https://dotnet.microsoft.com/en-us/download) installed, you can install win-acme as a global tool: + + ```powershell + dotnet tool install win-acme --global + ``` + + This makes the `wacs` command available system-wide. + + + + + + Run the following win-acme command to request a certificate from Infisical: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pemfiles --pemfilespath "C:\certificates" --verbose + ``` + + For guidance on each parameter: + + - `--target manual`: Specifies manual target configuration for domain specification. + - `--host`: The domain name for which the certificate is being requested. + - `--baseuri`: The Infisical ACME directory URL from Step 1. This instructs win-acme to communicate with Infisical's ACME server instead of other ACME providers. + - `--eab-key-identifier`: Your External Account Binding (EAB) Key Identifier from Step 1. + - `--eab-key`: The EAB secret associated with the KID from Step 1. + - `--validation selfhosting`: Uses self-hosting validation method to solve the [HTTP-01](https://letsencrypt.org/docs/challenge-types/#http-01-challenge) challenge. + - `--store pemfiles`: Stores certificates as PEM files in a specified directory. + - `--pemfilespath`: Directory where certificates will be saved on your Windows Server. + - `--verbose`: Enables detailed logging for troubleshooting and monitoring the certificate request process. + + The win-acme command generates a private key on your server, creates a Certificate Signing Request (CSR) using that key, and sends the CSR to Infisical for certificate issuance. Win-acme stores the private key and resulting leaf certificate and full certificate chain in the specified directory path. + + + Replace the placeholder values with your actual configuration: + - `example.infisical.com`: Your actual domain name + - `https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory`: Your Infisical ACME endpoint from Step 1 + - `your-eab-key-identifier` and `your-eab-secret`: Your External Account Binding credentials from Step 1 + - `C:\certificates`: Your desired certificate storage location + + + + + Win-acme supports various certificate storage options beyond PEM files. Here are common alternatives for different deployment scenarios: + + + + Store certificates directly in the [Windows Certificate Store](https://docs.microsoft.com/en-us/windows-hardware/drivers/install/certificate-stores) for integration with IIS and other Windows services: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store certificatestore --verbose + ``` + + + Generate [PFX files](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/certutil) with password protection for easy deployment across Windows environments: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pfxfile --pfxfilepath "C:\certificates" --pfxpassword "your-secure-password" --verbose + ``` + + + For IIS Central SSL store integration in high-scale environments: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store centralssl --centralsslstore "C:\CentralSSL" --verbose + ``` + + + + + + Win-acme can automatically create a [Windows Scheduled Task](https://docs.microsoft.com/en-us/windows/win32/taskschd/about-the-task-scheduler) for certificate renewal. Because win-acme stores the ACME server URL and EAB credentials from your initial request, renewal will automatically use the same Infisical ACME configuration—no additional settings are required. + + **Option 1: Enable during initial certificate request** + + Include the `--setuptaskscheduler` parameter in your initial command to automatically create the renewal task: + + ```powershell + wacs.exe --target manual --host example.infisical.com --baseuri "https://your-infisical-instance.com/api/v1/pki/certificate-profiles/{profile-id}/acme/directory" --eab-key-identifier "your-eab-key-identifier" --eab-key "your-eab-secret" --validation selfhosting --store pemfiles --pemfilespath "C:\certificates" --setuptaskscheduler --verbose + ``` + + **Option 2: Test manual renewal** + + You can test the renewal process manually before setting up automation to ensure the configuration works correctly: + + ```powershell + wacs.exe --renew --force --verbose + ``` + + This command simulates the full renewal process and verifies that win-acme can successfully contact Infisical and renew your certificate using the stored configuration. + + **Option 3: Verify scheduled task creation** + + Check that the scheduled task was created successfully: + + ```powershell + Get-ScheduledTask -TaskName "*win-acme*" + ``` + + The automatic renewal task will: + - Run under the SYSTEM account for elevated privileges. + - Check certificates daily for renewal eligibility. + - Automatically renew certificates that are within the renewal threshold (typically 30 days before expiration). + - Log renewal activities to Windows Event Viewer and win-acme log files for monitoring and troubleshooting. + + + + Win-acme stores renewal configurations automatically in its settings directory, so once a certificate is created, the renewal process will use the same parameters (ACME endpoint, EAB credentials, storage options) for future renewals. The renewal threshold can be adjusted in the win-acme configuration files if needed. + + + + + After successful certificate issuance, verify that the certificate files have been created correctly based on your chosen storage method. + + + Check your specified PEM files directory to ensure all certificate components are present: + + ```powershell + Get-ChildItem "C:\certificates" -Filter "*.pem" + ``` + + You should see files like: + - `example.infisical.com-crt.pem` (certificate) + - `example.infisical.com-key.pem` (private key) + - `example.infisical.com-chain.pem` (complete certificate chain) + - `example.infisical.com-chain-only.pem` (only certificate chain) + + ![Windows Server Generated PEM files](/images/platform/pki/integrations/windows-server/certificates-created.png) + + + If you used the certificate store option, check that the certificate was properly installed using PowerShell: + + ```powershell + Get-ChildItem -Path Cert:\LocalMachine\My | Where-Object {$_.Subject -like "*example.infisical.com*"} + ``` + + The certificate should appear in the [Local Computer Personal certificate store](https://docs.microsoft.com/en-us/dotnet/framework/wcf/feature-details/working-with-certificates#certificate-stores), making it available for use with IIS, other Windows services, and applications that integrate with the Windows Certificate Store. + + + + diff --git a/docs/documentation/platform/project-templates.mdx b/docs/documentation/platform/project-templates.mdx index 7dd5ceb50..9526f4092 100644 --- a/docs/documentation/platform/project-templates.mdx +++ b/docs/documentation/platform/project-templates.mdx @@ -106,13 +106,14 @@ In the following steps, we'll explore how to use a project template when creatin Your project will be provisioned with the configured template roles and environments. - To use a project template, make an API request to the [Create Project](/api-reference/endpoints/workspaces/create-workspace) API endpoint with the specified template name included. + To use a project template, make an API request to the [Create Project](/api-reference/endpoints/projects/create-project) API endpoint with the specified template name included. ### Sample request ```bash Request curl --request POST \ - --url https://app.infisical.com/api/v2/workspace \ + --url https://app.infisical.com/api/v1/projects \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "projectName": "My Project", diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 545ed6b3b..dfb3bf0ec 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -17,6 +17,10 @@ For example, if secret A references values from secrets B and C located in diffe This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references. + + You can hold the `Cmd` (Mac) or `Ctrl` (Windows/Linux) key and click the secret reference to be redirected to it. + + ### Syntax When defining a secret reference, interpolation syntax is used to define references to secrets in other environments and [folders](./folder). diff --git a/docs/documentation/platform/secret-rotation/overview.mdx b/docs/documentation/platform/secret-rotation/overview.mdx index d11334440..d2e5755c4 100644 --- a/docs/documentation/platform/secret-rotation/overview.mdx +++ b/docs/documentation/platform/secret-rotation/overview.mdx @@ -98,8 +98,8 @@ Using a __30-Day__ rotation interval as an example, here's how the process unfol ## Infisical Secret Rotation Strategies -- [PostgreSQL Credentials](./postgres) -- [Microsoft SQL Server Credentials](./mssql) +- [PostgreSQL Credentials](./postgres-credentials) +- [Microsoft SQL Server Credentials](./mssql-credentials) ## FAQ diff --git a/docs/documentation/platform/secrets-mgmt/project.mdx b/docs/documentation/platform/secrets-mgmt/project.mdx index 3e7c7cbc7..58f50ce37 100644 --- a/docs/documentation/platform/secrets-mgmt/project.mdx +++ b/docs/documentation/platform/secrets-mgmt/project.mdx @@ -16,7 +16,7 @@ customized depending on the intended use case. ## Secrets Overview -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](/documentation/platform/folder) across environments. This is useful for comparing secrets, identifying if anything is missing, and making quick changes. ![project secrets overview](/images/platform/project/project-secrets-overview-open.png) diff --git a/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx index 74bfca228..1c22550cd 100644 --- a/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx +++ b/docs/documentation/platform/ssh/concepts/ssh-certificates.mdx @@ -21,6 +21,6 @@ Because certificates are time-bound and centrally managed, they’re easier to a Infisical SSH gives you a secure, scalable way to manage infrastructure access using SSH certificates — without the overhead of running your own certificate authority, wiring trust across hosts, or building issuance workflows from scratch. -It replaces long-lived SSH keys with short-lived, identity-bound certificates and handles all the moving parts for you: operating CAs, configuring trust between users and hosts, and issuing certificates on demand. With Infisical SSH, you can register a host with [`infisical ssh add-host`](/docs/cli/commands/ssh#infisical-ssh-add-host), then connect with [`infisical ssh connect`](/docs/cli/commands/ssh#infisical-ssh-connect) — that’s all it takes. +It replaces long-lived SSH keys with short-lived, identity-bound certificates and handles all the moving parts for you: operating CAs, configuring trust between users and hosts, and issuing certificates on demand. With Infisical SSH, you can register a host with [`infisical ssh add-host`](/cli/commands/ssh#infisical-ssh-add-host), then connect with [`infisical ssh connect`](/cli/commands/ssh#infisical-ssh-connect) — that’s all it takes. The result is centralized, auditable SSH access that’s easy to use and built to scale with your infrastructure. diff --git a/docs/documentation/platform/sso/general-oidc/overview.mdx b/docs/documentation/platform/sso/general-oidc/overview.mdx index 586f66f26..5ba469264 100644 --- a/docs/documentation/platform/sso/general-oidc/overview.mdx +++ b/docs/documentation/platform/sso/general-oidc/overview.mdx @@ -77,7 +77,7 @@ Prerequisites: - If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite OIDC login. + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](/documentation/platform/admin-panel/server-admin#default-organization) to expedite OIDC login. diff --git a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx index 727bf9be6..0a32df228 100644 --- a/docs/documentation/platform/sso/keycloak-oidc/overview.mdx +++ b/docs/documentation/platform/sso/keycloak-oidc/overview.mdx @@ -103,7 +103,7 @@ description: "Learn how to configure Keycloak OIDC for Infisical SSO." - If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](../admin-panel/server-admin#default-organization) to expedite OIDC login. + If you are only using one organization on your Infisical instance, you can configure a default organization in the [Server Admin Console](/documentation/platform/admin-panel/server-admin#default-organization) to expedite OIDC login. diff --git a/docs/documentation/platform/workflow-integrations/slack-integration.mdx b/docs/documentation/platform/workflow-integrations/slack-integration.mdx index 2317baabe..38fc15ebe 100644 --- a/docs/documentation/platform/workflow-integrations/slack-integration.mdx +++ b/docs/documentation/platform/workflow-integrations/slack-integration.mdx @@ -17,13 +17,13 @@ This guide will provide step by step instructions on how to configure Slack inte ![org-slack-overview](/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png) - Press "Add" and select "Slack" as the platform. + Press **Add** and select **Slack** as the platform. ![org-slack-initial-add](/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png) Give your Slack integration a descriptive alias. You will use this to select the Slack integration for your project. ![org-slack-add-form](/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png) - Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Allow**. + Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Install Infisical**. ![org-slack-authenticate](/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png) This completes the workflow integration creation flow. The projects in your organization can now use this Slack integration to send real-time updates to your Slack workspace. @@ -38,6 +38,7 @@ This guide will provide step by step instructions on how to configure Slack inte + Press **Add** and select **Slack** as the platform. ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) @@ -66,13 +67,13 @@ This guide will provide step by step instructions on how to configure Slack inte Before anything else, you need to setup the Slack app to be used by your Infisical instance. Because you're self-hosting, you will need to create this Slack application as demonstrated in the preceding step. + + Click the **Create Slack app** button. This will open up a new window with the + custom app creation flow on Slack. + ![admin-settings-slack-overview](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png) - Click the "Create Slack app" button. This will open up a new window with the - custom app creation flow on Slack. - ![admin-slack-create-app](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png) - Select the Slack workspace you want to integrate with Infisical. ![admin-slack-app-workspace-select](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png) @@ -87,7 +88,7 @@ This guide will provide step by step instructions on how to configure Slack inte Copy the Client ID and Client Secret values from your newly created custom Slack app and add them to Infisical. ![admin-slack-app-credentials](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png) ![admin-slack-app-credentials-form](/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png) - Complete the admin setup by pressing Save. + Complete the admin setup by pressing **Save**. @@ -101,13 +102,13 @@ This guide will provide step by step instructions on how to configure Slack inte ![org-slack-overview](/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png) - Press "Add" and select "Slack" as the platform. + Press **Add** and select **Slack** as the platform. ![org-slack-initial-add](/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png) Give your Slack integration a descriptive alias. You will use this to select the Slack integration for your project. ![org-slack-add-form](/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png) - Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Allow**. + Press **Connect Slack**. This opens up the Slack app installation flow. Select the Slack workspace you want to install the custom Slack app to and press **Install Infisical**. ![org-slack-authenticate](/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png) Your Slack bot will then be added to your selected Slack workspace. This completes the workflow integration creation flow. Your projects in the organization can now use this Slack integration to send real-time updates to your Slack workspace. @@ -122,6 +123,7 @@ This guide will provide step by step instructions on how to configure Slack inte + Press **Add** and select **Slack** as the platform. ![project-slack-overview](/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png) @@ -162,3 +164,87 @@ This guide will provide step by step instructions on how to configure Slack inte channels](/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png) + +## Slack Events + +The Slack integration supports the following events that can be configured for your projects. Each event is triggered when specific actions occur within your Infisical instance. + + + + ### Access Request + This event is triggered when a user creates a new access approval request for a project. The notification includes details about the requester, the requested permissions, the secret path and environment, and whether the access is temporary or permanent. + + **When it's triggered:** + - A user submits a new access approval request through the Infisical UI + - The request requires approval based on configured access approval policies + - The notification is sent to the configured access request channels + + **Notification includes:** + - Requester's full name and email + - Requested permissions (read, write, etc.) + - Secret path and environment + - Access type (temporary or permanent) + - Optional user note + - Direct link to review the request + + ![access request notification](/images/platform/workflow-integrations/slack-integration/access-request-notification.png) + + ### Access Request Updated + This event is triggered when an existing access approval request is modified or updated. This helps approvers stay informed about changes to pending requests. + + **When it's triggered:** + - An access approval request is edited by the requester or another authorized user + - Changes are made to permissions, temporary range, or notes + - The notification is sent to the configured access request channels + + **Notification includes:** + - Original requester's information + - Editor's full name and email (who made the update) + - Updated permissions + - Updated secret path and environment + - Editor's note explaining the changes + - Direct link to review the updated request + + ![access request updated notification](/images/platform/workflow-integrations/slack-integration/access-request-updated-notification.png) + + + + ### Secret Approval + This event is triggered when a secret approval request is created. This occurs when a user attempts to create, update, or delete secrets that require approval based on secret approval policies. + + **When it's triggered:** + - A user creates, updates, or deletes secrets in a path protected by a secret approval policy + - The changes require approval before being applied + - The notification is sent to the configured secret request channels + + **Notification includes:** + - User's email who initiated the change + - Environment and secret path + - List of secret keys affected + - Direct link to review and approve the secret changes + + ![secret approval notification](/images/platform/workflow-integrations/slack-integration/secret-approval-notification.png) + + + + ### Secret Sync Error + This event is triggered when a secret sync operation fails. Secret syncs allow you to synchronize secrets between Infisical and external systems like GitHub, GitLab, AWS Secrets Manager, and others. + + **When it's triggered:** + - A secret sync fails to push secrets to the destination + - A secret sync fails to pull secrets from the source + - A secret sync fails to import secrets + - A secret sync fails to remove secrets + - Any other error occurs during the sync process + + **Notification includes:** + - Sync name and destination + - The action that failed + - Environment and secret path + - Project name + - Detailed error message explaining the failure + - Direct link to view and troubleshoot the sync configuration + + ![secret sync error notification](/images/platform/workflow-integrations/slack-integration/secret-sync-error-notification.png) + + diff --git a/docs/images/platform/pki/acme/acme-configuration-modal.png b/docs/images/platform/pki/acme/acme-configuration-modal.png new file mode 100644 index 000000000..584a6e643 Binary files /dev/null and b/docs/images/platform/pki/acme/acme-configuration-modal.png differ diff --git a/docs/images/platform/pki/acme/certificate-profile-acme-option.png b/docs/images/platform/pki/acme/certificate-profile-acme-option.png new file mode 100644 index 000000000..463438f20 Binary files /dev/null and b/docs/images/platform/pki/acme/certificate-profile-acme-option.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-option.png b/docs/images/platform/pki/certificate/cert-export-option.png new file mode 100644 index 000000000..ae966905c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-option.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem-modal.png b/docs/images/platform/pki/certificate/cert-export-pem-modal.png new file mode 100644 index 000000000..df01db0bf Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pem.png b/docs/images/platform/pki/certificate/cert-export-pem.png new file mode 100644 index 000000000..927227394 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pem.png differ diff --git a/docs/images/platform/pki/certificate/cert-export-pkcs12.png b/docs/images/platform/pki/certificate/cert-export-pkcs12.png new file mode 100644 index 000000000..773c69c74 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-export-pkcs12.png differ diff --git a/docs/images/platform/pki/integrations/windows-server/certificates-created.png b/docs/images/platform/pki/integrations/windows-server/certificates-created.png new file mode 100644 index 000000000..270e12b1a Binary files /dev/null and b/docs/images/platform/pki/integrations/windows-server/certificates-created.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/access-request-notification.png b/docs/images/platform/workflow-integrations/slack-integration/access-request-notification.png new file mode 100644 index 000000000..ad40c8f4f Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/access-request-notification.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/access-request-updated-notification.png b/docs/images/platform/workflow-integrations/slack-integration/access-request-updated-notification.png new file mode 100644 index 000000000..f7bbf51ae Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/access-request-updated-notification.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png index 0b97be58a..1af15b074 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credential-form.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png index ddf245eff..136cea9d8 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-credentials.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png index 95e9fe4ba..31cd0c458 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-summary.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png index 0c047ab1a..39955f547 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-app-workspace-select.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png deleted file mode 100644 index 502dbde0a..000000000 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-create-app.png and /dev/null differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png index 54ad6ca6e..3a200f296 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png and b/docs/images/platform/workflow-integrations/slack-integration/admin-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png index 048e91f85..8b9e59eca 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png and b/docs/images/platform/workflow-integrations/slack-integration/cloud-org-slack-integration-authenticate.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png index 97b38d6e4..1554b9bd8 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-add-form.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png index 166e5849f..ef492c018 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-authenticate.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png index f46f61d89..bf98abd0e 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-created.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png index 5bd66d932..5d9316d75 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-initial-add.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png index 1f7386559..9bfbbb7f7 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png and b/docs/images/platform/workflow-integrations/slack-integration/org-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png index 3f6bd0d1d..4864045f3 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png and b/docs/images/platform/workflow-integrations/slack-integration/private-slack-setup-channel-field.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png index c6dd70ad7..a844988d5 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-config.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png index 944db9cca..6806527da 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-overview.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png index 073d3fd93..9367d55a5 100644 Binary files a/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png and b/docs/images/platform/workflow-integrations/slack-integration/project-slack-integration-select.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/secret-approval-notification.png b/docs/images/platform/workflow-integrations/slack-integration/secret-approval-notification.png new file mode 100644 index 000000000..ef6d2ddad Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/secret-approval-notification.png differ diff --git a/docs/images/platform/workflow-integrations/slack-integration/secret-sync-error-notification.png b/docs/images/platform/workflow-integrations/slack-integration/secret-sync-error-notification.png new file mode 100644 index 000000000..121864df6 Binary files /dev/null and b/docs/images/platform/workflow-integrations/slack-integration/secret-sync-error-notification.png differ diff --git a/docs/integrations/platforms/docker-swarm-with-agent.mdx b/docs/integrations/platforms/docker-swarm-with-agent.mdx index 30118a8f0..40d9986bb 100644 --- a/docs/integrations/platforms/docker-swarm-with-agent.mdx +++ b/docs/integrations/platforms/docker-swarm-with-agent.mdx @@ -4,7 +4,7 @@ description: "Learn how to manage secrets in Docker Swarm services." --- In this guide, we'll demonstrate how to use Infisical for managing secrets within Docker Swarm. -Specifically, we'll set up a sidecar container using the [Infisical Agent](/infisical-agent/overview), which authenticates with Infisical to retrieve secrets and access tokens. +Specifically, we'll set up a sidecar container using the [Infisical Agent](/integrations/platforms/infisical-agent), which authenticates with Infisical to retrieve secrets and access tokens. These secrets are then stored in a shared volume accessible by other services in your Docker Swarm. ## Prerequisites @@ -12,7 +12,7 @@ These secrets are then stored in a shared volume accessible by other services in - Docker version 20.10.24 or newer - Basic knowledge of Docker Swarm - [Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) installed on your system -- Familiarity with the [Infisical Agent](/infisical-agent/overview) +- Familiarity with the [Infisical Agent](/integrations/platforms/infisical-agent) ## Objective Our goal is to deploy an Nginx instance in your Docker Swarm cluster, configured to display Infisical secrets on its landing page. This will provide hands-on experience in fetching and utilizing secrets from Infisical within Docker Swarm. The principles demonstrated here are also applicable to Docker Compose deployments. diff --git a/docs/integrations/platforms/ecs-with-agent.mdx b/docs/integrations/platforms/ecs-with-agent.mdx index 31c5a982b..b6b9ce7f2 100644 --- a/docs/integrations/platforms/ecs-with-agent.mdx +++ b/docs/integrations/platforms/ecs-with-agent.mdx @@ -7,7 +7,7 @@ description: "Learn how to deliver secrets to Amazon Elastic Container Service." This guide will go over the steps needed to access secrets stored in Infisical from Amazon Elastic Container Service (ECS). -At a high level, the steps involve setting up an ECS task with an [Infisical Agent](/infisical-agent/overview) as a sidecar container. This sidecar container uses [AWS Auth](/documentation/platform/identities/aws-auth) to authenticate with Infisical to fetch secrets/access tokens. +At a high level, the steps involve setting up an ECS task with an [Infisical Agent](/integrations/platforms/infisical-agent) as a sidecar container. This sidecar container uses [AWS Auth](/documentation/platform/identities/aws-auth) to authenticate with Infisical to fetch secrets/access tokens. Once the secrets/access tokens are retrieved, they are then stored in a shared [Amazon Elastic File System](https://aws.amazon.com/efs/) (EFS) volume. This volume is then made accessible to your application and all of its replicas. This guide primarily focuses on integrating Infisical Cloud with Amazon ECS on AWS Fargate and Amazon EFS. @@ -21,7 +21,7 @@ This guide requires the following prerequisites: - Git installed - Terraform v1.0 or later installed - Access to AWS credentials -- Understanding of [Infisical Agent](/infisical-agent/overview) +- Understanding of [Infisical Agent](/integrations/platforms/infisical-agent) ## What we will deploy diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 848da4055..f2911ac2f 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -19,7 +19,7 @@ This CRD offers the following features: ### Prerequisites - A project within Infisical. -- A [machine identity](/docs/documentation/platform/identities/overview) ready for use in Infisical that has permissions to create dynamic secret leases in the project. +- A [machine identity](/documentation/platform/identities/machine-identities) ready for use in Infisical that has permissions to create dynamic secret leases in the project. - You have already configured a dynamic secret in Infisical. - The operator is installed on to your Kubernetes cluster. diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index 0affe7841..a5c68e503 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -17,7 +17,7 @@ This CRD offers the following features: ### Prerequisites - A project within Infisical. -- A [machine identity](/docs/documentation/platform/identities/overview) ready for use in Infisical that has permissions to create secrets in your project. +- A [machine identity](/documentation/platform/identities/machine-identities) ready for use in Infisical that has permissions to create secrets in your project. - The operator is installed on to your Kubernetes cluster. ## Example usage diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index d7fb6249a..a7f3dd4ce 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -256,7 +256,7 @@ spec: ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -432,7 +432,7 @@ spec: ![identities organization](/images/platform/identities/identities-org.png) - When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + When creating an identity, you specify an organization level [role](/documentation/platform/access-controls/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. ![identities organization create](/images/platform/identities/identities-org-create.png) @@ -803,7 +803,7 @@ Follow the instructions below to create and store the service token in a Kuberne #### 1. Generate service token -You can generate a [service token](../../documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. +You can generate a [service token](/documentation/platform/token) for an Infisical project by heading over to the Infisical dashboard then to Project Settings. #### 2. Create Kubernetes secret containing service token diff --git a/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx b/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx index 91657c760..56c047056 100644 --- a/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx +++ b/docs/integrations/secret-syncs/digital-ocean-app-platform.mdx @@ -77,7 +77,7 @@ description: "Learn how to configure a DigitalOcean App Platform Sync for Infisi - To create a **DigitalOcean App Platform Sync**, make an API request to the [Create DigitalOcean Sync](/api-reference/endpoints/secret-syncs/digital-ocean/create) API endpoint. + To create a **DigitalOcean App Platform Sync**, make an API request to the [Create DigitalOcean Sync](/api-reference/endpoints/secret-syncs/digital-ocean-app-platform/create) API endpoint. ### Sample request diff --git a/docs/internals/overview.mdx b/docs/internals/overview.mdx index 510a6b06c..fc58faca2 100644 --- a/docs/internals/overview.mdx +++ b/docs/internals/overview.mdx @@ -15,7 +15,7 @@ This section covers the internals of Infisical including its technical underpinn We do not recommend hardcoding your [Machine Identity - Tokens](/platform/identities/overview). Setting it as an environment variable + Tokens](/documentation/platform/identities/machine-identities). Setting it as an environment variable would be best. diff --git a/docs/sdks/languages/ruby.mdx b/docs/sdks/languages/ruby.mdx index 3fb9cb3e1..c5417fcdc 100644 --- a/docs/sdks/languages/ruby.mdx +++ b/docs/sdks/languages/ruby.mdx @@ -36,7 +36,7 @@ puts "Secret: #{single_test_secret}" This example demonstrates how to use the Infisical Ruby SDK in a simple Ruby application. The application retrieves a secret named `API_KEY` from the `dev` environment of the `YOUR_PROJECT_ID` project. - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/machine-identities). Setting it as an environment variable would be best. # Installation diff --git a/docs/self-hosting/deployment-options/native/standalone-binary.mdx b/docs/self-hosting/deployment-options/native/standalone-binary.mdx deleted file mode 100644 index 5767ca948..000000000 --- a/docs/self-hosting/deployment-options/native/standalone-binary.mdx +++ /dev/null @@ -1,202 +0,0 @@ ---- -title: "Standalone" -description: "Learn how to deploy Infisical in a standalone environment." ---- - -# Self-Hosting Infisical with Standalone Infisical - -Deploying Infisical in a standalone environment is a great way to get started with Infisical without having to use containers. This guide will walk you through the process of deploying Infisical in a standalone environment. -This is one of the easiest ways to deploy Infisical. It is a single executable, currently only supported on Debian-based systems. - -The standalone deployment implements the "bring your own database" (BYOD) approach. This means that you will need to provide your own databases (specifically Postgres and Redis) for the Infisical services to use. The standalone deployment does not include any databases. - -If you wish to streamline the deployment process, we recommend using the Ansible role for Infisical. The Ansible role automates the end to end deployment process, and will take care of everything like databases, redis deployment, web serving, and availability. -- [Automated Deployment with high availability (HA)](/self-hosting/deployment-options/native/high-availability) - - -## Prerequisites -- A server running a Debian-based operating system (e.g., Ubuntu, Debian) -- A Postgres database -- A Redis database - -## Installing Infisical -Installing Infisical is as simple as running a single command. You can install Infisical by running the following command: - -```bash - $ curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-core/cfg/setup/bash.deb.sh' | sudo bash && sudo apt-get install -y infisical-core -``` - -## Running Infisical -Running Infisical and serving it to the web has a few steps. Below are the steps to get you started with running Infisical in a standalone environment. - * Setup environment variables - * Running Postgres migrations - * Create system daemon - * Exposing Infisical to the internet - - - - - To use Infisical you'll need to configure the environment variables beforehand. You can acheive this by creating an environment file to be used by Infisical. - - - #### Create environment file - ```bash - $ mkdir -p /etc/infisical && touch /etc/infisical/environment - ``` - - After creating the environment file, you'll need to fill it out with your environment variables. - - #### Edit environment file - ```bash - $ nano /etc/infisical/environment - ``` - - ```bash - DB_CONNECTION_URI=postgres://user:password@localhost:5432/infisical # Replace with your Postgres database connection URI - REDIS_URL=redis://localhost:6379 # Replace with your Redis connection URI - ENCRYPTION_KEY=your_encryption_key # Replace with your encryption key (can be generated with: openssl rand -hex 16) - AUTH_SECRET=your_auth_secret # Replace with your auth secret (can be generated with: openssl rand -base64 32) - ``` - - - The minimum required environment variables are `DB_CONNECTION_URI`, `REDIS_URL`, `ENCRYPTION_KEY`, and `AUTH_SECRET`. We recommend You take a look at our [list of all available environment variables](/docs/self-hosting/configuration/envars#general-platform), and configure the ones you need. - - - - - Assuming you're starting with a fresh Postgres database, you'll need to run the Postgres migrations to syncronize the database schema. - The migration command will use the environment variables you configured in the previous step. - - - ```bash - $ eval $(cat /etc/infisical/environment) infisical-core migration:latest - ``` - - - This step will need to be repeated if you update Infisical in the future. - - - - - - ```bash - $ nano /etc/systemd/system/infisical.service - ``` - - - - Create a systemd service file for Infisical. Creating a systemd service file will allow Infisical to start automatically when the system boots or in case of a crash. - - ```bash - $ nano /etc/systemd/system/infisical.service - ``` - - ```ini - [Unit] - Description=Infisical Service - After=network.target - - [Service] - # The path to the environment file we created in the previous step - EnvironmentFile=/etc/infisical/environment - Type=simple - # Change the user to the user you want to run Infisical as - User=root - ExecStart=/usr/local/bin/infisical-core - Restart=always - RestartSec=30 - - [Install] - WantedBy=multi-user.target - ``` - - Now we need to reload the systemd daemon and start the Infisical service. - - ```bash - $ systemctl daemon-reload - $ systemctl start infisical - $ systemctl enable infisical - ``` - - - You can check the status of the Infisical service by running `systemctl status infisical`. - It is also a good idea to check the logs for any errors by running `journalctl --no-pager -u infisical`. - - - - Exposing Infisical to the internet requires setting up a reverse proxy. You can use any reverse proxy of your choice, but we recommend using HAProxy or Nginx. Below is an example of how to set up a reverse proxy using HAProxy. - - #### Install HAProxy - ```bash - $ apt-get install -y haproxy - ``` - - #### Edit HAProxy configuration - ```bash - $ nano /etc/haproxy/haproxy.cfg - ``` - - ```ini - global - log /dev/log local0 - log /dev/log local1 notice - chroot /var/lib/haproxy - stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners - stats timeout 30s - user haproxy - group haproxy - daemon - - defaults - log global - mode http - option httplog - option dontlognull - timeout connect 5000 - timeout client 50000 - timeout server 50000 - - frontend http-in - bind *:80 - default_backend infisical - - backend infisical - server infisicalapp 127.0.0.1:8080 check - ``` - - - If you decide to use Nginx, then please be aware that the configuration will be different. **Infisical listens on port 8080**. - - - #### Restart HAProxy - ```bash - $ systemctl restart haproxy - ``` - - - - -And that's it! You have successfully deployed Infisical in a standalone environment. You can now access Infisical by visiting `http://your-server-ip`. - - - Please take note that the Infisical team cannot provide infrastructure support for **free self-hosted** deployments.
If you need help with infrastructure, we recommend upgrading to a [paid plan](https://infisical.com/pricing) which includes infrastructure support. - - You can also join our community [Slack](https://infisical.com/slack) for help and support from the community. -
- -## Troubleshooting - - - This is a common issue related to the HAProxy configuration file. The error is caused by the missing newline character at the end of the file. You can fix this by adding a newline character at the end of the file. - - ```bash - $ echo "" >> /etc/haproxy/haproxy.cfg - ``` - - - This issue can be caused by a number of reasons, mostly realted to the network configuration. Here are a few things you can check: - 1. Ensure that the firewall is not blocking the connection. You can check this by running `ufw status`. Ensure that port 80 is open. - 2. If you're using a cloud provider like AWS or GCP, ensure that the security group allows traffic on port 80. - 3. Ensure that the HAProxy service is running. You can check this by running `systemctl status haproxy`. - 4. Ensure that the Infisical service is running. You can check this by running `systemctl status infisical`. - \ No newline at end of file diff --git a/docs/self-hosting/overview.mdx b/docs/self-hosting/overview.mdx index acb692711..040bfa6b7 100644 --- a/docs/self-hosting/overview.mdx +++ b/docs/self-hosting/overview.mdx @@ -11,7 +11,7 @@ Choose from a number of deployment options listed below to get started. title="Docker" color="#000000" icon="docker" - href="deployment-options/standalone-infisical" + href="./deployment-options/standalone-infisical" > Use the fully packaged docker image to deploy Infisical anywhere.
@@ -20,7 +20,7 @@ Choose from a number of deployment options listed below to get started. title="Docker Compose" color="#000000" icon="docker" - href="deployment-options/docker-compose" + href="./deployment-options/docker-compose" > Install Infisical using our Docker Compose template. @@ -28,7 +28,7 @@ Choose from a number of deployment options listed below to get started. title="Kubernetes" color="#000000" icon="gear-complex-code" - href="deployment-options/kubernetes-helm" + href="./deployment-options/kubernetes-helm" > Use our Helm chart to Install Infisical on your Kubernetes cluster. @@ -36,7 +36,7 @@ Choose from a number of deployment options listed below to get started. Install Infisical on your system without containers using our Linux package. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8965ce45e..e8fdda096 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -150,7 +150,8 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", @@ -195,7 +196,6 @@ "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", @@ -488,7 +488,6 @@ "resolved": "https://registry.npmjs.org/@casl/ability/-/ability-6.7.2.tgz", "integrity": "sha512-KjKXlcjKbUz8dKw7PY56F7qlfOFgxTU6tnlJ8YrbDyWkJMIlHa6VRWzCD8RU20zbJUC1hExhOFggZjm6tf1mUw==", "license": "MIT", - "peer": true, "dependencies": { "@ucast/mongo2js": "^1.3.0" }, @@ -547,7 +546,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1325,7 +1323,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.1.tgz", "integrity": "sha512-8dBIHbfsKlCk2jHQ9PoRBg2Z+4TwyE3vZICSnoDlnsHA6SiMlTwfmW6yX0lHsRmWJugkeb92sA0hZdkXJhuz+g==", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.1" }, @@ -2016,7 +2013,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -4412,7 +4408,6 @@ "integrity": "sha512-RnO1SaiCFHn666wNz2QfZEFxvmiNRqhzaMXHXxXXKt+MEP7aajlPxUSMIQpKAaJfverpovEYqjBOXDq6dDcaOQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/utils": "^8.13.0", "eslint-visitor-keys": "^4.2.0", @@ -5038,7 +5033,6 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.95.1.tgz", "integrity": "sha512-P5x4yNhcdkYsCEoYeGZP8Q9Jlxf0WXJa4G/xvbmM905seZc9FqJqvCSRvX3dWTPOXRABhl4g+8DHqfft0c/AvQ==", "license": "MIT", - "peer": true, "dependencies": { "@tanstack/history": "1.95.0", "@tanstack/react-store": "^0.7.0", @@ -5250,6 +5244,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5270,6 +5265,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -5280,6 +5276,7 @@ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", @@ -5299,7 +5296,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/user-event": { "version": "14.6.1", @@ -5307,6 +5305,7 @@ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12", "npm": ">=6" @@ -5327,7 +5326,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5380,6 +5380,7 @@ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" @@ -5453,7 +5454,8 @@ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/doctrine": { "version": "0.0.9", @@ -5589,7 +5591,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.16.tgz", "integrity": "sha512-oh8AMIC4Y2ciKufU8hnKgs+ufgbA/dhPTACaZPM86AbwX9QwnFtSoPWEeRUj8fge+v6kFt78BXcDhAU1SrrAsw==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5601,7 +5602,6 @@ "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -5649,7 +5649,6 @@ "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.34.0", @@ -5690,7 +5689,6 @@ "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.34.0", "@typescript-eslint/types": "8.34.0", @@ -5962,6 +5960,7 @@ "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", @@ -5979,6 +5978,7 @@ "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -6006,6 +6006,7 @@ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -6016,6 +6017,7 @@ "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyrainbow": "^2.0.0" }, @@ -6029,6 +6031,7 @@ "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tinyspy": "^4.0.3" }, @@ -6042,6 +6045,7 @@ "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", @@ -6115,7 +6119,6 @@ "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6232,6 +6235,7 @@ "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -6280,6 +6284,7 @@ "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6360,6 +6365,7 @@ "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -6447,6 +6453,7 @@ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" } @@ -6457,6 +6464,7 @@ "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -6469,7 +6477,8 @@ "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/asynckit": { "version": "0.4.0", @@ -6519,6 +6528,7 @@ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -6620,6 +6630,7 @@ "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "open": "^8.0.4" }, @@ -6856,7 +6867,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -7038,6 +7048,7 @@ "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", @@ -7108,6 +7119,7 @@ "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 16" } @@ -7493,7 +7505,8 @@ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cssesc": { "version": "3.0.0", @@ -7512,8 +7525,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cva": { "name": "class-variance-authority", @@ -7585,7 +7597,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -7639,7 +7650,8 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/data-view-buffer": { "version": "1.0.1", @@ -7749,6 +7761,7 @@ "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -7783,6 +7796,7 @@ "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -7904,7 +7918,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-helpers": { "version": "5.2.1", @@ -8114,6 +8129,7 @@ "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -8197,7 +8213,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -8239,6 +8254,7 @@ "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.3.4" }, @@ -8275,7 +8291,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8332,7 +8347,6 @@ "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "eslint-config-airbnb-base": "^15.0.0", "object.assign": "^4.1.2", @@ -8355,7 +8369,6 @@ "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "confusing-browser-globals": "^1.0.10", "object.assign": "^4.1.2", @@ -8386,7 +8399,6 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8486,7 +8498,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -8616,6 +8627,7 @@ "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -8649,7 +8661,6 @@ "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8673,6 +8684,7 @@ "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -8686,6 +8698,7 @@ "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -8704,6 +8717,7 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -8865,6 +8879,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -9895,7 +9910,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.23.2" }, @@ -9989,6 +10003,7 @@ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -10265,6 +10280,7 @@ "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "is-docker": "cli.js" }, @@ -10586,6 +10602,7 @@ "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -10621,6 +10638,7 @@ "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", "license": "MIT", + "peer": true, "funding": { "type": "GitHub Sponsors ❤", "url": "https://github.com/sponsors/dmonad" @@ -10632,6 +10650,7 @@ "integrity": "sha512-x4WH0BWmrMmg4oHHl+duwubhrvczGlyuGAZu3nvrf0UXOfPu8IhZObFEr7DE/iv01YgVZrsOiRcqw2srkKEDIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", @@ -10781,6 +10800,7 @@ "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", @@ -10815,7 +10835,8 @@ "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, - "license": "CC0-1.0" + "license": "CC0-1.0", + "peer": true }, "node_modules/language-tags": { "version": "1.0.9", @@ -10823,6 +10844,7 @@ "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "language-subtag-registry": "^0.3.20" }, @@ -10855,6 +10877,7 @@ "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", "license": "MIT", + "peer": true, "dependencies": { "isomorphic.js": "^0.2.4" }, @@ -11179,7 +11202,8 @@ "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lru-cache": { "version": "5.1.1", @@ -11206,6 +11230,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -11897,6 +11922,7 @@ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12245,6 +12271,7 @@ "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", @@ -12514,6 +12541,7 @@ "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14.16" } @@ -12665,7 +12693,6 @@ "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12782,6 +12809,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -12797,6 +12825,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -12809,7 +12838,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prismjs": { "version": "1.30.0", @@ -13010,7 +13040,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13038,7 +13067,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -13130,7 +13158,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -13181,7 +13208,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", "integrity": "sha512-IK18V6GVbab4TAo1/cz3kqajxbDPGofdF0w7VHdCo0Nt8PrPlOZcuuDq9YYIV1BtjcX78x0XsldbQRQnQXWXmw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13432,6 +13458,7 @@ "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", @@ -13449,6 +13476,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13465,6 +13493,7 @@ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" @@ -13479,6 +13508,7 @@ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "min-indent": "^1.0.0" }, @@ -13693,7 +13723,6 @@ "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -14204,6 +14233,7 @@ "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14219,6 +14249,7 @@ "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", @@ -14246,6 +14277,7 @@ "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" @@ -14466,8 +14498,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -14571,6 +14602,7 @@ "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -14581,6 +14613,7 @@ "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -14865,7 +14898,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15251,7 +15283,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -15669,6 +15700,7 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -15713,7 +15745,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -15864,7 +15895,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/public/images/integrations/Cassandra.png b/frontend/public/images/integrations/Cassandra.png new file mode 100644 index 000000000..76552ceac Binary files /dev/null and b/frontend/public/images/integrations/Cassandra.png differ diff --git a/frontend/public/images/integrations/CockroachDB.png b/frontend/public/images/integrations/CockroachDB.png new file mode 100644 index 000000000..4e7b778e8 Binary files /dev/null and b/frontend/public/images/integrations/CockroachDB.png differ diff --git a/frontend/public/images/integrations/DynamoDB.png b/frontend/public/images/integrations/DynamoDB.png new file mode 100644 index 000000000..457701c24 Binary files /dev/null and b/frontend/public/images/integrations/DynamoDB.png differ diff --git a/frontend/public/images/integrations/Elastic.png b/frontend/public/images/integrations/Elastic.png new file mode 100644 index 000000000..9e480e27a Binary files /dev/null and b/frontend/public/images/integrations/Elastic.png differ diff --git a/frontend/public/images/integrations/MCP.png b/frontend/public/images/integrations/MCP.png new file mode 100644 index 000000000..ba293b810 Binary files /dev/null and b/frontend/public/images/integrations/MCP.png differ diff --git a/frontend/public/images/integrations/MongoDB.png b/frontend/public/images/integrations/MongoDB.png new file mode 100644 index 000000000..5fe595688 Binary files /dev/null and b/frontend/public/images/integrations/MongoDB.png differ diff --git a/frontend/public/images/integrations/SQLite.png b/frontend/public/images/integrations/SQLite.png new file mode 100644 index 000000000..5f2754308 Binary files /dev/null and b/frontend/public/images/integrations/SQLite.png differ diff --git a/frontend/public/images/integrations/Snowflake.png b/frontend/public/images/integrations/Snowflake.png new file mode 100644 index 000000000..6bbefae93 Binary files /dev/null and b/frontend/public/images/integrations/Snowflake.png differ diff --git a/frontend/public/images/integrations/Web.png b/frontend/public/images/integrations/Web.png new file mode 100644 index 000000000..981dceeb1 Binary files /dev/null and b/frontend/public/images/integrations/Web.png differ diff --git a/frontend/src/components/notifications/Notifications.tsx b/frontend/src/components/notifications/Notifications.tsx index 22f47fa17..58b9179bd 100644 --- a/frontend/src/components/notifications/Notifications.tsx +++ b/frontend/src/components/notifications/Notifications.tsx @@ -67,7 +67,8 @@ export const createNotification = ( ...toastProps, autoClose: toastProps.autoClose || 15000, theme: "dark", - type: myProps?.type || "info" + type: myProps?.type || "info", + className: `pointer-events-auto ${toastProps.className}` }); export const NotificationContainer = () => ( diff --git a/frontend/src/components/v2/Alert/Alert.tsx b/frontend/src/components/v2/Alert/Alert.tsx index f4910e495..70d0d5fc8 100644 --- a/frontend/src/components/v2/Alert/Alert.tsx +++ b/frontend/src/components/v2/Alert/Alert.tsx @@ -9,7 +9,7 @@ import { cva, type VariantProps } from "cva"; import { twMerge } from "tailwind-merge"; const alertVariants = cva( - "w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-4", + "w-full bg-mineshaft-800 rounded-lg border border-bunker-400 px-4 py-3 text-sm flex items-center gap-x-3", { variants: { variant: { @@ -28,6 +28,7 @@ type AlertProps = { title?: string; hideTitle?: boolean; icon?: React.ReactNode; + iconClassName?: string; }; const variantTitleMap = { @@ -45,36 +46,41 @@ const variantIconMap = { const Alert = forwardRef< HTMLDivElement, React.HTMLAttributes & VariantProps & AlertProps ->(({ className, variant, title, icon, hideTitle = false, children, ...props }, ref) => { - const defaultTitle = title ?? variantTitleMap[variant ?? "default"]; - return ( -
-
- {typeof icon !== "undefined" ? ( - <>{icon} - ) : ( - - )} +>( + ( + { className, variant, title, icon, hideTitle = false, children, iconClassName, ...props }, + ref + ) => { + const defaultTitle = title ?? variantTitleMap[variant ?? "default"]; + return ( +
+
+ {typeof icon !== "undefined" ? ( + <>{icon} + ) : ( + + )} +
+
+ {hideTitle ? null : ( +
+ {defaultTitle} +
+ )} + {children} +
-
- {hideTitle ? null : ( -
- {defaultTitle} -
- )} - {children} -
-
- ); -}); + ); + } +); Alert.displayName = "Alert"; const AlertDescription = forwardRef< diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index 8bc73bf89..d6c6712ef 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -55,6 +55,19 @@ export const DrawerContent = forwardRef( {...props} ref={forwardedRef} className={twMerge(drawerContentVariation({ direction, className }))} + onPointerDownOutside={(e) => { + const target = e.target as HTMLElement; + const toastElement = target.closest('[class*="Toastify"]'); + if (toastElement) { + e.preventDefault(); + return; + } + + if (onClose) { + onClose(); + } + props.onPointerDownOutside?.(e); + }} > {title && ( diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 9c4dc0b83..a109004e1 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -2,10 +2,15 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useSt import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; +import { useNavigate } from "@tanstack/react-router"; -import { useProject } from "@app/context"; +import { createNotification } from "@app/components/notifications"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useProject, useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce, useToggle } from "@app/hooks"; import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretInput } from "../SecretInput"; @@ -80,6 +85,8 @@ export const InfisicalSecretInput = forwardRef( ) => { const { currentProject } = useProject(); const projectId = currentProject?.id || ""; + const navigate = useNavigate({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path }); + const { permission } = useProjectPermission(); const [debouncedValue] = useDebounce(value, 100); @@ -307,6 +314,120 @@ export const InfisicalSecretInput = forwardRef( } }, []); + const handleClickSegment = useCallback( + (segment: string, allSegments: string[]) => { + if (!projectId) { + createNotification({ + text: "Project ID is not set", + type: "error" + }); + return; + } + + if (allSegments.length === 0) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + if (allSegments.length === 1) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: propEnvironment ?? "*", + secretPath: propSecretPath ?? "/", + secretName: segment, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + + navigate({ + search: (prev) => ({ + ...prev, + search: segment, + filterBy: "secret", + tags: "" + }) + }); + return; + } + + const environmentSlug = allSegments[0]; + const secretName = allSegments[allSegments.length - 1]; + let folderPath = "/"; + + if (allSegments.length > 2) { + const pathSegments = allSegments.slice(1, -1); + for (let i = 0; i < pathSegments.length; i += 1) { + if (!pathSegments[i]) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + const pathSegment = pathSegments[i]; + folderPath += `${pathSegment}`; + if (pathSegment === segment) { + folderPath += "/"; + break; + } + folderPath += "/"; + } + } + + // Only validate secret permission, users can always view environments and folders + if (segment === secretName) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: environmentSlug, + secretPath: folderPath, + secretName, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + } + + navigate({ + to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path, + params: { + projectId, + envSlug: environmentSlug + }, + search: (prev) => ({ + ...prev, + secretPath: segment === environmentSlug ? "/" : folderPath, + search: segment === secretName ? secretName : prev.search, + filterBy: segment === secretName ? "secret" : prev.filterBy, + tags: "" + }) + }); + }, + [navigate, projectId, permission, propEnvironment, propSecretPath] + ); + return ( @@ -329,6 +450,7 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} + onClickSegment={handleClickSegment} /> void; overlayClassName?: string; showCloseButton?: boolean; + closeOnOutsideClick?: boolean; }; export const ModalContent = forwardRef( @@ -29,6 +30,7 @@ export const ModalContent = forwardRef( bodyClassName, onClose, showCloseButton = true, + closeOnOutsideClick = true, ...props }, forwardedRef @@ -38,7 +40,23 @@ export const ModalContent = forwardRef( className={twMerge("animate-fade-in fixed inset-0 z-30 h-full w-full", overlayClassName)} style={{ backgroundColor: "rgba(0, 0, 0, 0.7)" }} /> - + { + const target = e.target as HTMLElement; + const toastElement = target.closest('[class*="Toastify"]'); + if (toastElement) { + e.preventDefault(); + return; + } + + if (closeOnOutsideClick && onClose) { + onClose(); + } + props.onPointerDownOutside?.(e); + }} + > void, + hoveredPart?: string, + isCmdOrCtrlPressed?: boolean, + onClickSegment?: (segment: string, allSegments: string[]) => void, placeholder?: string ) => { if (isLoadingValue) return HIDDEN_SECRET_VALUE; @@ -29,10 +33,57 @@ const syntaxHighlight = ( const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; + const part = el; + const innerContent = el.slice(2, -1); // Remove ${ and } + const parts = innerContent.split("."); + return ( - + ${ - {el.slice(2, -1)} + {parts.map((segment, segmentIndex) => { + const segmentKey = `${part}-segment-${segmentIndex}`; + const isHovered = hoveredPart === segmentKey; + const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed; + + return ( + + onHoverPart?.(segmentKey)} + onMouseLeave={() => onHoverPart?.("")} + onMouseDown={(e) => { + if (isCmdOrCtrlPressed) { + e.preventDefault(); + e.stopPropagation(); + } + }} + onClick={(e) => { + e.stopPropagation(); + if (isCmdOrCtrlPressed) { + e.preventDefault(); + onClickSegment?.(segment, parts); + } + }} + onKeyDown={(e) => { + if (isCmdOrCtrlPressed && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + e.stopPropagation(); + onClickSegment?.(segment, parts); + } + }} + > + {segment} + + {segmentIndex < parts.length - 1 && ( + . + )} + + ); + })} } ); @@ -62,6 +113,7 @@ type Props = TextareaHTMLAttributes & { canEditButNotView?: boolean; isLoadingValue?: boolean; isErrorLoadingValue?: boolean; + onClickSegment?: (segment: string, allSegments: string[]) => void; }; const commonClassName = "font-mono text-sm caret-white border-none outline-hidden w-full break-all"; @@ -81,12 +133,43 @@ export const SecretInput = forwardRef( canEditButNotView, isLoadingValue, isErrorLoadingValue, + onClickSegment, placeholder, ...props }, ref ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); + const [hoveredPart, setHoveredPart] = useState(); + const [isCmdOrCtrlPressed, setIsCmdOrCtrlPressed] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey) { + setIsCmdOrCtrlPressed(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey) { + setIsCmdOrCtrlPressed(false); + } + }; + + const handleBlur = () => { + setIsCmdOrCtrlPressed(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); return (
( style={{ maxHeight: `${21 * 7}px` }} >
-
+          
             
               (
                   isImport,
                   isLoadingValue,
                   isErrorLoadingValue,
+                  (part) => {
+                    setHoveredPart(part);
+                  },
+                  hoveredPart,
+                  isCmdOrCtrlPressed,
+                  onClickSegment,
                   placeholder
                 )}
               
@@ -138,6 +227,9 @@ export const SecretInput = forwardRef(
               onBlur?.(evt);
               setIsSecretFocused.off();
             }}
+            onMouseLeave={() => {
+              setHoveredPart(undefined);
+            }}
             value={value || ""}
             {...props}
             readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts
index c8100bd16..63446c95f 100644
--- a/frontend/src/config/env.ts
+++ b/frontend/src/config/env.ts
@@ -26,9 +26,6 @@ export const envConfig = {
       import.meta.env.VITE_TELEMETRY_CAPTURING_ENABLED === true
     );
   },
-  get ACME_FEATURE_ENABLED() {
-    return window?.__INFISICAL_RUNTIME_ENV__?.ACME_FEATURE_ENABLED ?? false;
-  },
 
   get PLATFORM_VERSION() {
     return import.meta.env.VITE_INFISICAL_PLATFORM_VERSION;
diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts
index b6fd85f44..a35451cd3 100644
--- a/frontend/src/context/ProjectPermissionContext/types.ts
+++ b/frontend/src/context/ProjectPermissionContext/types.ts
@@ -78,7 +78,11 @@ export enum ProjectPermissionIdentityActions {
   Edit = "edit",
   Delete = "delete",
   GrantPrivileges = "grant-privileges",
-  AssumePrivileges = "assume-privileges"
+  AssumePrivileges = "assume-privileges",
+  RevokeAuth = "revoke-auth",
+  CreateToken = "create-token",
+  GetToken = "get-token",
+  DeleteToken = "delete-token"
 }
 
 export enum ProjectPermissionMemberActions {
diff --git a/frontend/src/global.d.ts b/frontend/src/global.d.ts
index a6de7ac8c..30b80cb70 100644
--- a/frontend/src/global.d.ts
+++ b/frontend/src/global.d.ts
@@ -7,7 +7,6 @@ declare global {
       POSTHOG_API_KEY?: string;
       INTERCOM_ID?: string;
       TELEMETRY_CAPTURING_ENABLED: string;
-      ACME_FEATURE_ENABLED?: boolean;
     };
   }
 }
diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx
index b465474e8..796ab7b78 100644
--- a/frontend/src/hooks/api/auditLogs/constants.tsx
+++ b/frontend/src/hooks/api/auditLogs/constants.tsx
@@ -251,6 +251,17 @@ export const eventToNameMap: { [K in EventType]: string } = {
   [EventType.UPDATE_ORG_ROLE]: "Update Org Role",
   [EventType.DELETE_ORG_ROLE]: "Delete Org Role",
 
+  [EventType.CREATE_SUB_ORGANIZATION]: "Create Sub Organization",
+  [EventType.UPDATE_SUB_ORGANIZATION]: "Update Sub Organization",
+
+  [EventType.CREATE_IDENTITY_ORG_MEMBERSHIP]: "Create Identity Org Membership",
+  [EventType.UPDATE_IDENTITY_ORG_MEMBERSHIP]: "Update Identity Org Membership",
+  [EventType.DELETE_IDENTITY_ORG_MEMBERSHIP]: "Delete Identity Org Membership",
+
+  [EventType.CREATE_IDENTITY_PROJECT_MEMBERSHIP]: "Create Identity Project Membership",
+  [EventType.UPDATE_IDENTITY_PROJECT_MEMBERSHIP]: "Update Identity Project Membership",
+  [EventType.DELETE_IDENTITY_PROJECT_MEMBERSHIP]: "Delete Identity Project Membership",
+
   [EventType.PAM_SESSION_START]: "PAM Session Start",
   [EventType.PAM_SESSION_LOGS_UPDATE]: "PAM Session Logs Update",
   [EventType.PAM_SESSION_END]: "PAM Session End",
diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx
index 995d22624..cf104dfae 100644
--- a/frontend/src/hooks/api/auditLogs/enums.tsx
+++ b/frontend/src/hooks/api/auditLogs/enums.tsx
@@ -243,6 +243,17 @@ export enum EventType {
   UPDATE_ORG_ROLE = "update-org-role",
   DELETE_ORG_ROLE = "delete-org-role",
 
+  CREATE_SUB_ORGANIZATION = "create-sub-organization",
+  UPDATE_SUB_ORGANIZATION = "update-sub-organization",
+
+  CREATE_IDENTITY_ORG_MEMBERSHIP = "create-identity-org-membership",
+  UPDATE_IDENTITY_ORG_MEMBERSHIP = "update-identity-org-membership",
+  DELETE_IDENTITY_ORG_MEMBERSHIP = "delete-identity-org-membership",
+
+  CREATE_IDENTITY_PROJECT_MEMBERSHIP = "create-identity-project-membership",
+  UPDATE_IDENTITY_PROJECT_MEMBERSHIP = "update-identity-project-membership",
+  DELETE_IDENTITY_PROJECT_MEMBERSHIP = "delete-identity-project-membership",
+
   PAM_SESSION_START = "pam-session-start",
   PAM_SESSION_LOGS_UPDATE = "pam-session-logs-update",
   PAM_SESSION_END = "pam-session-end",
diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx
index 7d9c5df08..bc7f80d2c 100644
--- a/frontend/src/hooks/api/certificates/index.tsx
+++ b/frontend/src/hooks/api/certificates/index.tsx
@@ -1,6 +1,7 @@
 export { CertStatus } from "./enums";
 export {
   useDeleteCert,
+  useDownloadCertPkcs12,
   useImportCertificate,
   useRenewCertificate,
   useRevokeCert,
diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx
index 2ee470551..a6daf0491 100644
--- a/frontend/src/hooks/api/certificates/mutations.tsx
+++ b/frontend/src/hooks/api/certificates/mutations.tsx
@@ -7,6 +7,7 @@ import { projectKeys } from "../projects";
 import {
   TCertificate,
   TDeleteCertDTO,
+  TDownloadPkcs12DTO,
   TImportCertificateDTO,
   TImportCertificateResponse,
   TRenewCertificateDTO,
@@ -134,3 +135,42 @@ export const useUpdateRenewalConfig = () => {
     }
   });
 };
+
+export const useDownloadCertPkcs12 = () => {
+  return useMutation({
+    mutationFn: async ({ serialNumber, projectSlug, password, alias }) => {
+      try {
+        const response = await apiRequest.post(
+          `/api/v1/pki/certificates/${serialNumber}/pkcs12`,
+          {
+            password,
+            alias
+          },
+          {
+            params: { projectSlug },
+            responseType: "arraybuffer"
+          }
+        );
+
+        // Create blob and trigger download
+        const blob = new Blob([response.data], { type: "application/octet-stream" });
+        const url = window.URL.createObjectURL(blob);
+        const link = document.createElement("a");
+        link.href = url;
+        link.download = `certificate-${serialNumber}.p12`;
+        document.body.appendChild(link);
+        link.click();
+        document.body.removeChild(link);
+        window.URL.revokeObjectURL(url);
+      } catch (error: any) {
+        if (error.response?.data instanceof ArrayBuffer) {
+          const decoder = new TextDecoder();
+          const errorText = decoder.decode(error.response.data);
+          const errorData = JSON.parse(errorText);
+          throw new Error(errorData.message);
+        }
+        throw error;
+      }
+    }
+  });
+};
diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts
index adfb815a6..5e950b9c9 100644
--- a/frontend/src/hooks/api/certificates/types.ts
+++ b/frontend/src/hooks/api/certificates/types.ts
@@ -72,3 +72,10 @@ export type TUpdateRenewalConfigDTO = {
   enableAutoRenewal?: boolean;
   projectSlug: string;
 };
+
+export type TDownloadPkcs12DTO = {
+  serialNumber: string;
+  projectSlug: string;
+  password: string;
+  alias: string;
+};
diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx
index 4ada1fb9b..14903eaef 100644
--- a/frontend/src/hooks/api/identities/mutations.tsx
+++ b/frontend/src/hooks/api/identities/mutations.tsx
@@ -1,9 +1,9 @@
 import { useMutation, useQueryClient } from "@tanstack/react-query";
 
 import { apiRequest } from "@app/config/request";
+import { projectIdentityQuery, projectKeys } from "@app/hooks/api";
 
 import { organizationKeys } from "../organization/queries";
-import { subscriptionQueryKeys } from "../subscriptions/queries";
 import { identitiesKeys } from "./queries";
 import {
   AddIdentityAliCloudAuthDTO,
@@ -21,7 +21,6 @@ import {
   ClearIdentityLdapAuthLockoutsDTO,
   ClearIdentityUniversalAuthLockoutsDTO,
   ClientSecretData,
-  CreateIdentityDTO,
   CreateIdentityUniversalAuthClientSecretDTO,
   CreateIdentityUniversalAuthClientSecretRes,
   CreateTokenIdentityTokenAuthDTO,
@@ -29,7 +28,6 @@ import {
   DeleteIdentityAliCloudAuthDTO,
   DeleteIdentityAwsAuthDTO,
   DeleteIdentityAzureAuthDTO,
-  DeleteIdentityDTO,
   DeleteIdentityGcpAuthDTO,
   DeleteIdentityJwtAuthDTO,
   DeleteIdentityKubernetesAuthDTO,
@@ -40,7 +38,6 @@ import {
   DeleteIdentityTokenAuthDTO,
   DeleteIdentityUniversalAuthClientSecretDTO,
   DeleteIdentityUniversalAuthDTO,
-  Identity,
   IdentityAccessToken,
   IdentityAliCloudAuth,
   IdentityAwsAuth,
@@ -59,7 +56,6 @@ import {
   UpdateIdentityAliCloudAuthDTO,
   UpdateIdentityAwsAuthDTO,
   UpdateIdentityAzureAuthDTO,
-  UpdateIdentityDTO,
   UpdateIdentityGcpAuthDTO,
   UpdateIdentityJwtAuthDTO,
   UpdateIdentityKubernetesAuthDTO,
@@ -72,73 +68,6 @@ import {
   UpdateTokenIdentityTokenAuthDTO
 } from "./types";
 
-export const useCreateIdentity = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async (body) => {
-      const {
-        data: { identity }
-      } = await apiRequest.post("/api/v1/identities/", body);
-      return identity;
-    },
-    onSuccess: (_, { organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId)
-      });
-      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
-    }
-  });
-};
-
-export const useUpdateIdentity = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async ({ identityId, name, role, hasDeleteProtection, metadata }) => {
-      const {
-        data: { identity }
-      } = await apiRequest.patch(`/api/v1/identities/${identityId}`, {
-        name,
-        role,
-        hasDeleteProtection,
-        metadata
-      });
-
-      return identity;
-    },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
-      queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
-      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
-    }
-  });
-};
-
-export const useDeleteIdentity = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async ({ identityId }) => {
-      const {
-        data: { identity }
-      } = await apiRequest.delete(`/api/v1/identities/${identityId}`);
-      return identity;
-    },
-    onSuccess: (_, { organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId)
-      });
-      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
-    }
-  });
-};
-
 // TODO: move these to /auth
 
 export const useAddIdentityUniversalAuth = () => {
@@ -171,10 +100,20 @@ export const useAddIdentityUniversalAuth = () => {
       });
       return identityUniversalAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityUniversalAuth(identityId)
@@ -215,10 +154,20 @@ export const useUpdateIdentityUniversalAuth = () => {
       });
       return identityUniversalAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityUniversalAuth(identityId)
@@ -236,10 +185,20 @@ export const useDeleteIdentityUniversalAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/universal-auth/identities/${identityId}`);
       return identityUniversalAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityUniversalAuth(identityId)
@@ -344,10 +303,20 @@ export const useAddIdentityGcpAuth = () => {
 
       return identityGcpAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityGcpAuth(identityId) });
     }
@@ -386,10 +355,20 @@ export const useUpdateIdentityGcpAuth = () => {
 
       return identityGcpAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityGcpAuth(identityId) });
     }
@@ -405,10 +384,20 @@ export const useDeleteIdentityGcpAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/gcp-auth/identities/${identityId}`);
       return identityGcpAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityGcpAuth(identityId) });
     }
@@ -445,10 +434,20 @@ export const useAddIdentityAwsAuth = () => {
 
       return identityAwsAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAwsAuth(identityId) });
     }
@@ -485,10 +484,20 @@ export const useUpdateIdentityAwsAuth = () => {
 
       return identityAwsAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAwsAuth(identityId) });
     }
@@ -504,10 +513,20 @@ export const useDeleteIdentityAwsAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/aws-auth/identities/${identityId}`);
       return identityAwsAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAwsAuth(identityId) });
     }
@@ -542,10 +561,20 @@ export const useAddIdentityOciAuth = () => {
 
       return identityOciAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOciAuth(identityId) });
     }
@@ -580,10 +609,20 @@ export const useUpdateIdentityOciAuth = () => {
 
       return identityOciAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOciAuth(identityId) });
     }
@@ -599,10 +638,20 @@ export const useDeleteIdentityOciAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/oci-auth/identities/${identityId}`);
       return identityOciAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOciAuth(identityId) });
     }
@@ -635,10 +684,20 @@ export const useAddIdentityAliCloudAuth = () => {
 
       return identityAliCloudAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
@@ -673,10 +732,20 @@ export const useUpdateIdentityAliCloudAuth = () => {
 
       return identityAliCloudAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
@@ -694,10 +763,20 @@ export const useDeleteIdentityAliCloudAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/alicloud-auth/identities/${identityId}`);
       return identityAliCloudAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityAliCloudAuth(identityId)
@@ -734,10 +813,20 @@ export const useAddIdentityTlsCertAuth = () => {
 
       return identityTlsCertAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
@@ -774,10 +863,20 @@ export const useUpdateIdentityTlsCertAuth = () => {
 
       return identityTlsCertAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
@@ -795,10 +894,20 @@ export const useDeleteIdentityTlsCertAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/tls-cert-auth/identities/${identityId}`);
       return identityTlsCertAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityTlsCertAuth(identityId)
@@ -845,10 +954,20 @@ export const useUpdateIdentityOidcAuth = () => {
 
       return identityOidcAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOidcAuth(identityId) });
     }
@@ -893,10 +1012,20 @@ export const useAddIdentityOidcAuth = () => {
 
       return identityOidcAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOidcAuth(identityId) });
     }
@@ -912,10 +1041,20 @@ export const useDeleteIdentityOidcAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/oidc-auth/identities/${identityId}`);
       return identityOidcAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityOidcAuth(identityId) });
     }
@@ -961,10 +1100,20 @@ export const useUpdateIdentityJwtAuth = () => {
 
       return identityJwtAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityJwtAuth(identityId) });
     }
@@ -1011,10 +1160,20 @@ export const useAddIdentityJwtAuth = () => {
 
       return identityJwtAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityJwtAuth(identityId) });
     }
@@ -1030,10 +1189,20 @@ export const useDeleteIdentityJwtAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/jwt-auth/identities/${identityId}`);
       return identityJwtAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityJwtAuth(identityId) });
     }
@@ -1070,10 +1239,20 @@ export const useAddIdentityAzureAuth = () => {
 
       return identityAzureAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId)
@@ -1122,10 +1301,20 @@ export const useAddIdentityKubernetesAuth = () => {
 
       return identityKubernetesAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAzureAuth(identityId) });
     }
@@ -1162,10 +1351,20 @@ export const useUpdateIdentityAzureAuth = () => {
 
       return identityAzureAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAzureAuth(identityId) });
     }
@@ -1181,10 +1380,20 @@ export const useDeleteIdentityAzureAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/azure-auth/identities/${identityId}`);
       return identityAzureAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityAzureAuth(identityId) });
     }
@@ -1231,10 +1440,20 @@ export const useUpdateIdentityKubernetesAuth = () => {
 
       return identityKubernetesAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId)
@@ -1252,10 +1471,20 @@ export const useDeleteIdentityKubernetesAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/kubernetes-auth/identities/${identityId}`);
       return identityKubernetesAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityKubernetesAuth(identityId)
@@ -1288,10 +1517,20 @@ export const useAddIdentityTokenAuth = () => {
 
       return identityTokenAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityTokenAuth(identityId)
@@ -1324,10 +1563,20 @@ export const useUpdateIdentityTokenAuth = () => {
 
       return identityTokenAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityTokenAuth(identityId)
@@ -1345,10 +1594,20 @@ export const useDeleteIdentityTokenAuth = () => {
       } = await apiRequest.delete(`/api/v1/auth/token-auth/identities/${identityId}`);
       return identityTokenAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityTokenAuth(identityId) });
     }
@@ -1462,10 +1721,20 @@ export const useAddIdentityLdapAuth = () => {
       );
       return data.identityLdapAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityLdapAuth(identityId)
@@ -1519,10 +1788,20 @@ export const useUpdateIdentityLdapAuth = () => {
       );
       return data.identityLdapAuth;
     },
-    onSuccess: (_, { identityId, organizationId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { identityId, organizationId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityLdapAuth(identityId)
@@ -1538,10 +1817,20 @@ export const useDeleteIdentityLdapAuth = () => {
       const { data } = await apiRequest.delete(`/api/v1/auth/ldap-auth/identities/${identityId}`);
       return data.identityLdapAuth;
     },
-    onSuccess: (_, { organizationId, identityId }) => {
-      queryClient.invalidateQueries({
-        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
-      });
+    onSuccess: (_, { organizationId, identityId, projectId }) => {
+      if (organizationId) {
+        queryClient.invalidateQueries({
+          queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+        });
+      }
+      if (projectId) {
+        queryClient.invalidateQueries({
+          queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+        });
+        queryClient.invalidateQueries({
+          queryKey: projectIdentityQuery.getByIdKey({ identityId, projectId })
+        });
+      }
       queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
       queryClient.invalidateQueries({
         queryKey: identitiesKeys.getIdentityLdapAuth(identityId)
diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx
index cf7f4be87..866bcac80 100644
--- a/frontend/src/hooks/api/identities/queries.tsx
+++ b/frontend/src/hooks/api/identities/queries.tsx
@@ -13,10 +13,10 @@ import {
   IdentityJwtAuth,
   IdentityKubernetesAuth,
   IdentityLdapAuth,
-  IdentityMembership,
   IdentityMembershipOrg,
   IdentityOciAuth,
   IdentityOidcAuth,
+  IdentityProjectMembershipV1,
   IdentityTlsCertAuth,
   IdentityTokenAuth,
   IdentityUniversalAuth,
@@ -52,7 +52,7 @@ export const identitiesKeys = {
     [{ identityId }, "identity-project-memberships"] as const
 };
 
-export const useGetIdentityById = (identityId: string) => {
+export const useGetOrgIdentityMembershipById = (identityId: string) => {
   return useQuery({
     enabled: Boolean(identityId),
     queryKey: identitiesKeys.getIdentityById(identityId),
@@ -67,7 +67,7 @@ export const useGetIdentityById = (identityId: string) => {
   });
 };
 
-export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => {
+export const useSearchOrgIdentityMemberships = (dto: TSearchIdentitiesDTO) => {
   const { limit, search, offset, orderBy, orderDirection } = dto;
   return useQuery({
     queryKey: identitiesKeys.searchIdentities(dto),
@@ -95,7 +95,7 @@ export const useGetIdentityProjectMemberships = (identityId: string) => {
     queryFn: async () => {
       const {
         data: { identityMemberships }
-      } = await apiRequest.get<{ identityMemberships: IdentityMembership[] }>(
+      } = await apiRequest.get<{ identityMemberships: IdentityProjectMembershipV1[] }>(
         `/api/v1/identities/${identityId}/identity-memberships`
       );
       return identityMemberships;
diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts
index b7be75e1c..b28e9385b 100644
--- a/frontend/src/hooks/api/identities/types.ts
+++ b/frontend/src/hooks/api/identities/types.ts
@@ -1,6 +1,8 @@
+import { TemporaryPermissionMode } from "@app/hooks/api/shared";
+
 import { OrderByDirection } from "../generic/types";
 import { OrgIdentityOrderBy } from "../organization/types";
-import { Project, ProjectUserMembershipTemporaryMode } from "../projects/types";
+import { Project } from "../projects/types";
 import { TOrgRole } from "../roles/types";
 import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums";
 
@@ -21,6 +23,8 @@ export type Identity = {
   updatedAt: string;
   isInstanceAdmin?: boolean;
   orgId: string;
+  projectId?: string | null;
+  metadata?: { key: string; value: string; id: string }[];
 };
 
 export type IdentityAccessToken = {
@@ -52,7 +56,7 @@ export type IdentityMembershipOrg = {
   updatedAt: string;
 };
 
-export type IdentityMembership = {
+export type IdentityProjectMembershipV1 = {
   id: string;
   identity: Identity;
   project: Pick;
@@ -74,7 +78,7 @@ export type IdentityMembership = {
       | {
           isTemporary: true;
           temporaryRange: string;
-          temporaryMode: ProjectUserMembershipTemporaryMode;
+          temporaryMode: TemporaryPermissionMode;
           temporaryAccessEndTime: string;
           temporaryAccessStartTime: string;
         }
@@ -82,6 +86,41 @@ export type IdentityMembership = {
   >;
   createdAt: string;
   updatedAt: string;
+  lastLoginTime?: string;
+  lastLoginAuthMethod?: IdentityAuthMethod;
+};
+
+export type IdentityProjectMembershipV2 = {
+  id: string;
+  identity: Identity;
+  roles: Array<
+    {
+      id: string;
+      role: "owner" | "admin" | "member" | "no-access" | "custom";
+      customRoleId: string;
+      customRoleName: string;
+      customRoleSlug: string;
+    } & (
+      | {
+          isTemporary: false;
+          temporaryRange: null;
+          temporaryMode: null;
+          temporaryAccessEndTime: null;
+          temporaryAccessStartTime: null;
+        }
+      | {
+          isTemporary: true;
+          temporaryRange: string;
+          temporaryMode: TemporaryPermissionMode;
+          temporaryAccessEndTime: string;
+          temporaryAccessStartTime: string;
+        }
+    )
+  >;
+  createdAt: string;
+  updatedAt: string;
+  lastLoginTime?: string;
+  lastLoginAuthMethod?: IdentityAuthMethod;
 };
 
 export type CreateIdentityDTO = {
@@ -122,7 +161,8 @@ export type IdentityUniversalAuth = {
 };
 
 export type AddIdentityUniversalAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   clientSecretTrustedIps: {
     ipAddress: string;
@@ -138,10 +178,11 @@ export type AddIdentityUniversalAuthDTO = {
   lockoutThreshold: number;
   lockoutDurationSeconds: number;
   lockoutCounterResetSeconds: number;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityUniversalAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   clientSecretTrustedIps?: {
     ipAddress: string;
@@ -157,12 +198,13 @@ export type UpdateIdentityUniversalAuthDTO = {
   lockoutThreshold?: number;
   lockoutDurationSeconds?: number;
   lockoutCounterResetSeconds?: number;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityUniversalAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityGcpAuth = {
   identityId: string;
@@ -177,7 +219,8 @@ export type IdentityGcpAuth = {
 };
 
 export type AddIdentityGcpAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   type: "iam" | "gce";
   allowedServiceAccounts: string;
@@ -189,10 +232,11 @@ export type AddIdentityGcpAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityGcpAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   type?: "iam" | "gce";
   allowedServiceAccounts?: string;
@@ -204,12 +248,13 @@ export type UpdateIdentityGcpAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityGcpAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityOidcAuth = {
   identityId: string;
@@ -227,7 +272,8 @@ export type IdentityOidcAuth = {
 };
 
 export type AddIdentityOidcAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   oidcDiscoveryUrl: string;
   caCert: string;
@@ -242,10 +288,11 @@ export type AddIdentityOidcAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityOidcAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   oidcDiscoveryUrl?: string;
   caCert?: string;
@@ -260,12 +307,13 @@ export type UpdateIdentityOidcAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityOidcAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityAwsAuth = {
   identityId: string;
@@ -280,7 +328,8 @@ export type IdentityAwsAuth = {
 };
 
 export type AddIdentityAwsAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   stsEndpoint: string;
   allowedPrincipalArns: string;
@@ -291,10 +340,11 @@ export type AddIdentityAwsAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityAwsAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   stsEndpoint?: string;
   allowedPrincipalArns?: string;
@@ -308,9 +358,10 @@ export type UpdateIdentityAwsAuthDTO = {
 };
 
 export type DeleteIdentityAwsAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityAliCloudAuth = {
   identityId: string;
@@ -323,7 +374,8 @@ export type IdentityAliCloudAuth = {
 };
 
 export type AddIdentityAliCloudAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   allowedArns: string;
   accessTokenTTL: number;
@@ -332,10 +384,11 @@ export type AddIdentityAliCloudAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityAliCloudAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   allowedArns: string;
   accessTokenTTL?: number;
@@ -344,12 +397,13 @@ export type UpdateIdentityAliCloudAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityAliCloudAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityOciAuth = {
   identityId: string;
@@ -363,7 +417,8 @@ export type IdentityOciAuth = {
 };
 
 export type AddIdentityOciAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   tenancyOcid: string;
   allowedUsernames?: string | null;
@@ -373,10 +428,11 @@ export type AddIdentityOciAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityOciAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   tenancyOcid?: string;
   allowedUsernames?: string | null;
@@ -386,12 +442,13 @@ export type UpdateIdentityOciAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityOciAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityAzureAuth = {
   identityId: string;
@@ -405,7 +462,8 @@ export type IdentityAzureAuth = {
 };
 
 export type AddIdentityAzureAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   tenantId: string;
   resource: string;
@@ -416,10 +474,11 @@ export type AddIdentityAzureAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityAzureAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   tenantId?: string;
   resource?: string;
@@ -430,12 +489,13 @@ export type UpdateIdentityAzureAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityAzureAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export enum IdentityKubernetesAuthTokenReviewMode {
   Api = "api",
@@ -459,7 +519,8 @@ export type IdentityKubernetesAuth = {
 };
 
 export type AddIdentityKubernetesAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   kubernetesHost: string | null;
   tokenReviewerJwt?: string;
@@ -475,10 +536,11 @@ export type AddIdentityKubernetesAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityKubernetesAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   kubernetesHost?: string | null;
   tokenReviewerJwt?: string | null;
@@ -494,12 +556,13 @@ export type UpdateIdentityKubernetesAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityKubernetesAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityTlsCertAuth = {
   identityId: string;
@@ -512,7 +575,8 @@ export type IdentityTlsCertAuth = {
 };
 
 export type AddIdentityTlsCertAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   caCertificate: string;
   allowedCommonNames?: string;
@@ -522,10 +586,11 @@ export type AddIdentityTlsCertAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityTlsCertAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   caCertificate: string;
   allowedCommonNames?: string | null;
@@ -535,12 +600,13 @@ export type UpdateIdentityTlsCertAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityTlsCertAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type CreateIdentityUniversalAuthClientSecretDTO = {
   identityId: string;
@@ -585,7 +651,8 @@ export type IdentityTokenAuth = {
 };
 
 export type AddIdentityLdapAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   templateId?: string;
   url?: string;
@@ -609,11 +676,12 @@ export type AddIdentityLdapAuthDTO = {
   lockoutThreshold: number;
   lockoutDurationSeconds: number;
   lockoutCounterResetSeconds: number;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityLdapAuthDTO = {
   identityId: string;
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   templateId?: string;
   url?: string;
   bindDN?: string;
@@ -636,12 +704,13 @@ export type UpdateIdentityLdapAuthDTO = {
   lockoutThreshold?: number;
   lockoutDurationSeconds?: number;
   lockoutCounterResetSeconds?: number;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityLdapAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityLdapAuth = {
   url?: string;
@@ -673,7 +742,8 @@ export type ClearIdentityLdapAuthLockoutsDTO = {
 };
 
 export type AddIdentityTokenAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   accessTokenTTL: number;
   accessTokenMaxTTL: number;
@@ -681,10 +751,11 @@ export type AddIdentityTokenAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityTokenAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   accessTokenTTL?: number;
   accessTokenMaxTTL?: number;
@@ -692,12 +763,13 @@ export type UpdateIdentityTokenAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityTokenAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type IdentityJwtAuth = {
   identityId: string;
@@ -716,7 +788,8 @@ export type IdentityJwtAuth = {
 };
 
 export type AddIdentityJwtAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   configurationType: string;
   jwksUrl?: string;
@@ -732,10 +805,11 @@ export type AddIdentityJwtAuthDTO = {
   accessTokenTrustedIps: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type UpdateIdentityJwtAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
   configurationType?: string;
   jwksUrl?: string;
@@ -751,12 +825,13 @@ export type UpdateIdentityJwtAuthDTO = {
   accessTokenTrustedIps?: {
     ipAddress: string;
   }[];
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type DeleteIdentityJwtAuthDTO = {
-  organizationId: string;
+  organizationId?: string;
+  projectId?: string;
   identityId: string;
-};
+} & ({ organizationId: string } | { projectId: string });
 
 export type CreateTokenIdentityTokenAuthDTO = {
   identityId: string;
@@ -783,8 +858,13 @@ export type RevokeTokenRes = {
   message: string;
 };
 
-export type TProjectIdentitiesList = {
-  identityMemberships: IdentityMembership[];
+export type TProjectIdentityMembershipsList = {
+  identityMemberships: IdentityProjectMembershipV1[];
+  totalCount: number;
+};
+
+export type TProjectIdentityMembershipsListV2 = {
+  identityMemberships: IdentityProjectMembershipV2[];
   totalCount: number;
 };
 
diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx
index 0216ae030..33eacd18d 100644
--- a/frontend/src/hooks/api/index.tsx
+++ b/frontend/src/hooks/api/index.tsx
@@ -25,10 +25,14 @@ export * from "./ldapConfig";
 export * from "./oidcConfig";
 export * from "./orgAdmin";
 export * from "./organization";
+export * from "./orgIdentity";
+export * from "./orgIdentityMembership";
 export * from "./pkiAlerts";
 export * from "./pkiCollections";
 export * from "./pkiSubscriber";
 export * from "./pkiSyncs";
+export * from "./projectIdentity";
+export * from "./projectIdentityMembership";
 export * from "./projects";
 export * from "./projectUserAdditionalPrivilege";
 export * from "./rateLimit";
diff --git a/frontend/src/hooks/api/orgIdentity/index.ts b/frontend/src/hooks/api/orgIdentity/index.ts
new file mode 100644
index 000000000..60fb072cc
--- /dev/null
+++ b/frontend/src/hooks/api/orgIdentity/index.ts
@@ -0,0 +1,3 @@
+export * from "./mutations";
+export * from "./queries";
+export type * from "./types";
diff --git a/frontend/src/hooks/api/orgIdentity/mutations.tsx b/frontend/src/hooks/api/orgIdentity/mutations.tsx
new file mode 100644
index 000000000..acc863279
--- /dev/null
+++ b/frontend/src/hooks/api/orgIdentity/mutations.tsx
@@ -0,0 +1,116 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import { identitiesKeys } from "@app/hooks/api";
+import { CreateIdentityDTO, Identity, UpdateIdentityDTO } from "@app/hooks/api/identities/types";
+import { organizationKeys } from "@app/hooks/api/organization/queries";
+import { subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries";
+
+import { orgIdentityQuery } from "./queries";
+import { TDeleteOrgIdentityDTO, TOrgIdentity } from "./types";
+
+// TODO (scott/akhi): eventually move to the new api commented out below; the current ones use old api
+
+export const useCreateOrgIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async (body) => {
+      const {
+        data: { identity }
+      } = await apiRequest.post("/api/v1/identities/", body);
+      return identity;
+    },
+    onSuccess: (_, { organizationId }) => {
+      queryClient.invalidateQueries({
+        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: subscriptionQueryKeys.getOrgSubsription(organizationId)
+      });
+      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
+    }
+  });
+};
+
+export const useUpdateOrgIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ identityId, name, role, hasDeleteProtection, metadata }) => {
+      const {
+        data: { identity }
+      } = await apiRequest.patch(`/api/v1/identities/${identityId}`, {
+        name,
+        role,
+        hasDeleteProtection,
+        metadata
+      });
+
+      return identity;
+    },
+    onSuccess: (_, { organizationId, identityId }) => {
+      queryClient.invalidateQueries({
+        queryKey: organizationKeys.getOrgIdentityMemberships(organizationId)
+      });
+      queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(identityId) });
+      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
+    }
+  });
+};
+
+// export const useCreateOrgIdentity = () => {
+//   const queryClient = useQueryClient();
+//   return useMutation({
+//     mutationFn: async (dto: TCreateOrgIdentityDTO) => {
+//       const { data } = await apiRequest.post<{ identity: TOrgIdentity }>(
+//         "/api/v1/organization/identities",
+//         dto
+//       );
+//       return data;
+//     },
+//     onSuccess: () => {
+//       queryClient.invalidateQueries({ queryKey: orgIdentityQuery.allKey() });
+//       queryClient.invalidateQueries({
+//         queryKey: subscriptionQueryKeys.all()
+//       });
+//     }
+//   });
+// };
+//
+// export const useUpdateOrgIdentity = () => {
+//   const queryClient = useQueryClient();
+//   return useMutation({
+//     mutationFn: async ({ identityId, ...updates }: TUpdateOrgIdentityDTO) => {
+//       const { data } = await apiRequest.patch<{ identity: TOrgIdentity }>(
+//         `/api/v1/organization/identities/${identityId}`,
+//         updates
+//       );
+//       return data;
+//     },
+//     onSuccess: () => {
+//       queryClient.invalidateQueries({ queryKey: orgIdentityQuery.allKey() });
+//     }
+//   });
+// };
+
+export const useDeleteOrgIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ identityId }: TDeleteOrgIdentityDTO) => {
+      const { data } = await apiRequest.delete<{ identity: TOrgIdentity }>(
+        `/api/v1/identities/${identityId}`
+      );
+      return data;
+    },
+    onSuccess: (_, { orgId }) => {
+      queryClient.invalidateQueries({
+        queryKey: organizationKeys.getOrgIdentityMemberships(orgId)
+      });
+      queryClient.invalidateQueries({ queryKey: identitiesKeys.searchIdentitiesRoot });
+      queryClient.invalidateQueries({ queryKey: identitiesKeys.getIdentityById(orgId) });
+      queryClient.invalidateQueries({ queryKey: orgIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: subscriptionQueryKeys.all()
+      });
+    }
+  });
+};
diff --git a/frontend/src/hooks/api/orgIdentity/queries.tsx b/frontend/src/hooks/api/orgIdentity/queries.tsx
new file mode 100644
index 000000000..0f0a74992
--- /dev/null
+++ b/frontend/src/hooks/api/orgIdentity/queries.tsx
@@ -0,0 +1,40 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { TGetOrgIdentityByIdDTO, TListOrgIdentitiesDTO, TOrgIdentity } from "./types";
+
+export const orgIdentityQuery = {
+  allKey: () => ["organization-identities"] as const,
+  getByIdKey: (params: TGetOrgIdentityByIdDTO) =>
+    [...orgIdentityQuery.allKey(), "by-id", params] as const,
+  listKey: (params?: TListOrgIdentitiesDTO) =>
+    [...orgIdentityQuery.allKey(), "list", params] as const,
+  getById: (params: TGetOrgIdentityByIdDTO) =>
+    queryOptions({
+      queryKey: orgIdentityQuery.getByIdKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{ identity: TOrgIdentity }>(
+          `/api/v1/organization/identities/${params.identityId}`
+        );
+        return data.identity;
+      }
+    }),
+  list: (params: TListOrgIdentitiesDTO = {}) =>
+    queryOptions({
+      queryKey: orgIdentityQuery.listKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{
+          identities: TOrgIdentity[];
+          totalCount: number;
+        }>("/api/v1/organization/identities", {
+          params: {
+            offset: params.offset,
+            limit: params.limit,
+            search: params.search
+          }
+        });
+        return data;
+      }
+    })
+};
diff --git a/frontend/src/hooks/api/orgIdentity/types.ts b/frontend/src/hooks/api/orgIdentity/types.ts
new file mode 100644
index 000000000..03f74a23c
--- /dev/null
+++ b/frontend/src/hooks/api/orgIdentity/types.ts
@@ -0,0 +1,31 @@
+import { TIdentity, TMetadata } from "@app/hooks/api/shared";
+
+export type TOrgIdentity = TIdentity;
+
+export type TCreateOrgIdentityDTO = {
+  name: string;
+  hasDeleteProtection?: boolean;
+  metadata?: TMetadata;
+};
+
+export type TUpdateOrgIdentityDTO = {
+  identityId: string;
+  name?: string;
+  hasDeleteProtection?: boolean;
+  metadata?: TMetadata;
+};
+
+export type TGetOrgIdentityByIdDTO = {
+  identityId: string;
+};
+
+export type TListOrgIdentitiesDTO = {
+  offset?: number;
+  limit?: number;
+  search?: string;
+};
+
+export type TDeleteOrgIdentityDTO = {
+  identityId: string;
+  orgId: string;
+};
diff --git a/frontend/src/hooks/api/orgIdentityMembership/queries.tsx b/frontend/src/hooks/api/orgIdentityMembership/queries.tsx
new file mode 100644
index 000000000..d0cad6993
--- /dev/null
+++ b/frontend/src/hooks/api/orgIdentityMembership/queries.tsx
@@ -0,0 +1,31 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import {
+  TAvailableOrganizationIdentities,
+  TListAvailableOrganizationIdentitiesDTO,
+  TListOrgIdentityMembershipsDTO
+} from "./types";
+
+export const orgIdentityMembershipQuery = {
+  allKey: () => ["organization-identity-memberships"] as const,
+  listAvailableKey: (params?: TListOrgIdentityMembershipsDTO) =>
+    [...orgIdentityMembershipQuery.allKey(), "list-available", params] as const,
+  listAvailable: (params: TListAvailableOrganizationIdentitiesDTO = {}) =>
+    queryOptions({
+      queryKey: orgIdentityMembershipQuery.listAvailableKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{
+          identities: TAvailableOrganizationIdentities;
+        }>("/api/v1/organization/available-identities", {
+          params: {
+            offset: params.offset,
+            limit: params.limit,
+            identityName: params.identityName
+          }
+        });
+        return data.identities;
+      }
+    })
+};
diff --git a/frontend/src/hooks/api/orgIdentityMembership/types.ts b/frontend/src/hooks/api/orgIdentityMembership/types.ts
index 95fa06b82..2ec8dc3d4 100644
--- a/frontend/src/hooks/api/orgIdentityMembership/types.ts
+++ b/frontend/src/hooks/api/orgIdentityMembership/types.ts
@@ -1,6 +1,4 @@
-export enum TemporaryPermissionMode {
-  Relative = "relative"
-}
+import { TRoles } from "@app/hooks/api/shared";
 
 export type TOrgIdentityMembership = {
   id: string;
@@ -12,21 +10,24 @@ export type TOrgIdentityMembership = {
 
 export type TCreateOrgIdentityMembershipDTO = {
   identityId: string;
-  roles: Array<
-    | {
-        role: string;
-        isTemporary?: false;
-      }
-    | {
-        role: string;
-        isTemporary: true;
-        temporaryMode: TemporaryPermissionMode;
-        temporaryRange: string;
-        temporaryAccessStartTime: string;
-      }
-  >;
+  roles: TRoles;
 };
 
 export type TDeleteOrgIdentityMembershipDTO = {
   identityId: string;
 };
+
+export type TListOrgIdentityMembershipsDTO = {
+  offset?: number;
+  limit?: number;
+  identityName?: string;
+  roles?: string[];
+};
+
+export type TListAvailableOrganizationIdentitiesDTO = {
+  offset?: number;
+  limit?: number;
+  identityName?: string;
+};
+
+export type TAvailableOrganizationIdentities = Array<{ id: string; name: string }>;
diff --git a/frontend/src/hooks/api/organization/index.ts b/frontend/src/hooks/api/organization/index.ts
index 7c283691e..f4627a614 100644
--- a/frontend/src/hooks/api/organization/index.ts
+++ b/frontend/src/hooks/api/organization/index.ts
@@ -6,7 +6,6 @@ export {
   useDeleteOrgById,
   useDeleteOrgPmtMethod,
   useDeleteOrgTaxId,
-  useGetAvailableOrgIdentities,
   useGetIdentityMembershipOrgs,
   useGetOrganizationGroups,
   useGetOrganizations,
diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx
index 4340f9718..9e2413b28 100644
--- a/frontend/src/hooks/api/organization/queries.tsx
+++ b/frontend/src/hooks/api/organization/queries.tsx
@@ -579,19 +579,6 @@ export const useGetOrgIntegrationAuths = (
   });
 };
 
-export const useGetAvailableOrgIdentities = (enabled = true) =>
-  useQuery({
-    queryKey: organizationKeys.getAvailableIdentities(),
-    queryFn: async () => {
-      const { data } = await apiRequest.get<{ identities: { name: string; id: string }[] }>(
-        "/api/v1/organization/identities/available"
-      );
-
-      return data.identities;
-    },
-    enabled
-  });
-
 export const useGetAvailableOrgUsers = (enabled = true) =>
   useQuery({
     queryKey: organizationKeys.getAvailableUsers(),
diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts
index 0684f6073..78a4f4c13 100644
--- a/frontend/src/hooks/api/pam/enums.ts
+++ b/frontend/src/hooks/api/pam/enums.ts
@@ -3,7 +3,19 @@ export enum PamResourceType {
   MySQL = "mysql",
   RDP = "rdp",
   SSH = "ssh",
-  Kubernetes = "kubernetes"
+  Kubernetes = "kubernetes",
+  OracleDB = "oracledb",
+  SQLite = "sqlite",
+  MsSQL = "mssql",
+  MCP = "mcp",
+  Redis = "redis",
+  MongoDB = "mongodb",
+  WebApp = "webapp",
+  Cassandra = "cassandra",
+  CockroachDB = "cockroachdb",
+  Elasticsearch = "elasticsearch",
+  Snowflake = "snowflake",
+  DynamoDB = "dynamodb"
 }
 
 export enum PamSessionStatus {
diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts
index c240a12ad..90286a05d 100644
--- a/frontend/src/hooks/api/pam/maps.ts
+++ b/frontend/src/hooks/api/pam/maps.ts
@@ -8,5 +8,17 @@ export const PAM_RESOURCE_TYPE_MAP: Record<
   [PamResourceType.MySQL]: { name: "MySQL", image: "MySql.png" },
   [PamResourceType.RDP]: { name: "RDP", image: "RDP.png" },
   [PamResourceType.SSH]: { name: "SSH", image: "SSH.png" },
-  [PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" }
+  [PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" },
+  [PamResourceType.OracleDB]: { name: "OracleDB", image: "Oracle.png", size: 55 },
+  [PamResourceType.SQLite]: { name: "SQLite", image: "SQLite.png" },
+  [PamResourceType.MsSQL]: { name: "Microsoft SQL Server", image: "MsSql.png" },
+  [PamResourceType.MCP]: { name: "MCP", image: "MCP.png" },
+  [PamResourceType.Redis]: { name: "Redis", image: "Redis.png" },
+  [PamResourceType.MongoDB]: { name: "MongoDB", image: "MongoDB.png" },
+  [PamResourceType.WebApp]: { name: "Web Application", image: "Web.png" },
+  [PamResourceType.Cassandra]: { name: "Cassandra", image: "Cassandra.png", size: 55 },
+  [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 }
 };
diff --git a/frontend/src/hooks/api/projectIdentity/index.tsx b/frontend/src/hooks/api/projectIdentity/index.tsx
new file mode 100644
index 000000000..e4810ccd0
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentity/index.tsx
@@ -0,0 +1,15 @@
+export {
+  useCreateProjectIdentity,
+  useDeleteProjectIdentity,
+  useUpdateProjectIdentity
+} from "./mutations";
+export { projectIdentityQuery } from "./queries";
+export type {
+  TCreateProjectIdentityDTO,
+  TDeleteProjectIdentityDTO,
+  TGetProjectIdentityByIdDTO,
+  TListProjectIdentitiesDTO,
+  TProjectIdentity,
+  TProjectIdentityMetadata,
+  TUpdateProjectIdentityDTO
+} from "./types";
diff --git a/frontend/src/hooks/api/projectIdentity/mutations.tsx b/frontend/src/hooks/api/projectIdentity/mutations.tsx
new file mode 100644
index 000000000..277ed76c9
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentity/mutations.tsx
@@ -0,0 +1,76 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import { identitiesKeys, projectKeys } from "@app/hooks/api";
+import { subscriptionQueryKeys } from "@app/hooks/api/subscriptions/queries";
+
+import { projectIdentityQuery } from "./queries";
+import {
+  TCreateProjectIdentityDTO,
+  TDeleteProjectIdentityDTO,
+  TProjectIdentity,
+  TUpdateProjectIdentityDTO
+} from "./types";
+
+export const useCreateProjectIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ projectId, ...dto }: TCreateProjectIdentityDTO) => {
+      const { data } = await apiRequest.post<{ identity: TProjectIdentity }>(
+        `/api/v1/projects/${projectId}/identities`,
+        dto
+      );
+      return data.identity;
+    },
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: subscriptionQueryKeys.all()
+      });
+    }
+  });
+};
+
+export const useUpdateProjectIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ projectId, identityId, ...updates }: TUpdateProjectIdentityDTO) => {
+      const { data } = await apiRequest.patch<{ identity: TProjectIdentity }>(
+        `/api/v1/projects/${projectId}/identities/${identityId}`,
+        updates
+      );
+      return data.identity;
+    },
+    onSuccess: (_, { projectId, identityId }) => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMembershipDetails(projectId, identityId)
+      });
+    }
+  });
+};
+
+export const useDeleteProjectIdentity = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ projectId, identityId }: TDeleteProjectIdentityDTO) => {
+      const { data } = await apiRequest.delete<{ identity: TProjectIdentity }>(
+        `/api/v1/projects/${projectId}/identities/${identityId}`
+      );
+      return data.identity;
+    },
+    onSuccess: (_, { projectId, identityId }) => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: subscriptionQueryKeys.all()
+      });
+    }
+  });
+};
diff --git a/frontend/src/hooks/api/projectIdentity/queries.tsx b/frontend/src/hooks/api/projectIdentity/queries.tsx
new file mode 100644
index 000000000..163cd8d74
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentity/queries.tsx
@@ -0,0 +1,40 @@
+import { queryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+
+import { TGetProjectIdentityByIdDTO, TListProjectIdentitiesDTO, TProjectIdentity } from "./types";
+
+export const projectIdentityQuery = {
+  allKey: () => ["project-identities"] as const,
+  getByIdKey: (params: TGetProjectIdentityByIdDTO) =>
+    [...projectIdentityQuery.allKey(), "by-id", params] as const,
+  listKey: (params: TListProjectIdentitiesDTO) =>
+    [...projectIdentityQuery.allKey(), "list", params] as const,
+  getById: (params: TGetProjectIdentityByIdDTO) =>
+    queryOptions({
+      queryKey: projectIdentityQuery.getByIdKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{ identity: TProjectIdentity }>(
+          `/api/v1/projects/${params.projectId}/identities/${params.identityId}`
+        );
+        return data.identity;
+      }
+    }),
+  list: (params: TListProjectIdentitiesDTO) =>
+    queryOptions({
+      queryKey: projectIdentityQuery.listKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{
+          identities: TProjectIdentity[];
+          totalCount: number;
+        }>(`/api/v1/projects/${params.projectId}/identities`, {
+          params: {
+            offset: params.offset,
+            limit: params.limit,
+            search: params.search
+          }
+        });
+        return data;
+      }
+    })
+};
diff --git a/frontend/src/hooks/api/projectIdentity/types.ts b/frontend/src/hooks/api/projectIdentity/types.ts
new file mode 100644
index 000000000..3e6700391
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentity/types.ts
@@ -0,0 +1,41 @@
+import { TIdentity, TMetadata } from "@app/hooks/api/shared";
+
+export type TProjectIdentityMetadata = {
+  key: string;
+  value: string;
+  id: string;
+};
+
+export type TProjectIdentity = TIdentity;
+
+export type TCreateProjectIdentityDTO = {
+  projectId: string;
+  name: string;
+  hasDeleteProtection?: boolean;
+  metadata?: TMetadata[];
+};
+
+export type TUpdateProjectIdentityDTO = {
+  projectId: string;
+  identityId: string;
+  name?: string;
+  hasDeleteProtection?: boolean;
+  metadata?: TMetadata[];
+};
+
+export type TGetProjectIdentityByIdDTO = {
+  projectId: string;
+  identityId: string;
+};
+
+export type TListProjectIdentitiesDTO = {
+  projectId: string;
+  offset?: number;
+  limit?: number;
+  search?: string;
+};
+
+export type TDeleteProjectIdentityDTO = {
+  projectId: string;
+  identityId: string;
+};
diff --git a/frontend/src/hooks/api/projectIdentityMembership/index.ts b/frontend/src/hooks/api/projectIdentityMembership/index.ts
new file mode 100644
index 000000000..177955438
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentityMembership/index.ts
@@ -0,0 +1,3 @@
+export * from "./mutations";
+export * from "./queries";
+export * from "./types";
diff --git a/frontend/src/hooks/api/projectIdentityMembership/mutations.tsx b/frontend/src/hooks/api/projectIdentityMembership/mutations.tsx
new file mode 100644
index 000000000..54b9158de
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentityMembership/mutations.tsx
@@ -0,0 +1,93 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import { identitiesKeys, projectKeys } from "@app/hooks/api";
+import { projectIdentityQuery } from "@app/hooks/api/projectIdentity";
+
+import {
+  TCreateProjectIdentityMembershipDTO,
+  TDeleteProjectIdentityMembershipDTO,
+  TProjectIdentityMembership,
+  TUpdateProjectIdentityMembershipDTO
+} from "./types";
+
+export const useCreateProjectIdentityMembership = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ identityId, projectId, role }: TCreateProjectIdentityMembershipDTO) => {
+      const {
+        data: { identityMembership }
+      } = await apiRequest.post<{ identityMembership: TProjectIdentityMembership }>(
+        `/api/v1/projects/${projectId}/memberships/identities/${identityId}`,
+        {
+          role
+        }
+      );
+
+      return identityMembership;
+    },
+    onSuccess: (_, { identityId, projectId }) => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
+      });
+    }
+  });
+};
+
+export const useUpdateProjectIdentityMembership = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({
+      projectId,
+      identityId,
+      ...updates
+    }: TUpdateProjectIdentityMembershipDTO) => {
+      const {
+        data: { identityMembership }
+      } = await apiRequest.patch<{ identityMembership: TProjectIdentityMembership }>(
+        `/api/v1/projects/${projectId}/memberships/identities/${identityId}`,
+        updates
+      );
+      return identityMembership;
+    },
+    onSuccess: (_, { projectId, identityId }) => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMembershipDetails(projectId, identityId)
+      });
+    }
+  });
+};
+
+export const useDeleteProjectIdentityMembership = () => {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: async ({ identityId, projectId }: TDeleteProjectIdentityMembershipDTO) => {
+      const {
+        data: { identityMembership }
+      } = await apiRequest.delete<{ identityMembership: TProjectIdentityMembership }>(
+        `/api/v1/projects/${projectId}/memberships/identities/${identityId}`
+      );
+      return identityMembership;
+    },
+    onSuccess: (_, { identityId, projectId }) => {
+      queryClient.invalidateQueries({ queryKey: projectIdentityQuery.allKey() });
+      queryClient.invalidateQueries({
+        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
+      });
+      queryClient.invalidateQueries({
+        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
+      });
+    }
+  });
+};
diff --git a/frontend/src/hooks/api/projectIdentityMembership/queries.ts b/frontend/src/hooks/api/projectIdentityMembership/queries.ts
new file mode 100644
index 000000000..3f67f3fbf
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentityMembership/queries.ts
@@ -0,0 +1,116 @@
+import { queryOptions, useQuery, UseQueryOptions } from "@tanstack/react-query";
+
+import { apiRequest } from "@app/config/request";
+import {
+  projectKeys,
+  TAvailableProjectIdentities,
+  TListAvailableProjectIdentitiesDTO
+} from "@app/hooks/api";
+import { OrderByDirection } from "@app/hooks/api/generic/types";
+import {
+  IdentityProjectMembershipV1,
+  TProjectIdentityMembershipsListV2
+} from "@app/hooks/api/identities/types";
+import { ProjectIdentityOrderBy, TListProjectIdentitiesDTO } from "@app/hooks/api/projects/types";
+
+export const projectIdentityMembershipQuery = {
+  allKey: () => ["project-identity-memberships"] as const,
+  listAvailableKey: (params?: TListAvailableProjectIdentitiesDTO) =>
+    [...projectIdentityMembershipQuery.allKey(), "list-available", params] as const,
+  listAvailable: (params: TListAvailableProjectIdentitiesDTO) =>
+    queryOptions({
+      queryKey: projectIdentityMembershipQuery.listAvailableKey(params),
+      queryFn: async () => {
+        const { data } = await apiRequest.get<{
+          identities: TAvailableProjectIdentities;
+        }>(`/api/v1/projects/${params.projectId}/memberships/available-identities`, {
+          params: {
+            offset: params.offset,
+            limit: params.limit,
+            identityName: params.identityName
+          }
+        });
+        return data.identities;
+      }
+    })
+};
+
+// TODO (scott/akhi): move to new projectIdentityMembershipQuery structure
+
+export const useListProjectIdentityMemberships = (
+  {
+    projectId,
+    offset = 0,
+    limit = 100,
+    orderBy = ProjectIdentityOrderBy.Name,
+    orderDirection = OrderByDirection.ASC,
+    search = ""
+  }: TListProjectIdentitiesDTO,
+  options?: Omit<
+    UseQueryOptions<
+      TProjectIdentityMembershipsListV2,
+      unknown,
+      TProjectIdentityMembershipsListV2,
+      ReturnType
+    >,
+    "queryKey" | "queryFn"
+  >
+) => {
+  return useQuery({
+    queryKey: projectKeys.getProjectIdentityMembershipsWithParams({
+      projectId,
+      offset,
+      limit,
+      orderBy,
+      orderDirection,
+      search
+    }),
+    queryFn: async () => {
+      const params = new URLSearchParams({
+        offset: String(offset),
+        limit: String(limit),
+        orderBy: String(orderBy),
+        orderDirection: String(orderDirection),
+        search: String(search)
+      });
+
+      const { data } = await apiRequest.get(
+        `/api/v1/projects/${projectId}/memberships/identities`,
+        { params }
+      );
+      return data;
+    },
+    enabled: true,
+    ...options
+  });
+};
+
+export const useGetProjectIdentityMembership = (projectId: string, identityId: string) => {
+  return useQuery({
+    enabled: Boolean(projectId && identityId),
+    queryKey: projectKeys.getProjectIdentityMembershipDetails(projectId, identityId),
+    queryFn: async () => {
+      const {
+        data: { identityMembership }
+      } = await apiRequest.get<{ identityMembership: IdentityProjectMembershipV1 }>(
+        `/api/v1/projects/${projectId}/identity-memberships/${identityId}`
+      );
+      return identityMembership;
+    }
+  });
+};
+
+export const useGetProjectIdentityMembershipV2 = (projectId: string, identityId: string) => {
+  return useQuery({
+    enabled: Boolean(projectId && identityId),
+    queryKey: projectKeys.getProjectIdentityMembershipDetailsV2(projectId, identityId),
+    queryFn: async () => {
+      const {
+        data: { identityMembership }
+      } = await apiRequest.get<{ identityMembership: IdentityProjectMembershipV1 }>(
+        `/api/v1/projects/${projectId}/memberships/identities/${identityId}`
+      );
+      return identityMembership;
+    }
+  });
+};
diff --git a/frontend/src/hooks/api/projectIdentityMembership/types.ts b/frontend/src/hooks/api/projectIdentityMembership/types.ts
new file mode 100644
index 000000000..0c0186759
--- /dev/null
+++ b/frontend/src/hooks/api/projectIdentityMembership/types.ts
@@ -0,0 +1,36 @@
+import { TRoles } from "@app/hooks/api/shared";
+
+export type TProjectIdentityMembership = {
+  id: string;
+  projectId: string;
+  identityId: string;
+  createdAt: string;
+  updatedAt: string;
+  // TODO
+};
+
+export type TCreateProjectIdentityMembershipDTO = {
+  identityId: string;
+  projectId: string;
+  role?: string;
+};
+
+export type TUpdateProjectIdentityMembershipDTO = {
+  identityId: string;
+  projectId: string;
+  roles: TRoles;
+};
+
+export type TDeleteProjectIdentityMembershipDTO = {
+  identityId: string;
+  projectId: string;
+};
+
+export type TListAvailableProjectIdentitiesDTO = {
+  projectId: string;
+  offset?: number;
+  limit?: number;
+  identityName?: string;
+};
+
+export type TAvailableProjectIdentities = Array<{ id: string; name: string }>;
diff --git a/frontend/src/hooks/api/projects/index.tsx b/frontend/src/hooks/api/projects/index.tsx
index ed8eba815..d4a3cbc83 100644
--- a/frontend/src/hooks/api/projects/index.tsx
+++ b/frontend/src/hooks/api/projects/index.tsx
@@ -8,10 +8,8 @@ export {
   useUpdateProjectSshConfig
 } from "./mutations";
 export {
-  useAddIdentityToWorkspace,
   useCreateWorkspace,
   useCreateWsEnvironment,
-  useDeleteIdentityFromWorkspace,
   useDeleteUserFromWorkspace,
   useDeleteWorkspace,
   useDeleteWsEnvironment,
@@ -21,8 +19,6 @@ export {
   useGetUserWorkspaceMemberships,
   useGetWorkspaceAuthorizations,
   useGetWorkspaceById,
-  useGetWorkspaceIdentityMembershipDetails,
-  useGetWorkspaceIdentityMemberships,
   useGetWorkspaceIndexStatus,
   useGetWorkspaceIntegrations,
   useGetWorkspaceUserDetails,
@@ -41,7 +37,6 @@ export {
   useListWorkspaceSshHostGroups,
   useListWorkspaceSshHosts,
   useSearchProjects,
-  useUpdateIdentityWorkspaceRole,
   useUpdateProject,
   useUpdateUserWorkspaceRole,
   useUpdateWsEnvironment,
diff --git a/frontend/src/hooks/api/projects/queries.tsx b/frontend/src/hooks/api/projects/queries.tsx
index 1613e95a9..8c07ca691 100644
--- a/frontend/src/hooks/api/projects/queries.tsx
+++ b/frontend/src/hooks/api/projects/queries.tsx
@@ -1,15 +1,12 @@
-import { useMutation, useQuery, useQueryClient, UseQueryOptions } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 
 import { apiRequest } from "@app/config/request";
-import { OrderByDirection } from "@app/hooks/api/generic/types";
 
 import { CaStatus } from "../ca/enums";
 import { TCertificateAuthority } from "../ca/types";
 import { TCertificate } from "../certificates/types";
 import { TCertificateTemplate } from "../certificateTemplates/types";
 import { TGroupMembership } from "../groups/types";
-import { identitiesKeys } from "../identities/queries";
-import { IdentityMembership, TProjectIdentitiesList } from "../identities/types";
 import { IntegrationAuth } from "../integrationAuth/types";
 import { TIntegration } from "../integrations/types";
 import { TPkiAlert } from "../pkiAlerts/types";
@@ -33,13 +30,10 @@ import {
   DeleteWorkspaceDTO,
   Project,
   ProjectEnv,
-  ProjectIdentityOrderBy,
   ProjectType,
   TGetUpgradeProjectStatusDTO,
-  TListProjectIdentitiesDTO,
   TProjectSshConfig,
   TSearchProjectsDTO,
-  TUpdateWorkspaceIdentityRoleDTO,
   TUpdateWorkspaceUserRoleDTO,
   UpdateAuditLogsRetentionDTO,
   UpdateEnvironmentDTO,
@@ -452,154 +446,6 @@ export const useUpdateUserWorkspaceRole = () => {
   });
 };
 
-export const useAddIdentityToWorkspace = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async ({
-      identityId,
-      projectId,
-      role
-    }: {
-      identityId: string;
-      projectId: string;
-      role?: string;
-    }) => {
-      const {
-        data: { identityMembership }
-      } = await apiRequest.post(
-        `/api/v1/projects/${projectId}/identity-memberships/${identityId}`,
-        {
-          role
-        }
-      );
-
-      return identityMembership;
-    },
-    onSuccess: (_, { identityId, projectId }) => {
-      queryClient.invalidateQueries({
-        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
-      });
-    }
-  });
-};
-
-export const useUpdateIdentityWorkspaceRole = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async ({ identityId, projectId, roles }: TUpdateWorkspaceIdentityRoleDTO) => {
-      const {
-        data: { identityMembership }
-      } = await apiRequest.patch(
-        `/api/v1/projects/${projectId}/identity-memberships/${identityId}`,
-        {
-          roles
-        }
-      );
-
-      return identityMembership;
-    },
-    onSuccess: (_, { identityId, projectId }) => {
-      queryClient.invalidateQueries({
-        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: projectKeys.getProjectIdentityMembershipDetails(projectId, identityId)
-      });
-    }
-  });
-};
-
-export const useDeleteIdentityFromWorkspace = () => {
-  const queryClient = useQueryClient();
-  return useMutation({
-    mutationFn: async ({ identityId, projectId }: { identityId: string; projectId: string }) => {
-      const {
-        data: { identityMembership }
-      } = await apiRequest.delete(
-        `/api/v1/projects/${projectId}/identity-memberships/${identityId}`
-      );
-      return identityMembership;
-    },
-    onSuccess: (_, { identityId, projectId }) => {
-      queryClient.invalidateQueries({
-        queryKey: projectKeys.getProjectIdentityMemberships(projectId)
-      });
-      queryClient.invalidateQueries({
-        queryKey: identitiesKeys.getIdentityProjectMemberships(identityId)
-      });
-    }
-  });
-};
-
-export const useGetWorkspaceIdentityMemberships = (
-  {
-    projectId,
-    offset = 0,
-    limit = 100,
-    orderBy = ProjectIdentityOrderBy.Name,
-    orderDirection = OrderByDirection.ASC,
-    search = ""
-  }: TListProjectIdentitiesDTO,
-  options?: Omit<
-    UseQueryOptions<
-      TProjectIdentitiesList,
-      unknown,
-      TProjectIdentitiesList,
-      ReturnType
-    >,
-    "queryKey" | "queryFn"
-  >
-) => {
-  return useQuery({
-    queryKey: projectKeys.getProjectIdentityMembershipsWithParams({
-      projectId,
-      offset,
-      limit,
-      orderBy,
-      orderDirection,
-      search
-    }),
-    queryFn: async () => {
-      const params = new URLSearchParams({
-        offset: String(offset),
-        limit: String(limit),
-        orderBy: String(orderBy),
-        orderDirection: String(orderDirection),
-        search: String(search)
-      });
-
-      const { data } = await apiRequest.get(
-        `/api/v1/projects/${projectId}/identity-memberships`,
-        { params }
-      );
-      return data;
-    },
-    enabled: true,
-    ...options
-  });
-};
-
-export const useGetWorkspaceIdentityMembershipDetails = (projectId: string, identityId: string) => {
-  return useQuery({
-    enabled: Boolean(projectId && identityId),
-    queryKey: projectKeys.getProjectIdentityMembershipDetails(projectId, identityId),
-    queryFn: async () => {
-      const {
-        data: { identityMembership }
-      } = await apiRequest.get<{ identityMembership: IdentityMembership }>(
-        `/api/v1/projects/${projectId}/identity-memberships/${identityId}`
-      );
-      return identityMembership;
-    }
-  });
-};
-
 export const useGetWorkspaceGroupMembershipDetails = (projectId: string, groupId: string) => {
   return useQuery({
     enabled: Boolean(projectId && groupId),
diff --git a/frontend/src/hooks/api/projects/query-keys.tsx b/frontend/src/hooks/api/projects/query-keys.tsx
index 04f14f90d..48830f9e2 100644
--- a/frontend/src/hooks/api/projects/query-keys.tsx
+++ b/frontend/src/hooks/api/projects/query-keys.tsx
@@ -23,6 +23,8 @@ export const projectKeys = {
     [{ projectId }, "project-identity-memberships"] as const,
   getProjectIdentityMembershipDetails: (projectId: string, identityId: string) =>
     [{ projectId, identityId }, "project-identity-membership-details"] as const,
+  getProjectIdentityMembershipDetailsV2: (projectId: string, identityId: string) =>
+    [{ projectId, identityId }, "project-identity-membership-details"] as const,
   // allows invalidation using above key without knowing params
   getProjectIdentityMembershipsWithParams: ({ projectId, ...params }: TListProjectIdentitiesDTO) =>
     [...projectKeys.getProjectIdentityMemberships(projectId), params] as const,
diff --git a/frontend/src/hooks/api/projects/types.ts b/frontend/src/hooks/api/projects/types.ts
index 977afd179..b47720106 100644
--- a/frontend/src/hooks/api/projects/types.ts
+++ b/frontend/src/hooks/api/projects/types.ts
@@ -138,24 +138,6 @@ export type TUpdateWorkspaceUserRoleDTO = {
   )[];
 };
 
-export type TUpdateWorkspaceIdentityRoleDTO = {
-  identityId: string;
-  projectId: string;
-  roles: (
-    | {
-        role: string;
-        isTemporary?: false;
-      }
-    | {
-        role: string;
-        isTemporary: true;
-        temporaryMode: ProjectUserMembershipTemporaryMode;
-        temporaryRange: string;
-        temporaryAccessStartTime: string;
-      }
-  )[];
-};
-
 export type TUpdateWorkspaceGroupRoleDTO = {
   groupId: string;
   projectId: string;
diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts
index c35228fab..524346beb 100644
--- a/frontend/src/hooks/api/secretSharing/types.ts
+++ b/frontend/src/hooks/api/secretSharing/types.ts
@@ -59,6 +59,8 @@ export type TViewSharedSecretResponse = {
     tag: string;
     accessType: SecretSharingAccessType;
     orgName?: string;
+    expiresAt?: Date | string;
+    expiresAfterViews?: number | null;
   };
 };
 
diff --git a/frontend/src/hooks/api/shared/index.ts b/frontend/src/hooks/api/shared/index.ts
new file mode 100644
index 000000000..eea524d65
--- /dev/null
+++ b/frontend/src/hooks/api/shared/index.ts
@@ -0,0 +1 @@
+export * from "./types";
diff --git a/frontend/src/hooks/api/shared/types.ts b/frontend/src/hooks/api/shared/types.ts
new file mode 100644
index 000000000..c57daf3fd
--- /dev/null
+++ b/frontend/src/hooks/api/shared/types.ts
@@ -0,0 +1,37 @@
+import { IdentityAuthMethod } from "@app/hooks/api";
+
+export enum TemporaryPermissionMode {
+  Relative = "relative"
+}
+
+export type TMetadata = {
+  key: string;
+  value: string;
+};
+
+export type TIdentity = {
+  id: string;
+  name: string;
+  orgId: string;
+  projectId: string | null;
+  createdAt: string;
+  updatedAt: string;
+  hasDeleteProtection: boolean;
+  authMethods: IdentityAuthMethod[];
+  activeLockoutAuthMethods: string[];
+  metadata?: Array;
+};
+
+export type TRoles = Array<
+  | {
+      role: string;
+      isTemporary?: false;
+    }
+  | {
+      role: string;
+      isTemporary: true;
+      temporaryMode: TemporaryPermissionMode;
+      temporaryRange: string;
+      temporaryAccessStartTime: string;
+    }
+>;
diff --git a/frontend/src/hooks/api/subscriptions/queries.tsx b/frontend/src/hooks/api/subscriptions/queries.tsx
index f545565eb..325e62e5f 100644
--- a/frontend/src/hooks/api/subscriptions/queries.tsx
+++ b/frontend/src/hooks/api/subscriptions/queries.tsx
@@ -7,7 +7,8 @@ import { SubscriptionPlan } from "./types";
 // import { Workspace } from './types';
 
 export const subscriptionQueryKeys = {
-  getOrgSubsription: (orgID: string) => ["plan", { orgID }] as const
+  all: () => ["plan"] as const,
+  getOrgSubsription: (orgID: string) => [...subscriptionQueryKeys.all(), { orgID }] as const
 };
 
 export const fetchOrgSubscription = async (orgID: string, refreshCache: boolean = false) => {
diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts
index 80bea3db7..43ee68c3e 100644
--- a/frontend/src/hooks/api/subscriptions/types.ts
+++ b/frontend/src/hooks/api/subscriptions/types.ts
@@ -48,6 +48,7 @@ export type SubscriptionPlan = {
   gateway: boolean;
   externalKms: boolean;
   pkiEst: boolean;
+  pkiAcme: boolean;
   pkiLegacyTemplates: boolean;
   enforceMfa: boolean;
   enforceGoogleSSO: boolean;
diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx
index 45978e3d7..6afdc2c98 100644
--- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx
+++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx
@@ -2,12 +2,13 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
 
 import { Button } from "@app/components/v2";
-import { useProject, useProjectPermission } from "@app/context";
+import { useOrganization, useProject, useProjectPermission } from "@app/context";
 import { getProjectHomePage } from "@app/helpers/project";
 import { useRemoveAssumeProjectPrivilege } from "@app/hooks/api";
 import { ActorType } from "@app/hooks/api/auditLogs/enums";
 
 export const AssumePrivilegeModeBanner = () => {
+  const { isSubOrganization, currentOrg } = useOrganization();
   const { currentProject } = useProject();
   const exitAssumePrivilegeMode = useRemoveAssumeProjectPrivilege();
   const { assumedPrivilegeDetails } = useProjectPermission();
@@ -36,7 +37,7 @@ export const AssumePrivilegeModeBanner = () => {
               },
               {
                 onSuccess: () => {
-                  const url = getProjectHomePage(currentProject.type, currentProject.environments);
+                  const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`;
                   window.location.href = url.replace("$projectId", currentProject.id);
                 }
               }
diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx
new file mode 100644
index 000000000..3916477a1
--- /dev/null
+++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateExportModal.tsx
@@ -0,0 +1,163 @@
+import { useEffect, useState } from "react";
+import { faDownload } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+
+import {
+  Button,
+  FormControl,
+  Input,
+  Modal,
+  ModalContent,
+  Select,
+  SelectItem
+} from "@app/components/v2";
+import { UsePopUpState } from "@app/hooks/usePopUp";
+
+type Props = {
+  popUp: UsePopUpState<["certificateExport"]>;
+  handlePopUpToggle: (
+    popUpName: keyof UsePopUpState<["certificateExport"]>,
+    state?: boolean
+  ) => void;
+  onFormatSelected: (
+    format: "pem" | "pkcs12",
+    serialNumber: string,
+    options?: ExportOptions
+  ) => void;
+};
+
+export type CertificateExportFormat = "pem" | "pkcs12";
+
+export type ExportOptions = {
+  pkcs12?: {
+    password: string;
+    alias: string;
+  };
+};
+
+export const CertificateExportModal = ({ popUp, handlePopUpToggle, onFormatSelected }: Props) => {
+  const [selectedFormat, setSelectedFormat] = useState("pem");
+  const [pkcs12Options, setPkcs12Options] = useState({
+    password: "",
+    alias: ""
+  });
+
+  const serialNumber =
+    (popUp?.certificateExport?.data as { serialNumber: string })?.serialNumber || "";
+
+  // Reset form whenever the modal opens
+  useEffect(() => {
+    if (popUp?.certificateExport?.isOpen) {
+      setSelectedFormat("pem");
+      setPkcs12Options({
+        password: "",
+        alias: ""
+      });
+    }
+  }, [popUp?.certificateExport?.isOpen]);
+
+  const isFormValid = () => {
+    if (selectedFormat === "pkcs12") {
+      return pkcs12Options.password.length >= 6 && pkcs12Options.alias.trim() !== "";
+    }
+    return true;
+  };
+
+  const handleExport = () => {
+    if (serialNumber && isFormValid()) {
+      const options: ExportOptions = {};
+
+      if (selectedFormat === "pkcs12") {
+        options.pkcs12 = pkcs12Options;
+      }
+
+      onFormatSelected(selectedFormat, serialNumber, options);
+      handlePopUpToggle("certificateExport", false);
+    }
+  };
+
+  return (
+     {
+        handlePopUpToggle("certificateExport", isOpen);
+      }}
+    >
+      
+        
+

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 })) + } + type="password" + /> + + + + setPkcs12Options((prev) => ({ ...prev, alias: e.target.value }))} + /> + + + )} + +
+ + +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 6a5566d6a..7ca9b81d0 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -9,10 +9,11 @@ import { ProjectPermissionSub, useProject } from "@app/context"; -import { useDeleteCert } from "@app/hooks/api"; +import { useDeleteCert, useDownloadCertPkcs12 } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateExportModal, ExportOptions } from "./CertificateExportModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal"; @@ -24,11 +25,13 @@ import { CertificatesTable } from "./CertificatesTable"; export const CertificatesSection = () => { const { currentProject } = useProject(); const { mutateAsync: deleteCert } = useDeleteCert(); + const { mutateAsync: downloadCertPkcs12 } = useDownloadCertPkcs12(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "issueCertificate", "certificateImport", "certificateCert", + "certificateExport", "deleteCertificate", "revokeCertificate", "manageRenewal", @@ -49,6 +52,45 @@ export const CertificatesSection = () => { handlePopUpClose("deleteCertificate"); }; + const handleCertificateExport = async ( + format: "pem" | "pkcs12", + serialNumber: string, + options?: ExportOptions + ) => { + if (format === "pem") { + handlePopUpOpen("certificateCert", { serialNumber }); + } else if (format === "pkcs12") { + if (!currentProject?.slug) return; + + if (!options?.pkcs12?.password || !options?.pkcs12?.alias) { + createNotification({ + text: "Password and alias are required for PKCS12 export", + type: "error" + }); + return; + } + + try { + await downloadCertPkcs12({ + serialNumber, + projectSlug: currentProject.slug, + password: options.pkcs12.password, + alias: options.pkcs12.alias + }); + + createNotification({ + text: "PKCS12 certificate downloaded successfully", + type: "success" + }); + } catch (error: any) { + createNotification({ + text: error?.message || "Failed to download PKCS12 certificate", + type: "error" + }); + } + } + }; + return (
@@ -84,6 +126,11 @@ export const CertificatesSection = () => { + diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 9bccff31d..a60142762 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -68,6 +68,7 @@ type Props = { "deleteCertificate", "revokeCertificate", "certificateCert", + "certificateExport", "manageRenewal", "renewCertificate", "managePkiSyncs" @@ -275,7 +276,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { !isAllowed && "pointer-events-none cursor-not-allowed opacity-50" )} onClick={async () => - handlePopUpOpen("certificateCert", { + handlePopUpOpen("certificateExport", { serialNumber: certificate.serialNumber }) } 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 ab34abec8..39bb3c4e9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { Button, DeleteActionModal } from "@app/components/v2"; import { useProjectPermission } from "@app/context"; @@ -9,6 +10,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; +import { usePopUp } from "@app/hooks"; import { TCertificateProfileWithDetails, useDeleteCertificateProfile @@ -29,6 +31,7 @@ export const CertificateProfilesTab = () => { const [selectedProfile, setSelectedProfile] = useState( null ); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); const deleteProfile = useDeleteCertificateProfile(); @@ -99,7 +102,17 @@ export const CertificateProfilesTab = () => { onDeleteProfile={handleDeleteProfile} /> - setIsCreateModalOpen(false)} /> + setIsCreateModalOpen(false)} + handlePopUpOpen={handlePopUpOpen} + /> + handlePopUpToggle("upgradePlan", isOpen)} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + text="Your current plan does not include access to managing template enrollment options for ACME. To unlock this feature, please upgrade to Infisical Enterprise plan." + /> {selectedProfile && ( <> @@ -109,6 +122,7 @@ export const CertificateProfilesTab = () => { setIsEditModalOpen(false); setSelectedProfile(null); }} + handlePopUpOpen={handlePopUpOpen} profile={selectedProfile} mode="edit" /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index f53084a4e..516b64288 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -18,8 +18,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; -import { envConfig } from "@app/config/env"; -import { useProject } from "@app/context"; +import { useProject, useSubscription } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { TCertificateProfileWithDetails, @@ -29,6 +28,7 @@ import { useUpdateCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; const createSchema = z .object({ @@ -151,12 +151,25 @@ export type FormData = z.infer; interface Props { isOpen: boolean; onClose: () => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { + isEnterpriseFeature?: boolean; + } + ) => void; profile?: TCertificateProfileWithDetails; mode?: "create" | "edit"; } -export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { +export const CreateProfileModal = ({ + isOpen, + onClose, + handlePopUpOpen, + profile, + mode = "create" +}: Props) => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { data: caData } = useListCasByProjectId(currentProject?.id || ""); const { data: templateData } = useListCertificateTemplatesV2({ @@ -248,6 +261,15 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } }, [isEdit, profile, reset]); const onFormSubmit = async (data: FormData) => { + if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === "acme") { + reset(); + onClose(); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true + }); + return; + } + if (!currentProject?.id && !isEdit) return; if (isEdit) { @@ -467,7 +489,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > API EST - {envConfig.ACME_FEATURE_ENABLED && ACME} + ACME )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx index ec660b339..33f92a122 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CertificateTemplatesV2Tab.tsx @@ -10,7 +10,7 @@ import { ProjectPermissionSub } from "@app/context/ProjectPermissionContext/types"; import { useDeleteCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/mutations"; -import { TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; +import { type TCertificateTemplateV2WithPolicies } from "@app/hooks/api/certificateTemplates/types"; import { CreateTemplateModal } from "./CreateTemplateModal"; import { TemplateList } from "./TemplateList"; @@ -84,7 +84,12 @@ export const CertificateTemplatesV2Tab = () => { - setIsCreateModalOpen(false)} /> + { + setIsCreateModalOpen(false); + }} + /> {selectedTemplate && ( <> 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 b929ce36e..d55c1b5ef 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/CreateTemplateModal.tsx @@ -36,13 +36,18 @@ import { CertDurationUnit, CertExtendedKeyUsageType, CertKeyUsageType, + CertSanInclude, CertSubjectAlternativeNameType, + CertSubjectAttributeInclude, CertSubjectAttributeType, SAN_INCLUDE_OPTIONS, SAN_TYPE_OPTIONS, SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS, - SUBJECT_ATTRIBUTE_TYPE_OPTIONS + SUBJECT_ATTRIBUTE_TYPE_OPTIONS, + TEMPLATE_PRESET_IDS, + type TemplatePresetId } from "./shared/certificate-constants"; +import { CERTIFICATE_TEMPLATE_PRESETS } from "./shared/template-presets"; import { KeyUsagesSection, TemplateFormData, templateSchema } from "./shared"; export type FormData = TemplateFormData; @@ -91,7 +96,7 @@ const SIGNATURE_ALGORITHMS = [ "SHA256-ECDSA", "SHA384-ECDSA", "SHA512-ECDSA" -]; +] as const; const KEY_ALGORITHMS = [ "RSA-2048", @@ -100,7 +105,7 @@ const KEY_ALGORITHMS = [ "ECDSA-P256", "ECDSA-P384", "ECDSA-P521" -]; +] as const; export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }: Props) => { const { currentProject } = useProject(); @@ -117,7 +122,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" subj.allowed.forEach((allowedValue) => { attributes.push({ type: subj.type as CertSubjectAttributeType, - include: "optional", + include: CertSubjectAttributeInclude.OPTIONAL, value: [allowedValue] }); }); @@ -126,7 +131,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" subj.denied.forEach((deniedValue) => { attributes.push({ type: subj.type as CertSubjectAttributeType, - include: "prohibit", + include: CertSubjectAttributeInclude.PROHIBIT, value: [deniedValue] }); }); @@ -141,7 +146,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" san.required.forEach((requiredValue) => { subjectAlternativeNames.push({ type: san.type as CertSubjectAlternativeNameType, - include: "mandatory", + include: CertSanInclude.MANDATORY, value: [requiredValue] }); }); @@ -150,7 +155,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" san.allowed.forEach((allowedValue) => { subjectAlternativeNames.push({ type: san.type as CertSubjectAlternativeNameType, - include: "optional", + include: CertSanInclude.OPTIONAL, value: [allowedValue] }); }); @@ -159,7 +164,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" san.denied.forEach((deniedValue) => { subjectAlternativeNames.push({ type: san.type as CertSubjectAlternativeNameType, - include: "prohibit", + include: CertSanInclude.PROHIBIT, value: [deniedValue] }); }); @@ -219,6 +224,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }; return { + preset: TEMPLATE_PRESET_IDS.CUSTOM, name: templateData.name || "", description: templateData.description || "", attributes, @@ -231,25 +237,30 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" }; }; - const getDefaultValues = (): FormData => ({ - name: "", - description: "", - attributes: [], - keyUsages: { requiredUsages: [], optionalUsages: [] }, - extendedKeyUsages: { requiredUsages: [], optionalUsages: [] }, - subjectAlternativeNames: [], - validity: { - maxDuration: { value: 365, unit: CertDurationUnit.DAYS } - }, - signatureAlgorithm: { - allowedAlgorithms: [] - }, - keyAlgorithm: { - allowedKeyTypes: [] - } - }); + const getDefaultValues = (): FormData & { preset: TemplatePresetId } => { + return { + preset: TEMPLATE_PRESET_IDS.CUSTOM, + name: "", + description: "", + attributes: [], + keyUsages: { requiredUsages: [], optionalUsages: [] }, + extendedKeyUsages: { requiredUsages: [], optionalUsages: [] }, + subjectAlternativeNames: [], + validity: { + maxDuration: { value: 365, unit: CertDurationUnit.DAYS } + }, + signatureAlgorithm: { + allowedAlgorithms: [] + }, + keyAlgorithm: { + allowedKeyTypes: [] + } + }; + }; - const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ + const { control, handleSubmit, reset, watch, setValue, formState } = useForm< + FormData & { preset: TemplatePresetId } + >({ resolver: zodResolver(templateSchema), defaultValues: getDefaultValues(), mode: "onChange", @@ -260,7 +271,7 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" useEffect(() => { if (isEdit && template) { const convertedData = convertApiToUiFormat(template); - reset(convertedData); + reset({ ...convertedData, preset: TEMPLATE_PRESET_IDS.CUSTOM }); } else if (!isEdit) { reset(getDefaultValues()); } @@ -273,6 +284,37 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" requiredUsages: [], optionalUsages: [] }; + const watchedPreset = watch("preset") || TEMPLATE_PRESET_IDS.CUSTOM; + + const handlePresetChange = (presetId: TemplatePresetId) => { + setValue("preset", presetId); + + if (presetId === TEMPLATE_PRESET_IDS.CUSTOM) { + return; + } + + const selectedPreset = CERTIFICATE_TEMPLATE_PRESETS.find((p) => p.id === presetId); + if (selectedPreset) { + if (selectedPreset.formData.keyUsages) { + setValue("keyUsages", selectedPreset.formData.keyUsages); + } + if (selectedPreset.formData.extendedKeyUsages) { + setValue("extendedKeyUsages", selectedPreset.formData.extendedKeyUsages); + } + if (selectedPreset.formData.attributes) { + setValue("attributes", selectedPreset.formData.attributes); + } + if (selectedPreset.formData.subjectAlternativeNames) { + setValue("subjectAlternativeNames", selectedPreset.formData.subjectAlternativeNames); + } + if (selectedPreset.formData.signatureAlgorithm) { + setValue("signatureAlgorithm", selectedPreset.formData.signatureAlgorithm); + } + if (selectedPreset.formData.keyAlgorithm) { + setValue("keyAlgorithm", selectedPreset.formData.keyAlgorithm); + } + } + }; const consolidateByType = < T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } @@ -309,9 +351,17 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" data.attributes?.map((attr) => { const result: AttributeTransform = { type: attr.type }; - if (attr.include === "optional" && attr.value && attr.value.length > 0) { + if ( + attr.include === CertSubjectAttributeInclude.OPTIONAL && + attr.value && + attr.value.length > 0 + ) { result.allowed = attr.value; - } else if (attr.include === "prohibit" && attr.value && attr.value.length > 0) { + } else if ( + attr.include === CertSubjectAttributeInclude.PROHIBIT && + attr.value && + attr.value.length > 0 + ) { result.denied = attr.value; } @@ -322,11 +372,11 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" data.subjectAlternativeNames?.map((san) => { const result: SanTransform = { type: san.type }; - if (san.include === "mandatory" && san.value && san.value.length > 0) { + if (san.include === CertSanInclude.MANDATORY && san.value && san.value.length > 0) { result.required = san.value; - } else if (san.include === "optional" && san.value && san.value.length > 0) { + } else if (san.include === CertSanInclude.OPTIONAL && san.value && san.value.length > 0) { result.allowed = san.value; - } else if (san.include === "prohibit" && san.value && san.value.length > 0) { + } else if (san.include === CertSanInclude.PROHIBIT && san.value && san.value.length > 0) { result.denied = san.value; } @@ -464,11 +514,19 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" value: ["*"] }; setValue("attributes", [...watchedAttributes, newAttribute]); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; const removeAttribute = (index: number) => { const newAttributes = watchedAttributes.filter((_, i) => i !== index); setValue("attributes", newAttributes); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; const addSan = () => { @@ -478,11 +536,19 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" value: ["*"] }; setValue("subjectAlternativeNames", [...watchedSans, newSan]); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; const removeSan = (index: number) => { const newSans = watchedSans.filter((_, i) => i !== index); setValue("subjectAlternativeNames", newSans); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; const handleKeyUsagesChange = (usages: { @@ -493,6 +559,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" requiredUsages: usages.requiredUsages, optionalUsages: usages.optionalUsages }); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; const handleExtendedKeyUsagesChange = (usages: { @@ -503,6 +573,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" requiredUsages: usages.requiredUsages, optionalUsages: usages.optionalUsages }); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }; return ( @@ -555,6 +629,33 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" )} /> + + ( + + + + )} + />
Subject Attributes @@ -595,6 +696,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" type: value as CertSubjectAttributeType }; setValue("attributes", newAttributes); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className="w-48" > @@ -615,6 +720,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" value as (typeof SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS)[number] }; setValue("attributes", newAttributes); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className="w-32" > @@ -635,6 +744,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" value: e.target.value.trim() ? [e.target.value.trim()] : [] }; setValue("attributes", newAttributes); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className={`flex-1 ${ attr.value && attr.value.length > 0 && attr.value[0] === "" @@ -701,6 +814,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" type: value as CertSubjectAlternativeNameType }; setValue("subjectAlternativeNames", newSans); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className="w-36" > @@ -720,6 +837,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" include: value as (typeof SAN_INCLUDE_OPTIONS)[number] }; setValue("subjectAlternativeNames", newSans); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className="w-32" > @@ -740,6 +861,10 @@ export const CreateTemplateModal = ({ isOpen, onClose, template, mode = "create" value: e.target.value.trim() ? [e.target.value.trim()] : [] }; setValue("subjectAlternativeNames", newSans); + + if (watchedPreset !== TEMPLATE_PRESET_IDS.CUSTOM) { + setValue("preset", TEMPLATE_PRESET_IDS.CUSTOM); + } }} className={`flex-1 ${ san.value && san.value.length > 0 && san.value[0] === "" diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts index 50b69a2e2..47fdcd5a2 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants.ts @@ -150,8 +150,19 @@ export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttribute export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); -export const SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS = ["optional", "prohibit"] as const; -export const SAN_INCLUDE_OPTIONS = ["mandatory", "optional", "prohibit"] as const; +export enum CertSubjectAttributeInclude { + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export enum CertSanInclude { + MANDATORY = "mandatory", + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export const SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS = Object.values(CertSubjectAttributeInclude); +export const SAN_INCLUDE_OPTIONS = Object.values(CertSanInclude); export const USAGE_STATES = { REQUIRED: "required", @@ -239,3 +250,27 @@ export const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => }; return mapping[templateFormat] || templateFormat; }; + +export const TEMPLATE_PRESET_IDS = { + CUSTOM: "custom", + TLS_SERVER: "tls-server", + TLS_CLIENT: "tls-client", + CODE_SIGNING: "code-signing", + DEVICE: "device", + USER: "user", + EMAIL_PROTECTION: "email-protection", + DUAL_PURPOSE_SERVER: "dual-purpose-server" +} as const; + +export type TemplatePresetId = (typeof TEMPLATE_PRESET_IDS)[keyof typeof TEMPLATE_PRESET_IDS]; + +export const ALGORITHM_FAMILIES = { + ECDSA: { + signature: ["SHA256-ECDSA", "SHA384-ECDSA", "SHA512-ECDSA"] as const, + key: ["ECDSA-P256", "ECDSA-P384", "ECDSA-P521"] as const + }, + RSA: { + signature: ["SHA256-RSA", "SHA384-RSA", "SHA512-RSA"] as const, + key: ["RSA-2048", "RSA-3072", "RSA-4096"] as const + } +} as const; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts index 0590c4160..137ca262c 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/schemas.ts @@ -4,21 +4,22 @@ import { CertDurationUnit, CertExtendedKeyUsageType, CertKeyUsageType, + CertSanInclude, CertSubjectAlternativeNameType, + CertSubjectAttributeInclude, CertSubjectAttributeType, - SAN_INCLUDE_OPTIONS, - SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS + TEMPLATE_PRESET_IDS } from "./certificate-constants"; export const uiAttributeSchema = z.object({ type: z.nativeEnum(CertSubjectAttributeType), - include: z.enum(SUBJECT_ATTRIBUTE_INCLUDE_OPTIONS), + include: z.nativeEnum(CertSubjectAttributeInclude), value: z.array(z.string().min(1, "Value cannot be empty")) }); export const uiSanSchema = z.object({ type: z.nativeEnum(CertSubjectAlternativeNameType), - include: z.enum(SAN_INCLUDE_OPTIONS), + include: z.nativeEnum(CertSanInclude), value: z.array(z.string().min(1, "Value cannot be empty")) }); @@ -51,7 +52,21 @@ export const uiKeyAlgorithmSchema = z.object({ .min(1, "At least one key type must be selected") }); +export const uiPresetSchema = z + .enum([ + TEMPLATE_PRESET_IDS.CUSTOM, + TEMPLATE_PRESET_IDS.TLS_SERVER, + TEMPLATE_PRESET_IDS.TLS_CLIENT, + TEMPLATE_PRESET_IDS.CODE_SIGNING, + TEMPLATE_PRESET_IDS.DEVICE, + TEMPLATE_PRESET_IDS.USER, + TEMPLATE_PRESET_IDS.EMAIL_PROTECTION, + TEMPLATE_PRESET_IDS.DUAL_PURPOSE_SERVER + ]) + .default(TEMPLATE_PRESET_IDS.CUSTOM); + export const templateSchema = z.object({ + preset: uiPresetSchema, name: z .string() .trim() diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/template-presets.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/template-presets.ts new file mode 100644 index 000000000..9edad21ea --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/template-presets.ts @@ -0,0 +1,385 @@ +import { + ALGORITHM_FAMILIES, + CertDurationUnit, + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSanInclude, + CertSubjectAlternativeNameType, + CertSubjectAttributeInclude, + CertSubjectAttributeType, + TEMPLATE_PRESET_IDS, + type TemplatePresetId +} from "./certificate-constants"; +import { TemplateFormData } from "."; + +export interface CertificateTemplatePreset { + readonly id: TemplatePresetId; + readonly name: string; + readonly description: string; + readonly useCase: string; + readonly formData: Omit; +} + +export const CERTIFICATE_TEMPLATE_PRESETS: CertificateTemplatePreset[] = [ + { + id: TEMPLATE_PRESET_IDS.TLS_SERVER, + name: "TLS Server Certificate", + description: "Standard TLS/SSL server certificate for HTTPS services and API endpoints.", + useCase: "Web servers, API endpoints, HTTPS services", + formData: { + name: "TLS Server Certificate", + description: "Standard TLS/SSL server certificate for HTTPS services and API endpoints.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] + }, + extendedKeyUsages: { + requiredUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + optionalUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertSanInclude.OPTIONAL, + value: ["*"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.ECDSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.ECDSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.TLS_CLIENT, + name: "TLS Client Certificate", + description: "Client certificate for mutual TLS authentication and API access.", + useCase: "Client authentication, mTLS, API authentication", + formData: { + name: "TLS Client Certificate", + description: "Client certificate for mutual TLS authentication and API access.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_AGREEMENT] + }, + extendedKeyUsages: { + requiredUsages: [CertExtendedKeyUsageType.CLIENT_AUTH], + optionalUsages: [CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + include: CertSanInclude.OPTIONAL, + value: ["*"] + }, + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.ECDSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.ECDSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.CODE_SIGNING, + name: "Code Signing Certificate", + description: + "Certificate for signing software, executables, and packages. Requires hardware security modules.", + useCase: "Software signing, executable authentication, package validation", + formData: { + name: "Code Signing Certificate", + description: + "Certificate for signing software, executables, and packages. Requires hardware security modules.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION], + optionalUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION] + }, + extendedKeyUsages: { + requiredUsages: [CertExtendedKeyUsageType.CODE_SIGNING], + optionalUsages: [ + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.TIME_STAMPING + ] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.RSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.RSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.DEVICE, + name: "Device Certificate", + description: + "Certificate for IoT devices and embedded systems authentication. IEEE 802.1AR compliant.", + useCase: "Device authentication, IoT security, embedded systems", + formData: { + name: "Device Certificate", + description: + "Certificate for IoT devices and embedded systems authentication. IEEE 802.1AR compliant.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_AGREEMENT] + }, + extendedKeyUsages: { + requiredUsages: [CertExtendedKeyUsageType.CLIENT_AUTH], + optionalUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.SERVER_AUTH] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertSanInclude.OPTIONAL, + value: ["*"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.ECDSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.ECDSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.USER, + name: "User Certificate", + description: + "Personal certificate for user authentication and email signing. FIPS 201 PIV compliant.", + useCase: "Personal authentication, smart cards, email protection", + formData: { + name: "User Certificate", + description: + "Personal certificate for user authentication and email signing. FIPS 201 PIV compliant.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + requiredUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ], + optionalUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.ECDSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.ECDSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.EMAIL_PROTECTION, + name: "Email Protection Certificate", + description: "S/MIME certificate for email encryption and digital signing. RFC 8550 compliant.", + useCase: "Email encryption, digital signing, secure messaging", + formData: { + name: "Email Protection Certificate", + description: + "S/MIME certificate for email encryption and digital signing. RFC 8550 compliant.", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + requiredUsages: [CertExtendedKeyUsageType.EMAIL_PROTECTION], + optionalUsages: [CertExtendedKeyUsageType.EMAIL_PROTECTION] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.RSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.RSA.key] + } + } + }, + { + id: TEMPLATE_PRESET_IDS.DUAL_PURPOSE_SERVER, + name: "Dual-Purpose Server Certificate", + description: + "Certificate for services requiring both server and client authentication capabilities", + useCase: "Microservices, service mesh, dual authentication", + formData: { + name: "Dual-Purpose Server Certificate", + description: + "Certificate for services requiring both server and client authentication capabilities", + keyUsages: { + requiredUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + optionalUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + requiredUsages: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH + ], + optionalUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + maxDuration: { + value: 365, + unit: CertDurationUnit.DAYS + } + }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertSubjectAttributeInclude.OPTIONAL, + value: ["*"] + } + ], + subjectAlternativeNames: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + include: CertSanInclude.OPTIONAL, + value: ["*"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + include: CertSanInclude.OPTIONAL, + value: ["*"] + } + ], + signatureAlgorithm: { + allowedAlgorithms: [...ALGORITHM_FAMILIES.ECDSA.signature] + }, + keyAlgorithm: { + allowedKeyTypes: [...ALGORITHM_FAMILIES.ECDSA.key] + } + } + } +]; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx index 94c9c49a3..1aca4dc5f 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -76,7 +77,9 @@ export const IdentityAliCloudAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityAliCloudAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityAliCloudAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -144,7 +147,7 @@ export const IdentityAliCloudAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), allowedArns, identityId, accessTokenTTL: Number(accessTokenTTL), @@ -154,7 +157,7 @@ export const IdentityAliCloudAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, allowedArns, accessTokenTTL: Number(accessTokenTTL), diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx index a05dcf8df..1737d250b 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -78,7 +79,9 @@ export const IdentityAwsAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityAwsAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityAwsAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -154,7 +157,7 @@ export const IdentityAwsAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), stsEndpoint, allowedPrincipalArns, allowedAccountIds, @@ -166,7 +169,7 @@ export const IdentityAwsAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, stsEndpoint: stsEndpoint || "", allowedPrincipalArns: allowedPrincipalArns || "", @@ -176,10 +179,8 @@ export const IdentityAwsAuthForm = ({ accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), accessTokenTrustedIps }); + handlePopUpToggle("identityAuthMethod", false); } - - handlePopUpToggle("identityAuthMethod", false); - createNotification({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx index ada799d13..9315768c9 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -73,7 +74,9 @@ export const IdentityAzureAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityAzureAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityAzureAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -150,7 +153,7 @@ export const IdentityAzureAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, tenantId, resource, @@ -162,7 +165,7 @@ export const IdentityAzureAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, tenantId: tenantId || "", resource: resource || "", diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx index 960d4b561..b110f5a7d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -76,7 +77,9 @@ export const IdentityGcpAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityGcpAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityGcpAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -160,7 +163,7 @@ export const IdentityGcpAuthForm = ({ if (data) { await updateMutateAsync({ identityId, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), type, allowedServiceAccounts, allowedProjects, @@ -173,7 +176,7 @@ export const IdentityGcpAuthForm = ({ } else { await addMutateAsync({ identityId, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), type, allowedServiceAccounts: allowedServiceAccounts || "", allowedProjects: allowedProjects || "", @@ -191,7 +194,6 @@ export const IdentityGcpAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" }); - reset(); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx index 10eab486d..8435bf6f4 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx @@ -4,6 +4,7 @@ import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -109,7 +110,9 @@ export const IdentityJwtAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityJwtAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityJwtAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -227,7 +230,7 @@ export const IdentityJwtAuthForm = ({ if (data) { await updateMutateAsync({ identityId, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), configurationType, jwksUrl, jwksCaCert, @@ -252,7 +255,7 @@ export const IdentityJwtAuthForm = ({ boundAudiences, boundClaims: Object.fromEntries(boundClaims.map((entry) => [entry.key, entry.value])), boundSubject, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -266,7 +269,6 @@ export const IdentityJwtAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" }); - reset(); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index c4ae8bac0..4d13469c8 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -4,6 +4,7 @@ import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -117,7 +118,9 @@ export const IdentityKubernetesAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityKubernetesAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityKubernetesAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -318,7 +321,7 @@ export const IdentityKubernetesAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), ...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api ? { kubernetesHost: kubernetesHost || "" @@ -341,7 +344,7 @@ export const IdentityKubernetesAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, ...(tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api ? { diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index a3ab70de0..213d3e2a6 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faQuestionCircle, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import ms from "ms"; import { z } from "zod"; @@ -168,7 +169,9 @@ export const IdentityLdapAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityLdapAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityLdapAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -345,7 +348,7 @@ export const IdentityLdapAuthForm = ({ ms(`${lockoutCounterResetValue}${lockoutCounterResetUnit}`) / 1000; const basePayload = { - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, searchFilter, ldapCaCertificate, diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx index 3ebfceb4a..ee6160d41 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -84,7 +85,9 @@ export const IdentityOciAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityOciAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityOciAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -156,7 +159,7 @@ export const IdentityOciAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), tenancyOcid, allowedUsernames, identityId, @@ -167,7 +170,7 @@ export const IdentityOciAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, tenancyOcid, allowedUsernames: allowedUsernames || undefined, @@ -184,7 +187,6 @@ export const IdentityOciAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" }); - reset(); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx index 7503e6f45..e30e2b37f 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -4,6 +4,7 @@ import { faQuestionCircle } from "@fortawesome/free-regular-svg-icons"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -96,7 +97,9 @@ export const IdentityOidcAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityOidcAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityOidcAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -211,7 +214,7 @@ export const IdentityOidcAuthForm = ({ if (data) { await updateMutateAsync({ identityId, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), oidcDiscoveryUrl, caCert, boundIssuer, @@ -238,7 +241,7 @@ export const IdentityOidcAuthForm = ({ ? Object.fromEntries(claimMetadataMapping.map((entry) => [entry.key, entry.value])) : undefined, boundSubject, - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -252,7 +255,6 @@ export const IdentityOidcAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" }); - reset(); }; 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 8380836cb..037429ad3 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,5 +1,8 @@ -import { faLink, faPlus } from "@fortawesome/free-solid-svg-icons"; +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 { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; @@ -14,17 +17,23 @@ import { } from "@app/context"; import { OrgPermissionMachineIdentityAuthTemplateActions } from "@app/context/OrgPermissionContext/types"; import { withPermission } from "@app/hoc"; -import { useDeleteIdentity } from "@app/hooks/api"; +import { useDeleteOrgIdentity } from "@app/hooks/api"; import { useDeleteIdentityAuthTemplate } from "@app/hooks/api/identityAuthTemplates"; import { usePopUp } from "@app/hooks/usePopUp"; import { IdentityAuthTemplateModal } from "./IdentityAuthTemplateModal"; import { IdentityAuthTemplatesTable } from "./IdentityAuthTemplatesTable"; -import { IdentityLinkForm } from "./IdentityLinkForm"; -import { IdentityModal } from "./IdentityModal"; import { IdentityTable } from "./IdentityTable"; import { IdentityTokenAuthTokenModal } from "./IdentityTokenAuthTokenModal"; import { MachineAuthTemplateUsagesModal } from "./MachineAuthTemplateUsagesModal"; +import { OrgIdentityLinkForm } from "./OrgIdentityLinkForm"; +import { OrgIdentityModal } from "./OrgIdentityModal"; + +enum IdentityWizardSteps { + SelectAction = "select-action", + LinkIdentity = "link-identity", + OrganizationIdentity = "project-identity" +} export const IdentitySection = withPermission( () => { @@ -32,7 +41,9 @@ export const IdentitySection = withPermission( const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id || ""; - const { mutateAsync: deleteMutateAsync } = useDeleteIdentity(); + const [wizardStep, setWizardStep] = useState(IdentityWizardSteps.SelectAction); + + const { mutateAsync: deleteMutateAsync } = useDeleteOrgIdentity(); const { mutateAsync: deleteTemplateMutateAsync } = useDeleteIdentityAuthTemplate(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "identity", @@ -46,7 +57,7 @@ export const IdentitySection = withPermission( "editTemplate", "deleteTemplate", "viewUsages", - "linkIdentity" + "addOptions" ] as const); const isMoreIdentitiesAllowed = subscription?.identityLimit @@ -58,7 +69,7 @@ export const IdentitySection = withPermission( const onDeleteIdentitySubmit = async (identityId: string) => { await deleteMutateAsync({ identityId, - organizationId: orgId + orgId }); createNotification({ @@ -91,50 +102,38 @@ export const IdentitySection = withPermission(

Identities

- {isSubOrganization && ( +
{(isAllowed) => ( )} - )} - - {(isAllowed) => ( - - )} - +
@@ -173,7 +172,6 @@ export const IdentitySection = withPermission(
- + handlePopUpToggle("linkIdentity", isOpen)} + isOpen={popUp.identity.isOpen} + onOpenChange={(open) => { + handlePopUpToggle("identity", open); + if (!open) { + setWizardStep(IdentityWizardSteps.SelectAction); + } + }} > - handlePopUpClose("linkIdentity")} /> + + {wizardStep === IdentityWizardSteps.SelectAction && ( + +
setWizardStep(IdentityWizardSteps.OrganizationIdentity)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setWizardStep(IdentityWizardSteps.OrganizationIdentity); + } + }} + > +
+ +
Create New Identity
+
+
+ Create a new machine identity specifically for this sub-organization. This + identity will be managed at the sub-organization level. +
+
+
setWizardStep(IdentityWizardSteps.LinkIdentity)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setWizardStep(IdentityWizardSteps.LinkIdentity); + } + }} + > +
+ +
Assign Existing Identity
+
+
+ Assign an existing identity from your parent organization. The identity will + continue to be managed at its original scope. +
+
+
+ )} + {wizardStep === IdentityWizardSteps.OrganizationIdentity && ( + + + + )} + {wizardStep === IdentityWizardSteps.LinkIdentity && ( + + handlePopUpClose("identity")} /> + + )} +
- { const organizationId = currentOrg?.id || ""; - const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); + const { mutateAsync: updateMutateAsync } = useUpdateOrgIdentity(); - const { data, isPending, isFetching } = useSearchIdentities({ + const { data, isPending, isFetching } = useSearchOrgIdentityMemberships({ offset, limit, orderDirection, @@ -293,6 +293,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { lastLoginAuthMethod, lastLoginTime }) => { + const isSubOrgIdentity = currentOrg.id === orgId; + return ( { {isSubOrganization && ( -

- - {currentOrg.id === orgId ? "Sub Organization" : "Root Organization"} -

+ + {isSubOrgIdentity ? ( + <> + + Sub-Organization + + ) : ( + <> + + Root Organization + + )} + )} @@ -394,7 +405,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { }} isDisabled={!isAllowed} > - Edit Identity + Edit Identity {isSubOrgIdentity ? "" : "Membership"} )} @@ -414,7 +425,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { isDisabled={!isAllowed} icon={} > - Delete Identity + {isSubOrgIdentity + ? "Delete Identity" + : "Remove From Sub-Organization"} )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx index 5666be39f..dc615b111 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -71,7 +72,9 @@ export const IdentityTlsCertAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityTlsCertAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityTlsCertAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -141,7 +144,7 @@ export const IdentityTlsCertAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), caCertificate, allowedCommonNames: allowedCommonNames || null, identityId, @@ -152,7 +155,7 @@ export const IdentityTlsCertAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, caCertificate, allowedCommonNames: allowedCommonNames || undefined, @@ -169,7 +172,6 @@ export const IdentityTlsCertAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "configured"} auth method`, type: "success" }); - reset(); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx index af2404c5c..34f5116d8 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -70,7 +71,9 @@ export const IdentityTokenAuthForm = ({ const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); - + const { projectId } = useParams({ + strict: false + }); const { mutateAsync: addMutateAsync } = useAddIdentityTokenAuth(); const { mutateAsync: updateMutateAsync } = useUpdateIdentityTokenAuth(); const [tabValue, setTabValue] = useState(IdentityFormTab.Configuration); @@ -134,7 +137,7 @@ export const IdentityTokenAuthForm = ({ if (data) { await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), @@ -143,7 +146,7 @@ export const IdentityTokenAuthForm = ({ }); } else { await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index d4de4bd0e..c7990f55a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -3,6 +3,7 @@ import { Controller, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useParams } from "@tanstack/react-router"; import ms from "ms"; import { z } from "zod"; @@ -105,6 +106,9 @@ export const IdentityUniversalAuthForm = ({ identityId, isUpdate }: Props) => { + const { projectId } = useParams({ + strict: false + }); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { subscription } = useSubscription(); @@ -232,7 +236,7 @@ export const IdentityUniversalAuthForm = ({ if (data) { // update universal auth configuration await updateMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, clientSecretTrustedIps, accessTokenTTL: Number(accessTokenTTL), @@ -249,7 +253,7 @@ export const IdentityUniversalAuthForm = ({ // create new universal auth configuration await addMutateAsync({ - organizationId: orgId, + ...(projectId ? { projectId } : { organizationId: orgId }), identityId, clientSecretTrustedIps, accessTokenTTL: Number(accessTokenTTL), @@ -270,7 +274,6 @@ export const IdentityUniversalAuthForm = ({ text: `Successfully ${isUpdate ? "updated" : "created"} auth method`, type: "success" }); - reset(); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx similarity index 83% rename from frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx rename to frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx index 545aea5aa..24406382e 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLinkForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx @@ -1,13 +1,15 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FilterableSelect, FormControl } from "@app/components/v2"; import { useOrganization } from "@app/context"; -import { useGetAvailableOrgIdentities, useGetOrgRoles } from "@app/hooks/api"; +import { useGetOrgRoles } from "@app/hooks/api"; import { useCreateOrgIdentityMembership } from "@app/hooks/api/orgIdentityMembership"; +import { orgIdentityMembershipQuery } from "@app/hooks/api/orgIdentityMembership/queries"; const schema = z .object({ @@ -22,15 +24,26 @@ type Props = { onClose: () => void; }; -export const IdentityLinkForm = ({ onClose }: Props) => { +export const OrgIdentityLinkForm = ({ onClose }: Props) => { const navigate = useNavigate(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; const { data: roles } = useGetOrgRoles(orgId); + // const [searchValue, setSearchValue] = useState(""); + // + // const [debouncedSearchValue] = useDebounce(searchValue); + const { mutateAsync: createMutateAsync } = useCreateOrgIdentityMembership(); - const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useGetAvailableOrgIdentities(); + + // TODO: name filter needs to be implemented on backend + const { data: rootOrgIdentities, isPending: isRootOrgLoading } = useQuery({ + ...orgIdentityMembershipQuery.listAvailable({ + // identityName: debouncedSearchValue + }), + placeholderData: (prev) => prev + }); const { control, @@ -69,6 +82,7 @@ export const IdentityLinkForm = ({ onClose }: Props) => { value={value} onChange={onChange} placeholder="Select identity..." + // onInputChange={setSearchValue} options={rootOrgIdentities} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -94,7 +108,6 @@ export const IdentityLinkForm = ({ onClose }: Props) => { placeholder="Select role..." getOptionValue={(option) => option.slug} getOptionLabel={(option) => option.name} - // menuPortalTarget={document.body} /> )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx similarity index 67% rename from frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx rename to frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx index 09e779fa9..9c54a1e54 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx @@ -14,13 +14,11 @@ import { FormLabel, IconButton, Input, - Modal, - ModalContent, Switch } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { findOrgMembershipRole } from "@app/helpers/roles"; -import { useCreateIdentity, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; +import { useCreateOrgIdentity, useGetOrgRoles, useUpdateOrgIdentity } from "@app/hooks/api"; import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -47,7 +45,7 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; }; -export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { +export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const navigate = useNavigate(); const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; @@ -55,8 +53,8 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { const { data: roles } = useGetOrgRoles(orgId); const isOrgIdentity = popUp?.identity?.data ? orgId === popUp?.identity?.data?.orgId : true; - const { mutateAsync: createMutateAsync } = useCreateIdentity(); - const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); + const { mutateAsync: createMutateAsync } = useCreateOrgIdentity(); + const { mutateAsync: updateMutateAsync } = useUpdateOrgIdentity(); const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); const { @@ -173,75 +171,66 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { }; return ( - { - handlePopUpToggle("identity", isOpen); - reset(); - }} - > - -
- {isOrgIdentity && ( - ( - - - - )} - /> + + {isOrgIdentity && ( + ( + + + )} - ( - + )} + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + {isOrgIdentity && ( + ( + + - option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> - {isOrgIdentity && ( - ( - - -

Delete Protection {value ? "Enabled" : "Disabled"}

-
-
- )} - /> +

Delete Protection {value ? "Enabled" : "Disabled"}

+ +
)} + /> + )} + {isOrgIdentity && ( + <>
@@ -303,26 +292,26 @@ export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { -
- - -
- -
-
+ + )} +
+ + +
+ ); }; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx index 21e376c2e..d786a7d75 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/LdapConnectionForm.tsx @@ -4,8 +4,10 @@ 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 { useQuery } from "@tanstack/react-query"; import { z } from "zod"; +import { OrgPermissionCan } from "@app/components/permissions"; import { Button, FormControl, @@ -18,8 +20,11 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { OrgPermissionSubjects, useSubscription } from "@app/context"; +import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; import { DistinguishedNameRegex, UserPrincipalNameRegex } from "@app/helpers/string"; +import { gatewaysQueryKeys } from "@app/hooks/api"; import { LdapConnectionMethod, LdapConnectionProvider, @@ -84,6 +89,7 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { defaultValues: appConnection ?? { app: AppConnection.LDAP, method: LdapConnectionMethod.SimpleBind, + gatewayId: null, credentials: { provider: LdapConnectionProvider.ActiveDirectory, url: "", @@ -104,6 +110,8 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { const selectedProvider = watch("credentials.provider"); const sslEnabled = watch("credentials.url")?.startsWith("ldaps://") ?? false; + const { subscription } = useSubscription(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); return ( @@ -114,6 +122,57 @@ export const LdapConnectionForm = ({ appConnection, onSubmit }: Props) => { }} > {!isUpdate && } + {subscription.gateway && ( + + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+ )}
{ const identityId = params.identityId as string; const { currentOrg, isSubOrganization } = useOrganization(); const orgId = currentOrg?.id || ""; - const { data } = useGetIdentityById(identityId); - const { mutateAsync: deleteIdentity } = useDeleteIdentity(); + const { data } = useGetOrgIdentityMembershipById(identityId); + const { mutateAsync: deleteIdentity, isPending: isDeletingIdentity } = useDeleteOrgIdentity(); const isAuthHidden = orgId !== data?.identity?.orgId; const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ @@ -46,7 +51,7 @@ const Page = () => { const onDeleteIdentitySubmit = async (id: string) => { await deleteIdentity({ identityId: id, - organizationId: orgId + orgId }); createNotification({ @@ -81,7 +86,36 @@ const Page = () => { scope={isSubOrganization ? "namespace" : "org"} description={`${isSubOrganization ? "Sub-" : ""}Organization Identity`} title={data.identity.name} - /> + > +
+ {isSubOrganization && data.identity.orgId !== currentOrg.id && ( + + {(isAllowed) => ( + + )} + + )} +
+
{
)} - + handlePopUpToggle("identity", isOpen)} + > + + + + { - const { data, refetch } = useGetIdentityById(identityId); + const { data, refetch } = useGetOrgIdentityMembershipById(identityId); return data ? (
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityClientSecrets.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityClientSecrets.tsx index c1bd0adf6..10129be26 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityClientSecrets.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityClientSecrets.tsx @@ -7,9 +7,9 @@ import { Button, IconButton, Tooltip } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { - useGetIdentityById, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets + useGetIdentityUniversalAuthClientSecrets, + useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -30,7 +30,7 @@ export const IdentityClientSecrets = ({ identityId, handlePopUpOpen }: Props) => initialState: "Copy Client ID to clipboard" }); - const { data } = useGetIdentityById(identityId); + const { data } = useGetOrgIdentityMembershipById(identityId); const { data: identityUniversalAuth } = useGetIdentityUniversalAuth(identityId); const { data: clientSecrets } = useGetIdentityUniversalAuthClientSecrets(identityId); return ( diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityTokens.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityTokens.tsx index 118fea015..d0c562bc9 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityTokens.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityAuthenticationSection/IdentityTokens.tsx @@ -11,7 +11,7 @@ import { IconButton, Tooltip } from "@app/components/v2"; -import { useGetIdentityById, useGetIdentityTokensTokenAuth } from "@app/hooks/api"; +import { useGetIdentityTokensTokenAuth, useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -23,7 +23,7 @@ type Props = { }; export const IdentityTokens = ({ identityId, handlePopUpOpen }: Props) => { - const { data } = useGetIdentityById(identityId); + const { data } = useGetOrgIdentityMembershipById(identityId); const { data: tokens } = useGetIdentityTokensTokenAuth(identityId); return (
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx index 30429d48d..b19901bef 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx @@ -23,7 +23,7 @@ import { } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { useTimedReset } from "@app/hooks"; -import { identityAuthToNameMap, useGetIdentityById } from "@app/hooks/api"; +import { identityAuthToNameMap, useGetOrgIdentityMembershipById } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { @@ -41,7 +41,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdent }); const { isSubOrganization } = useOrganization(); - const { data } = useGetIdentityById(identityId); + const { data } = useGetOrgIdentityMembershipById(identityId); return data ? (
@@ -86,7 +86,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdent }} disabled={!isAllowed} > - Edit Identity + {isOrgIdentity ? "Edit Identity" : "Edit Identity Role"} )} @@ -110,7 +110,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdent icon={} disabled={!isAllowed} > - Delete Identity + {!isOrgIdentity ? "Remove From Sub-Organization" : "Delete Identity"} )} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx index 2f407142a..5c4dcd6dd 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx @@ -14,7 +14,7 @@ import { } from "@app/components/v2"; import { useOrganization } from "@app/context"; import { - useAddIdentityToWorkspace, + useCreateProjectIdentityMembership, useGetIdentityProjectMemberships, useGetProjectRoles, useGetUserProjects, @@ -45,7 +45,7 @@ type Props = { const Content = ({ identityId, handlePopUpToggle }: Omit) => { const { currentOrg } = useOrganization(); const { data: workspaces = [] } = useGetUserProjects(); - const { mutateAsync: addIdentityToWorkspace } = useAddIdentityToWorkspace(); + const { mutateAsync: addIdentityToWorkspace } = useCreateProjectIdentityMembership(); const { control, diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx index 1622962ea..6bb25e2c1 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectRow.tsx @@ -9,7 +9,7 @@ import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; import { getProjectBaseURL } from "@app/helpers/project"; import { formatProjectRoleName } from "@app/helpers/roles"; import { useGetUserProjects } from "@app/hooks/api"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { IdentityProjectMembershipV1 } from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; export enum TabSections { @@ -20,7 +20,7 @@ export enum TabSections { } type Props = { - membership: IdentityMembership; + membership: IdentityProjectMembershipV1; handlePopUpOpen: ( popUpName: keyof UsePopUpState<["removeIdentityFromProject"]>, data?: object diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsSection.tsx index 8d67ef6a4..542ed1486 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsSection.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { DeleteActionModal, IconButton } from "@app/components/v2"; -import { useDeleteIdentityFromWorkspace } from "@app/hooks/api"; +import { useDeleteProjectIdentityMembership } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; import { IdentityAddToProjectModal } from "./IdentityAddToProjectModal"; @@ -14,7 +14,7 @@ type Props = { }; export const IdentityProjectsSection = ({ identityId }: Props) => { - const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + const { mutateAsync: deleteMutateAsync } = useDeleteProjectIdentityMembership(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "addIdentityToProject", diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx index 4ec5871a2..d1bca3787 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityAuthLockoutFields.tsx @@ -1,11 +1,18 @@ import { useState } from "react"; +import { subject } from "@casl/ability"; import { UseMutationResult } from "@tanstack/react-query"; +import { useParams } from "@tanstack/react-router"; import ms from "ms"; import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { VariablePermissionCan } from "@app/components/permissions"; import { Button } from "@app/components/v2"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + ProjectPermissionIdentityActions, + ProjectPermissionSub +} from "@app/context"; import { IdentityAuthFieldDisplay } from "./IdentityAuthFieldDisplay"; @@ -31,7 +38,11 @@ export const LockoutFields = ({ const [lockedOutState, setLockedOutState] = useState(lockedOut); - const clearLockouts = async () => { + const { projectId } = useParams({ + strict: false + }); + + async function clearLockouts() { const deleted = await mutateAsync({ identityId }); createNotification({ text: `Successfully cleared ${deleted} lockout${deleted === 1 ? "" : "s"}`, @@ -39,13 +50,23 @@ export const LockoutFields = ({ }); setLockedOutState(false); onResetAllLockouts(); - }; + } return ( <>
Lockout Options - + {(isAllowed) => (
{data.lockoutThreshold} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx index cc11d9d10..d0d0b0fcd 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityTokenAuthTokensTable.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; +import { subject } from "@casl/ability"; import { faBan, faEdit, faKey, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useParams } from "@tanstack/react-router"; import { format } from "date-fns"; import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { VariablePermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -20,7 +22,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + ProjectPermissionIdentityActions, + ProjectPermissionSub +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useRevokeIdentityTokenAuthToken } from "@app/hooks/api"; import { IdentityAccessToken } from "@app/hooks/api/identities/types"; @@ -37,6 +44,10 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => { "revokeToken" ] as const); + const { projectId } = useParams({ + strict: false + }); + const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(5); @@ -68,7 +79,17 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => {
Access Tokens - + {(isAllowed) => ( )} - +
@@ -132,9 +153,20 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => { diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx index 017f30719..cfecb5f3c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/IdentityUniversalAuthClientSecretsTable.tsx @@ -1,10 +1,12 @@ import { useState } from "react"; +import { subject } from "@casl/ability"; import { faKey, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useParams } from "@tanstack/react-router"; import { format } from "date-fns"; import { createNotification } from "@app/components/notifications"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { VariablePermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, @@ -20,7 +22,12 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + ProjectPermissionIdentityActions, + ProjectPermissionSub +} from "@app/context"; import { usePopUp } from "@app/hooks"; import { useRevokeIdentityUniversalAuthClientSecret } from "@app/hooks/api"; import { ClientSecretData } from "@app/hooks/api/identities/types"; @@ -37,6 +44,10 @@ export const IdentityUniversalAuthClientSecretsTable = ({ clientSecrets, identit "clientSecret" ] as const); + const { projectId } = useParams({ + strict: false + }); + const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(5); @@ -60,7 +71,17 @@ export const IdentityUniversalAuthClientSecretsTable = ({ clientSecrets, identit
Client Secrets - + {(isAllowed) => (
- {(isAllowed) => ( @@ -155,11 +187,22 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => { )} - + {!isAccessTokenRevoked && ( - {(isAllowed) => ( @@ -181,7 +224,7 @@ export const IdentityTokenAuthTokensTable = ({ tokens, identityId }: Props) => { )} - + )}
@@ -120,9 +141,20 @@ export const IdentityUniversalAuthClientSecretsTable = ({ clientSecrets, identit {expiresAt ? format(expiresAt, "yyyy-MM-dd") : "-"} ); diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx index 6d6ca6cdc..5249d7f65 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAliCloudAuthContent.tsx @@ -49,6 +49,7 @@ export const ViewIdentityAliCloudAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx index be5ae2cc9..5c12ddd4c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAuthModal.tsx @@ -1,3 +1,5 @@ +import { useParams } from "@tanstack/react-router"; + import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { DeleteActionModal, Modal, ModalContent } from "@app/components/v2"; @@ -46,8 +48,7 @@ type Props = { type TRevokeOptions = { identityId: string; - organizationId: string; -}; +} & ({ projectId: string } | { organizationId: string }); export const Content = ({ identityId, @@ -61,7 +62,9 @@ export const Content = ({ >) => { const { currentOrg } = useOrganization(); const orgId = currentOrg?.id || ""; - + const { projectId } = useParams({ + strict: false + }); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ "revokeAuthMethod", "upgradePlan", @@ -142,7 +145,11 @@ export const Content = ({ const handleDeleteAuthMethod = async () => { await revokeMethod({ identityId, - organizationId: orgId + ...(projectId + ? { projectId } + : { + organizationId: orgId + }) }); createNotification({ diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx index e7130d8a2..dde9f1d2a 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAwsAuthContent.tsx @@ -46,6 +46,7 @@ export const ViewIdentityAwsAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx index ea9ee09ea..82e4c69e0 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityAzureAuthContent.tsx @@ -46,6 +46,7 @@ export const ViewIdentityAzureAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx index 7ea2c8ba3..f3a4cb369 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityContentWrapper.tsx @@ -1,8 +1,10 @@ import { ReactNode } from "react"; +import { subject } from "@casl/ability"; import { faChevronDown, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useParams } from "@tanstack/react-router"; -import { OrgPermissionCan } from "@app/components/permissions"; +import { VariablePermissionCan } from "@app/components/permissions"; import { Button, DropdownMenu, @@ -10,15 +12,25 @@ import { DropdownMenuItem, DropdownMenuTrigger } from "@app/components/v2"; -import { OrgPermissionIdentityActions, OrgPermissionSubjects } from "@app/context"; +import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, + ProjectPermissionIdentityActions, + ProjectPermissionSub +} from "@app/context"; type Props = { children: ReactNode; onEdit: VoidFunction; onDelete: VoidFunction; + identityId: string; }; -export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props) => { +export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit, identityId }: Props) => { + const { projectId } = useParams({ + strict: false + }); + return (
@@ -36,9 +48,20 @@ export const ViewIdentityContentWrapper = ({ children, onDelete, onEdit }: Props - {(isAllowed) => ( )} - - + {(isAllowed) => ( )} - +
diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx index 4008850e9..296b4d764 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityGcpAuthContent.tsx @@ -46,6 +46,7 @@ export const ViewIdentityGcpAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx index f068a2b7f..7fb81d2e1 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityJwtAuthContent.tsx @@ -49,6 +49,7 @@ export const ViewIdentityJwtAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx index 62c2b210e..02b0e0c88 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityKubernetesAuthContent.tsx @@ -59,6 +59,7 @@ export const ViewIdentityKubernetesAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx index c3ea58db4..b6db96c41 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityLdapAuthContent.tsx @@ -52,6 +52,7 @@ export const ViewIdentityLdapAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx index fb3bd4fa8..c577c01e0 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOciAuthContent.tsx @@ -46,6 +46,7 @@ export const ViewIdentityOciAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx index 19130f914..dc7e5b985 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityOidcAuthContent.tsx @@ -48,6 +48,7 @@ export const ViewIdentityOidcAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx index 1e0878add..03c4989a6 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTlsCertAuthContent.tsx @@ -51,6 +51,7 @@ export const ViewIdentityTlsCertAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx index 516289f74..d42f9d2ca 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityTokenAuthContent.tsx @@ -49,6 +49,7 @@ export const ViewIdentityTokenAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {data.accessTokenTTL} diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx index 4d7b95087..e9840a28c 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal/ViewIdentityUniversalAuthContent.tsx @@ -66,6 +66,7 @@ export const ViewIdentityUniversalAuthContent = ({ handlePopUpOpen("identityAuthMethod")} onDelete={onDelete} + identityId={identityId} > {Number(data.accessTokenPeriod) > 0 ? ( diff --git a/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx b/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx index b34f33721..fb48734bb 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/ResourceTypeSelect.tsx @@ -26,9 +26,21 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { return [ ...resourceOptions, // We are temporarily showing these resources so that we can gauge interest before committing + { name: "OracleDB", resource: PamResourceType.OracleDB }, + { name: "SQLite", resource: PamResourceType.SQLite }, + { name: "Microsoft SQL Server", resource: PamResourceType.MsSQL }, + { name: "MongoDB", resource: PamResourceType.MongoDB }, + { name: "Cassandra", resource: PamResourceType.Cassandra }, + { name: "CockroachDB", resource: PamResourceType.CockroachDB }, + { name: "DynamoDB", resource: PamResourceType.DynamoDB }, + { name: "Snowflake", resource: PamResourceType.Snowflake }, + { name: "Elasticsearch", resource: PamResourceType.Elasticsearch }, + { name: "Redis", resource: PamResourceType.Redis }, { name: "RDP", resource: PamResourceType.RDP }, { name: "SSH", resource: PamResourceType.SSH }, - { name: "Kubernetes", resource: PamResourceType.Kubernetes } + { name: "Kubernetes", resource: PamResourceType.Kubernetes }, + { name: "MCP", resource: PamResourceType.MCP }, + { name: "Web Application", resource: PamResourceType.WebApp } ]; }, [resourceOptions]); @@ -38,11 +50,13 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { const filteredOptions = useMemo( () => - appendedResourceOptions?.filter( - ({ name, resource }) => - name.toLowerCase().includes(search.trim().toLowerCase()) || - resource.toLowerCase().includes(search.trim().toLowerCase()) - ) ?? [], + appendedResourceOptions + ?.filter( + ({ name, resource }) => + name.toLowerCase().includes(search.trim().toLowerCase()) || + resource.toLowerCase().includes(search.trim().toLowerCase()) + ) + .sort((a, b) => a.name.localeCompare(b.name)) ?? [], [appendedResourceOptions, search] ); @@ -65,7 +79,16 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { if ( resource === PamResourceType.RDP || resource === PamResourceType.SSH || - resource === PamResourceType.Kubernetes + resource === PamResourceType.Kubernetes || + resource === PamResourceType.MCP || + resource === PamResourceType.Redis || + resource === PamResourceType.MongoDB || + resource === PamResourceType.WebApp || + resource === PamResourceType.Cassandra || + resource === PamResourceType.CockroachDB || + resource === PamResourceType.Elasticsearch || + resource === PamResourceType.Snowflake || + resource === PamResourceType.DynamoDB ) { handlePopUpOpen("upgradePlan", { text: "Your current plan does not include access to this resource type. To unlock this feature, please upgrade to Infisical Enterprise plan.", @@ -105,17 +128,16 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { onClick={() => handleResourceSelect(option.resource)} className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600" > -
+
{`${name}
-
+
{name}
diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx index 48e01e2aa..e98633368 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { subject } from "@casl/ability"; import { faArrowDown, @@ -11,7 +12,8 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; -import { format } from "date-fns"; +import { AnimatePresence, motion } from "framer-motion"; +import { LinkIcon, PlusIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -29,6 +31,8 @@ import { HoverCardTrigger, IconButton, Input, + Modal, + ModalContent, Pagination, Spinner, Table, @@ -42,8 +46,20 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { DocumentationLinkBadge } from "@app/components/v3"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { Blur } from "@app/components/v2/Blur"; +import { + Badge, + DocumentationLinkBadge, + OrgIcon, + ProjectIcon, + SubOrgIcon +} from "@app/components/v3"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useOrganization, + useProject +} from "@app/context"; import { getProjectBaseURL } from "@app/helpers/project"; import { formatProjectRoleName } from "@app/helpers/roles"; import { @@ -53,19 +69,33 @@ import { } from "@app/helpers/userTablePreferences"; import { withProjectPermission } from "@app/hoc"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api"; +import { + useDeleteProjectIdentity, + useDeleteProjectIdentityMembership, + useListProjectIdentityMemberships +} from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectIdentityOrderBy } from "@app/hooks/api/projects/types"; import { usePopUp } from "@app/hooks/usePopUp"; +import { ProjectIdentityModal } from "@app/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal"; -import { IdentityModal } from "./components/IdentityModal"; +import { ProjectLinkIdentityModal } from "./components/ProjectLinkIdentityModal"; const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; +enum WizardSteps { + SelectAction = "select-action", + LinkIdentity = "link-identity", + ProjectIdentity = "project-identity" +} + export const IdentityTab = withProjectPermission( () => { const { currentProject, projectId } = useProject(); const navigate = useNavigate(); + const { isSubOrganization, currentOrg } = useOrganization(); + + const [wizardStep, setWizardStep] = useState(WizardSteps.SelectAction); const { offset, @@ -90,7 +120,7 @@ export const IdentityTab = withProjectPermission( setUserTablePreference("projectIdentityTable", PreferenceKey.PerPage, newPerPage); }; - const { data, isPending, isFetching } = useGetWorkspaceIdentityMemberships( + const { data, isPending, isFetching } = useListProjectIdentityMemberships( { projectId, offset, @@ -110,28 +140,41 @@ export const IdentityTab = withProjectPermission( setPage }); - const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace(); + const { mutateAsync: deleteMembershipMutateAsync } = useDeleteProjectIdentityMembership(); + const { mutateAsync: deleteProjectIdentity } = useDeleteProjectIdentity(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ - "identity", + "createIdentity", "deleteIdentity", - "upgradePlan" + "upgradePlan", + "addOptions" ] as const); - const onRemoveIdentitySubmit = async (identityId: string) => { - await deleteMutateAsync({ - identityId, - projectId - }); + const onRemoveIdentitySubmit = async (identityId: string, isProjectIdentity: boolean) => { + if (isProjectIdentity) { + await deleteProjectIdentity({ + identityId, + projectId + }); - createNotification({ - text: "Successfully removed identity from project", - type: "success" - }); + createNotification({ + text: "Successfully deleted project identity", + type: "success" + }); + } else { + await deleteMembershipMutateAsync({ + identityId, + projectId + }); + + createNotification({ + text: "Successfully removed identity from project", + type: "success" + }); + } handlePopUpClose("deleteIdentity"); }; - const handleSort = (column: ProjectIdentityOrderBy) => { if (column === orderBy) { setOrderDirection((prev) => @@ -144,6 +187,12 @@ export const IdentityTab = withProjectPermission( setOrderDirection(OrderByDirection.ASC); }; + const noAccessIdentityCount = Math.max( + (page * perPage > totalCount ? totalCount % perPage : perPage) - + (data?.identityMemberships?.length || 0), + 0 + ); + return (
@@ -151,22 +200,23 @@ export const IdentityTab = withProjectPermission(

Identities

- - {(isAllowed) => ( - - )} - +
+ + {(isAllowed) => ( + + )} + +
- + @@ -213,9 +263,8 @@ export const IdentityTab = withProjectPermission( data.identityMemberships.length > 0 && data.identityMemberships.map((identityMember) => { const { - identity: { id, name }, - roles, - createdAt + identity: { id, name, projectId: identityProjectId, orgId: identityOrgId }, + roles } = identityMember; return ( - + ); })} + {!isPending && + data && + data?.totalCount !== 0 && + Array.from(Array(noAccessIdentityCount)).map((_e, i) => ( + + + + + ))}
- {(isAllowed) => ( @@ -143,7 +175,7 @@ export const IdentityUniversalAuthClientSecretsTable = ({ clientSecrets, identit )} - +
RoleAdded onManaged by {isFetching ? : null}
{format(new Date(createdAt), "yyyy-MM-dd")} + + {/* eslint-disable-next-line no-nested-ternary */} + {identityProjectId ? ( + <> + + Project + + ) : isSubOrganization && currentOrg.id === identityOrgId ? ( + <> + + Sub-Organization + + ) : ( + <> + + Organization + + )} + + @@ -369,11 +447,12 @@ export const IdentityTab = withProjectPermission( evt.preventDefault(); handlePopUpOpen("deleteIdentity", { identityId: id, - name + name, + isProjectIdentity: Boolean(identityProjectId) }); }} > - Remove Identity From Project + {identityProjectId ? "Delete Identity" : "Remove From Project"} )} @@ -384,6 +463,20 @@ export const IdentityTab = withProjectPermission(
No Access + +
{!isPending && data && totalCount > 0 && ( @@ -395,18 +488,113 @@ export const IdentityTab = withProjectPermission( onChangePerPage={handlePerPageChange} /> )} - {!isPending && data && data?.identityMemberships.length === 0 && ( - 0 - ? "No identities match search filter" - : "No identities have been added to this project" - } - icon={faServer} - /> - )} + {!isPending && + data && + data?.identityMemberships.length === 0 && + data?.totalCount === 0 && ( + 0 + ? "No identities match search filter" + : "No identities have been added to this project" + } + icon={faServer} + /> + )}
- + { + handlePopUpToggle("createIdentity", open); + if (!open) setWizardStep(WizardSteps.SelectAction); + }} + > + + + {wizardStep === WizardSteps.SelectAction && ( + +
setWizardStep(WizardSteps.ProjectIdentity)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setWizardStep(WizardSteps.ProjectIdentity); + } + }} + > +
+ +
Create New Identity
+
+
+ Create a new machine identity specifically for this project. This identity + will be managed at the project-level. +
+
+
setWizardStep(WizardSteps.LinkIdentity)} + onKeyDown={(e) => { + if (e.key === "Enter") { + setWizardStep(WizardSteps.LinkIdentity); + } + }} + > +
+ +
Assign Existing Identity
+
+
+ Assign an existing identity from your organization. The identity will continue + to be managed at its original scope. +
+
+
+ )} + {wizardStep === WizardSteps.ProjectIdentity && ( + + { + handlePopUpClose("createIdentity"); + setWizardStep(WizardSteps.SelectAction); + }} + /> + + )} + {wizardStep === WizardSteps.LinkIdentity && ( + + + + )} +
+
+
onRemoveIdentitySubmit( - (popUp?.deleteIdentity?.data as { identityId: string })?.identityId + popUp?.deleteIdentity?.data?.identityId, + popUp?.deleteIdentity?.data?.isProjectIdentity ) } /> diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx deleted file mode 100644 index 3274185b1..000000000 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/IdentityModal.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useMemo } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { components, OptionProps } from "react-select"; -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Link } from "@tanstack/react-router"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { - Button, - FilterableSelect, - FormControl, - Modal, - ModalClose, - ModalContent, - Spinner -} from "@app/components/v2"; -import { Badge, OrgIcon } from "@app/components/v3"; -import { useOrganization, useProject } from "@app/context"; -import { - useAddIdentityToWorkspace, - useGetIdentityMembershipOrgs, - useGetProjectRoles, - useGetWorkspaceIdentityMemberships -} from "@app/hooks/api"; -import { UsePopUpState } from "@app/hooks/usePopUp"; - -const schema = z.object({ - identity: z.object({ name: z.string(), id: z.string(), isManagedByRootOrg: z.boolean() }), - role: z.object({ name: z.string(), slug: z.string() }) -}); - -export type FormData = z.infer; - -type Props = { - popUp: UsePopUpState<["identity"]>; - handlePopUpToggle: (popUpName: keyof UsePopUpState<["identity"]>, state?: boolean) => void; -}; - -const Option = ({ - isSelected, - children, - ...props -}: OptionProps<{ name: string; id: string; isManagedByRootOrg: boolean }>) => { - return ( - -
-

{children}

- {props.data.isManagedByRootOrg && ( - - - Organization - - )} - {isSelected && ( - - )} -
-
- ); -}; - -const Content = ({ popUp, handlePopUpToggle }: Props) => { - const { currentOrg } = useOrganization(); - const { projectId } = useProject(); - - const organizationId = currentOrg?.id || ""; - - const { data: identityMembershipOrgsData, isPending: isMembershipsLoading } = - useGetIdentityMembershipOrgs({ - organizationId, - limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will replace with combobox in separate PR - }); - const identityMembershipOrgs = identityMembershipOrgsData?.identityMemberships; - const { data: identityMembershipsData } = useGetWorkspaceIdentityMemberships({ - projectId, - limit: 20000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above - }); - const identityMemberships = identityMembershipsData?.identityMemberships; - - const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(projectId); - - const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); - - const filteredIdentityMembershipOrgs = useMemo(() => { - const wsIdentityIds = new Map(); - - identityMemberships?.forEach((identityMembership) => { - wsIdentityIds.set(identityMembership.identity.id, true); - }); - - return (identityMembershipOrgs || []).filter(({ identity: i }) => !wsIdentityIds.has(i.id)); - }, [identityMembershipOrgs, identityMemberships]); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(schema) - }); - - const onFormSubmit = async ({ identity, role }: FormData) => { - await addIdentityToWorkspaceMutateAsync({ - projectId, - identityId: identity.id, - role: role.slug || undefined - }); - - createNotification({ - text: "Successfully added identity to project", - type: "success" - }); - - const nextAvailableMembership = filteredIdentityMembershipOrgs.filter( - (membership) => membership.identity.id !== identity.id - )[0]; - - // prevents combobox from displaying previously added identity - reset({ - identity: { - name: nextAvailableMembership?.identity.name, - id: nextAvailableMembership?.identity.id - } - }); - handlePopUpToggle("identity", false); - }; - - if (isMembershipsLoading || isRolesLoading) - return ( -
- -
- ); - - return filteredIdentityMembershipOrgs.length ? ( -
- ( - - ({ - ...membership.identity, - isManagedByRootOrg: membership.identity.orgId !== currentOrg.id - }))} - getOptionValue={(option) => option.id} - getOptionLabel={(option) => option.name} - components={{ - Option - }} - /> - - )} - /> - ( - - option.slug} - getOptionLabel={(option) => option.name} - /> - - )} - /> -
- - - - -
- - ) : ( -
-
- All identities in your organization have already been added to this project. -
- - - -
- ); -}; - -export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { - return ( - { - handlePopUpToggle("identity", isOpen); - }} - > - - - - - ); -}; diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx new file mode 100644 index 000000000..327144733 --- /dev/null +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx @@ -0,0 +1,292 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useNavigate } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FilterableSelect, + FormControl, + FormLabel, + IconButton, + Input, + ModalClose, + Switch +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { getProjectBaseURL } from "@app/helpers/project"; +import { + TProjectIdentity, + useCreateProjectIdentity, + useGetProjectRoles, + useUpdateProjectIdentity, + useUpdateProjectIdentityMembership +} from "@app/hooks/api"; +import { useAddIdentityUniversalAuth } from "@app/hooks/api/identities"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; + +const schema = z.object({ + name: z.string().min(1, "Required"), + hasDeleteProtection: z.boolean(), + role: z.object({ slug: z.string(), name: z.string() }).optional(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().min(1) + }) + .array() + .default([]) + .optional() +}); +export type FormData = z.infer; + +type ContentProps = { + onClose: () => void; + identity?: TProjectIdentity; +}; + +export const ProjectIdentityModal = ({ onClose, identity }: ContentProps) => { + const navigate = useNavigate(); + + const { currentProject } = useProject(); + + const isUpdate = Boolean(identity); + + const { data: roles } = useGetProjectRoles(currentProject.id); + + const { mutateAsync: createMutateAsync } = useCreateProjectIdentity(); + const { mutateAsync: updateMutateAsync } = useUpdateProjectIdentity(); + const { mutateAsync: addMutateAsync } = useAddIdentityUniversalAuth(); + const { mutateAsync: updateMembershipMutateAsync } = useUpdateProjectIdentityMembership(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: identity?.name ?? "", + hasDeleteProtection: identity?.hasDeleteProtection ?? false, + metadata: identity?.metadata ?? [], + role: isUpdate ? undefined : { slug: ProjectMembershipRole.NoAccess, name: "No Access" } + } + }); + + const metadataFormFields = useFieldArray({ + control, + name: "metadata" + }); + + const onFormSubmit = async ({ name, role, metadata, hasDeleteProtection }: FormData) => { + try { + if (identity) { + // update + await updateMutateAsync({ + identityId: identity.id, + name, + hasDeleteProtection, + projectId: currentProject.id, + metadata + }); + + onClose(); + } else { + // create + + const { id: createdId } = await createMutateAsync({ + name, + projectId: currentProject.id, + hasDeleteProtection, + metadata + }); + + if (role) { + await updateMembershipMutateAsync({ + roles: [{ role: role.slug }], + identityId: createdId, + projectId: currentProject.id + }); + } + + await addMutateAsync({ + projectId: currentProject.id, + identityId: createdId, + clientSecretTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + accessTokenTrustedIps: [{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }], + accessTokenTTL: 2592000, + accessTokenMaxTTL: 2592000, + accessTokenNumUsesLimit: 0, + accessTokenPeriod: 0, + lockoutEnabled: true, + lockoutThreshold: 3, + lockoutDurationSeconds: 300, + lockoutCounterResetSeconds: 30 + }); + + onClose(); + navigate({ + to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId`, + params: { + identityId: createdId + } + }); + } + + createNotification({ + text: `Successfully ${isUpdate ? "updated" : "created"} project identity`, + type: "success" + }); + + reset(); + } catch (err) { + console.error(err); + const error = err as any; + const text = + error?.response?.data?.message ?? + `Failed to ${isUpdate ? "update" : "create"} project identity`; + + createNotification({ + text, + type: "error" + }); + } + }; + + return ( +
+ ( + + + + )} + /> + {!isUpdate && ( + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> + )} + ( + + +

Delete Protection {value ? "Enabled" : "Disabled"}

+
+
+ )} + /> +
+ +
+
+ {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( +
+
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ metadataFormFields.remove(i)} + > + + +
+ ))} +
+ +
+
+
+ + + + +
+ + ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectLinkIdentityModal.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectLinkIdentityModal.tsx new file mode 100644 index 000000000..46e910432 --- /dev/null +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectLinkIdentityModal.tsx @@ -0,0 +1,172 @@ +import { useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FilterableSelect, FormControl, ModalClose, Spinner } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { + projectIdentityMembershipQuery, + useCreateProjectIdentityMembership, + useGetProjectRoles, + useListProjectIdentityMemberships +} from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + identity: z.object({ + name: z.string(), + id: z.string() + }), + role: z.object({ name: z.string(), slug: z.string() }) +}); + +export type FormData = z.infer; + +type Props = { + handlePopUpToggle: (popUpName: keyof UsePopUpState<["createIdentity"]>, state?: boolean) => void; +}; + +export const ProjectLinkIdentityModal = ({ handlePopUpToggle }: Props) => { + const { projectId } = useProject(); + + // const [searchValue, setSearchValue] = useState(""); + + // const [debouncedSearchValue] = useDebounce(searchValue); + + // TODO: name search needs to be implemented on the backend + const { data: identityMembershipOrgs, isPending: isMembershipsLoading } = useQuery({ + ...projectIdentityMembershipQuery.listAvailable({ + projectId + // identityName: debouncedSearchValue + }), + placeholderData: (prev) => prev + }); + + const { data: identityMembershipsData } = useListProjectIdentityMemberships({ + projectId, + limit: 1000 // TODO: this is temp to preserve functionality for larger projects, will optimize in PR referenced above + }); + const identityMemberships = identityMembershipsData?.identityMemberships; + + const { data: roles, isPending: isRolesLoading } = useGetProjectRoles(projectId); + + const { mutateAsync: createProjectIdentityMembershipMutateAsync } = + useCreateProjectIdentityMembership(); + + const filteredIdentityMembershipOrgs = useMemo(() => { + const wsIdentityIds = new Map(); + + identityMemberships?.forEach((identityMembership) => { + wsIdentityIds.set(identityMembership.identity.id, true); + }); + + return (identityMembershipOrgs || []).filter((i) => !wsIdentityIds.has(i.id)); + }, [identityMembershipOrgs, identityMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ identity, role }: FormData) => { + await createProjectIdentityMembershipMutateAsync({ + projectId, + identityId: identity.id, + role: role.slug || undefined + }); + + createNotification({ + text: "Successfully added identity to project", + type: "success" + }); + + const nextAvailableMembership = filteredIdentityMembershipOrgs.filter( + (membership) => membership.id !== identity.id + )[0]; + + // prevents combobox from displaying previously added identity + reset({ + identity: { + name: nextAvailableMembership?.name, + id: nextAvailableMembership?.id + } + }); + handlePopUpToggle("createIdentity", false); + }; + + if (isMembershipsLoading || isRolesLoading) + return ( +
+ +
+ ); + + return ( +
+ ( + + ({ + name: membership.name, + id: membership.id + }))} + getOptionValue={(option) => option.id} + getOptionLabel={(option) => option.name} + /> + + )} + /> + ( + + option.slug} + getOptionLabel={(option) => option.name} + /> + + )} + /> +
+ + + + +
+ + ); +}; diff --git a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx index 7ced08534..e717a5622 100644 --- a/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx +++ b/frontend/src/pages/project/GroupDetailsByIDPage/components/GroupMembersSection/GroupMembersTable.tsx @@ -24,7 +24,7 @@ import { THead, Tr } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { useOrganization, useProject } from "@app/context"; import { getProjectHomePage } from "@app/helpers/project"; import { getUserTablePreference, @@ -86,6 +86,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { setUserTablePreference("projectGroupMembersTable", PreferenceKey.PerPage, newPerPage); }; + const { isSubOrganization, currentOrg } = useOrganization(); const { currentProject } = useProject(); const { data: groupMemberships, isPending } = useListProjectGroupUsers({ @@ -153,7 +154,7 @@ export const GroupMembersTable = ({ groupMembership }: Props) => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentProject.type, currentProject.environments); + const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`; window.location.href = url.replace("$projectId", currentProject.id); } } diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 65a2a5710..e8a7d39e0 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -3,12 +3,15 @@ import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; +import { OrgPermissionCan, ProjectPermissionCan } from "@app/components/permissions"; import { + Alert, + AlertDescription, Button, ConfirmActionModal, DeleteActionModal, @@ -17,19 +20,25 @@ import { Spinner } from "@app/components/v2"; import { + OrgPermissionIdentityActions, + OrgPermissionSubjects, ProjectPermissionActions, ProjectPermissionIdentityActions, ProjectPermissionSub, + useOrganization, useProject } from "@app/context"; import { getProjectBaseURL, getProjectHomePage } from "@app/helpers/project"; import { usePopUp } from "@app/hooks"; import { useAssumeProjectPrivileges, - useDeleteIdentityFromWorkspace, - useGetWorkspaceIdentityMembershipDetails + useDeleteProjectIdentityMembership, + useGetProjectIdentityMembershipV2 } from "@app/hooks/api"; import { ActorType } from "@app/hooks/api/auditLogs/enums"; +import { projectIdentityQuery } from "@app/hooks/api/projectIdentity"; +import { ProjectIdentityAuthenticationSection } from "@app/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityAuthSection"; +import { ProjectIdentityDetailsSection } from "@app/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection"; import { ProjectAccessControlTabs } from "@app/types/project"; import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection"; @@ -42,12 +51,29 @@ const Page = () => { select: (el) => el.identityId as string }); const { currentProject, projectId } = useProject(); + const { currentOrg, isSubOrganization } = useOrganization(); const { data: identityMembershipDetails, isPending: isMembershipDetailsLoading } = - useGetWorkspaceIdentityMembershipDetails(projectId, identityId); + useGetProjectIdentityMembershipV2(projectId, identityId); const { mutateAsync: deleteMutateAsync, isPending: isDeletingIdentity } = - useDeleteIdentityFromWorkspace(); + useDeleteProjectIdentityMembership(); + + const isProjectIdentity = Boolean(identityMembershipDetails?.identity.projectId); + const isNonScopedIdentity = + !isProjectIdentity && currentOrg.id !== identityMembershipDetails?.identity?.orgId; + + const { + data: identity, + isPending: isProjectIdentityPending, + refetch: refetchIdentity + } = useQuery({ + ...projectIdentityQuery.getById({ + identityId: identityMembershipDetails?.identity.id as string, + projectId: identityMembershipDetails?.identity.projectId as string + }), + enabled: isProjectIdentity + }); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "deleteIdentity", @@ -68,7 +94,7 @@ const Page = () => { type: "success", text: "Identity privilege assumption has started" }); - const url = getProjectHomePage(currentProject.type, currentProject.environments); + const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization && isNonScopedIdentity ? `?subOrganization=${currentOrg.slug}` : ""}`; window.location.href = url.replace("$projectId", currentProject.id); } } @@ -96,7 +122,7 @@ const Page = () => { }); }; - if (isMembershipDetailsLoading) { + if (isMembershipDetailsLoading || (isProjectIdentity && isProjectIdentityPending)) { return (
@@ -124,7 +150,8 @@ const Page = () => {
{ )} - - {(isAllowed) => ( - - )} - + {!isProjectIdentity && ( + + {(isAllowed) => ( + + )} + + )}
- - + {!isProjectIdentity && ( + + + This identity is managed by your organization.{" "} + + {(isAllowed) => + isAllowed ? ( + + + Click here to manage identity. + + + ) : null + } + + + + )} +
+ {identity ? ( +
+ + refetchIdentity()} + /> +
+ ) : ( +
+
+ +
+
+ )} +
+ + +
+
{ @@ -46,13 +47,13 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe ] as const); const { permission } = useProjectPermission(); const identityId = identityMembershipDetails?.identity?.id; - const projectId = identityMembershipDetails?.project?.id; + const { projectId } = useProject(); const { mutateAsync: deletePrivilege } = useDeleteIdentityProjectAdditionalPrivilege(); const { data: identityProjectPrivileges, isPending } = useListIdentityProjectPrivileges({ identityId: identityMembershipDetails?.identity?.id, - projectId: identityMembershipDetails?.project?.id + projectId }); const handlePrivilegeDelete = async () => { diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx index dce632121..507a83a56 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleDetailsSection.tsx @@ -26,14 +26,14 @@ import { import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; import { formatProjectRoleName } from "@app/helpers/roles"; import { usePopUp } from "@app/hooks"; -import { useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; +import { useUpdateProjectIdentityMembership } from "@app/hooks/api"; +import { IdentityProjectMembershipV1 } from "@app/hooks/api/identities/types"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { IdentityRoleModify } from "./IdentityRoleModify"; type Props = { - identityMembershipDetails: IdentityMembership; + identityMembershipDetails: IdentityProjectMembershipV1; isMembershipDetailsLoading?: boolean; }; @@ -46,12 +46,12 @@ export const IdentityRoleDetailsSection = ({ "deleteRole", "modifyRole" ] as const); - const { mutateAsync: updateIdentityWorkspaceRole } = useUpdateIdentityWorkspaceRole(); + const { mutateAsync: updateIdentityProjectMembership } = useUpdateProjectIdentityMembership(); const handleRoleDelete = async () => { const { id } = popUp?.deleteRole?.data as TProjectRole; const updatedRoles = identityMembershipDetails?.roles?.filter((el) => el.id !== id); - await updateIdentityWorkspaceRole({ + await updateIdentityProjectMembership({ projectId: currentProject?.id || "", identityId: identityMembershipDetails.identity.id, roles: updatedRoles.map( diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx index 8bc451151..6216e1321 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityRoleDetailsSection/IdentityRoleModify.tsx @@ -32,10 +32,10 @@ import { useProject, useProjectPermission } from "@app/context"; -import { useGetProjectRoles, useUpdateIdentityWorkspaceRole } from "@app/hooks/api"; -import { IdentityMembership } from "@app/hooks/api/identities/types"; -import { ProjectUserMembershipTemporaryMode } from "@app/hooks/api/projects/types"; +import { useGetProjectRoles, useUpdateProjectIdentityMembership } from "@app/hooks/api"; +import { IdentityProjectMembershipV1 } from "@app/hooks/api/identities/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { TemporaryPermissionMode } from "@app/hooks/api/shared"; const roleFormSchema = z.object({ roles: z @@ -58,7 +58,7 @@ const roleFormSchema = z.object({ type TRoleForm = z.infer; type Props = { - identityProjectMembership: IdentityMembership; + identityProjectMembership: IdentityProjectMembershipV1; }; export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { @@ -95,10 +95,10 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { const formRoleField = roleForm.watch("roles"); - const updateIdentityWorkspaceRole = useUpdateIdentityWorkspaceRole(); + const updateProjectIdentityMembership = useUpdateProjectIdentityMembership(); const handleRoleUpdate = async (data: TRoleForm) => { - if (updateIdentityWorkspaceRole.isPending) return; + if (updateProjectIdentityMembership.isPending) return; const sanitizedRoles = data.roles.map((el) => { const { isTemporary } = el.temporaryAccess; @@ -108,13 +108,13 @@ export const IdentityRoleModify = ({ identityProjectMembership }: Props) => { return { role: el.slug, isTemporary: true as const, - temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryMode: TemporaryPermissionMode.Relative, temporaryRange: el.temporaryAccess.temporaryRange, temporaryAccessStartTime: el.temporaryAccess.temporaryAccessStartTime }; }); - await updateIdentityWorkspaceRole.mutateAsync({ + await updateProjectIdentityMembership.mutateAsync({ projectId, identityId: identityProjectMembership.identity.id, roles: sanitizedRoles diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityAuthSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityAuthSection.tsx new file mode 100644 index 000000000..f471a3d24 --- /dev/null +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityAuthSection.tsx @@ -0,0 +1,119 @@ +import { subject } from "@casl/ability"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { LockIcon, SettingsIcon } from "lucide-react"; + +import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub } from "@app/context"; +import { IdentityAuthMethod, identityAuthToNameMap, TProjectIdentity } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; +import { IdentityAuthMethodModal } from "@app/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModal"; +import { ViewIdentityAuthModal } from "@app/pages/organization/IdentityDetailsByIDPage/components/ViewIdentityAuthModal"; + +type Props = { + identity: TProjectIdentity; + refetchIdentity: () => void; +}; + +export const ProjectIdentityAuthenticationSection = ({ identity, refetchIdentity }: Props) => { + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "viewAuthMethod", + "identityAuthMethod", + "upgradePlan" + ]); + + return ( +
+
+

Authentication

+
+ {identity.authMethods.length > 0 ? ( +
+ {identity.authMethods.map((authMethod) => ( + + ))} +
+ ) : ( +
+

+ No authentication methods configured. Get started by creating a new auth method. +

+
+ )} + {!Object.values(IdentityAuthMethod).every((method) => + identity.authMethods.includes(method) + ) && ( + + {(isAllowed) => ( + + )} + + )} + + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + /> + handlePopUpToggle("viewAuthMethod", isOpen)} + authMethod={popUp.viewAuthMethod.data?.authMethod} + lockedOut={popUp.viewAuthMethod.data?.lockedOut || false} + identityId={identity.id} + onResetAllLockouts={popUp.viewAuthMethod.data?.refetchIdentity} + /> +
+ ); +}; diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx new file mode 100644 index 000000000..dae996489 --- /dev/null +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx @@ -0,0 +1,249 @@ +import { subject } from "@casl/ability"; +import { + faCheck, + faChevronDown, + faCopy, + faEdit, + faKey, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate } from "@tanstack/react-router"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + Button, + DeleteActionModal, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + IconButton, + Modal, + ModalContent, + Tag, + Tooltip +} from "@app/components/v2"; +import { ProjectPermissionIdentityActions, ProjectPermissionSub, useProject } from "@app/context"; +import { getProjectBaseURL } from "@app/helpers/project"; +import { usePopUp, useTimedReset } from "@app/hooks"; +import { identityAuthToNameMap, TProjectIdentity, useDeleteProjectIdentity } from "@app/hooks/api"; +import { IdentityProjectMembershipV1 } from "@app/hooks/api/identities/types"; +import { ProjectIdentityModal } from "@app/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal"; + +type Props = { + identity: TProjectIdentity; + isOrgIdentity?: boolean; + membership: IdentityProjectMembershipV1; +}; + +export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, membership }: Props) => { + const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + const { currentProject } = useProject(); + const { mutateAsync: deleteIdentity } = useDeleteProjectIdentity(); + const navigate = useNavigate(); + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "editIdentity", + "deleteIdentity" + ] as const); + + const handleDeleteIdentity = async () => { + try { + await deleteIdentity({ + identityId: identity.id, + projectId: identity.projectId! + }); + + navigate({ + to: `${getProjectBaseURL(currentProject.type)}/access-management`, + search: { + selectedTab: "identities" + } + }); + } catch { + createNotification({ + type: "error", + text: "Failed to delete project identity" + }); + } + }; + + return ( +
+
+

Identity Details

+ + {!isOrgIdentity && ( + + + + )} + + + {(isAllowed) => ( + } + onClick={async () => { + handlePopUpOpen("editIdentity"); + }} + disabled={!isAllowed} + > + Edit Identity + + )} + + + {(isAllowed) => ( + { + handlePopUpOpen("deleteIdentity"); + }} + icon={} + disabled={!isAllowed} + > + Delete Identity + + )} + + + +
+
+
+

Identity ID

+
+

{identity.id}

+
+ + { + navigator.clipboard.writeText(identity.id); + setCopyTextId("Copied"); + }} + > + + + +
+
+
+
+

Managed By

+

+ {identity.projectId ? "Project" : "Organization"} +

+
+ {!isOrgIdentity && ( + <> +
+

Last Login Auth Method

+

+ {membership.lastLoginAuthMethod + ? identityAuthToNameMap[membership.lastLoginAuthMethod] + : "-"} +

+
+
+

Last Login Time

+

+ {membership.lastLoginTime ? format(membership.lastLoginTime, "PPpp") : "-"} +

+
+
+

Delete Protection

+

+ {identity.hasDeleteProtection ? "On" : "Off"} +

+
+ + )} +
+

Metadata

+ {identity?.metadata?.length ? ( +
+ {identity.metadata?.map((el) => ( +
+ + +
{el.key}
+
+ +
+ {el.value} +
+
+
+ ))} +
+ ) : ( +

-

+ )} +
+
+ handlePopUpToggle("editIdentity", open)} + > + + handlePopUpToggle("editIdentity", false)} + /> + + + + handlePopUpToggle("deleteIdentity", isOpen)} + deleteKey="confirm" + onDeleteApproved={handleDeleteIdentity} + /> +
+ ); +}; diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index 5d8ae812c..2c578ea4f 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -42,7 +42,7 @@ export const Page = () => { strict: false, select: (el) => el.membershipId as string }); - const { currentOrg } = useOrganization(); + const { currentOrg, isSubOrganization } = useOrganization(); const { currentProject, projectId } = useProject(); const { data: membershipDetails, isPending: isMembershipDetailsLoading } = @@ -73,7 +73,7 @@ export const Page = () => { text: "User privilege assumption has started" }); - const url = getProjectHomePage(currentProject.type, currentProject.environments); + const url = `${getProjectHomePage(currentProject.type, currentProject.environments)}${isSubOrganization ? `?subOrganization=${currentOrg.slug}` : ""}`; window.location.href = url.replace("$projectId", currentProject.id); } } diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index efd7b123b..a8e5d6fda 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -180,7 +180,11 @@ const IdentityPolicyActionSchema = z.object({ [ProjectPermissionIdentityActions.Edit]: z.boolean().optional(), [ProjectPermissionIdentityActions.Delete]: z.boolean().optional(), [ProjectPermissionIdentityActions.GrantPrivileges]: z.boolean().optional(), - [ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional() + [ProjectPermissionIdentityActions.AssumePrivileges]: z.boolean().optional(), + [ProjectPermissionIdentityActions.RevokeAuth]: z.boolean().optional(), + [ProjectPermissionIdentityActions.GetToken]: z.boolean().optional(), + [ProjectPermissionIdentityActions.CreateToken]: z.boolean().optional(), + [ProjectPermissionIdentityActions.DeleteToken]: z.boolean().optional() }); const GroupPolicyActionSchema = z.object({ @@ -577,6 +581,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { ProjectPermissionSub.IpAllowList, ProjectPermissionSub.CertificateAuthorities, ProjectPermissionSub.PkiAlerts, + ProjectPermissionSub.Identity, ProjectPermissionSub.PkiCollections, ProjectPermissionSub.Tags, ProjectPermissionSub.SecretRotation, @@ -782,6 +787,41 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } + if (subject === ProjectPermissionSub.Identity) { + const canRead = action.includes(ProjectPermissionIdentityActions.Read); + const canCreate = action.includes(ProjectPermissionIdentityActions.Create); + const canEdit = action.includes(ProjectPermissionIdentityActions.Edit); + const canDelete = action.includes(ProjectPermissionIdentityActions.Delete); + const canGrantPrivileges = action.includes( + ProjectPermissionIdentityActions.GrantPrivileges + ); + const canAssumePrivileges = action.includes( + ProjectPermissionIdentityActions.AssumePrivileges + ); + const canRevokeAuth = action.includes(ProjectPermissionIdentityActions.RevokeAuth); + const canCreateToken = action.includes(ProjectPermissionIdentityActions.CreateToken); + const canGetToken = action.includes(ProjectPermissionIdentityActions.GetToken); + const canDeleteToken = action.includes(ProjectPermissionIdentityActions.DeleteToken); + + // from above statement we are sure it won't be undefined + formVal[subject]!.push({ + [ProjectPermissionIdentityActions.Read]: canRead, + [ProjectPermissionIdentityActions.Create]: canCreate, + [ProjectPermissionIdentityActions.Edit]: canEdit, + [ProjectPermissionIdentityActions.Delete]: canDelete, + [ProjectPermissionIdentityActions.GrantPrivileges]: canGrantPrivileges, + [ProjectPermissionIdentityActions.AssumePrivileges]: canAssumePrivileges, + [ProjectPermissionIdentityActions.RevokeAuth]: canRevokeAuth, + [ProjectPermissionIdentityActions.CreateToken]: canCreateToken, + [ProjectPermissionIdentityActions.GetToken]: canGetToken, + [ProjectPermissionIdentityActions.DeleteToken]: canDeleteToken, + conditions: conditions ? convertCaslConditionToFormOperator(conditions) : [], + inverted + }); + + return; + } + // for other subjects const canRead = action.includes(ProjectPermissionActions.Read); const canEdit = action.includes(ProjectPermissionActions.Edit); @@ -953,30 +993,6 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { return; } - if (subject === ProjectPermissionSub.Identity) { - const canRead = action.includes(ProjectPermissionIdentityActions.Read); - const canCreate = action.includes(ProjectPermissionIdentityActions.Create); - const canEdit = action.includes(ProjectPermissionIdentityActions.Edit); - const canDelete = action.includes(ProjectPermissionIdentityActions.Delete); - const canGrantPrivileges = action.includes(ProjectPermissionIdentityActions.GrantPrivileges); - const canAssumePrivileges = action.includes( - ProjectPermissionIdentityActions.AssumePrivileges - ); - - if (!formVal[subject]) formVal[subject] = [{ conditions: [] }]; - - // from above statement we are sure it won't be undefined - if (canRead) formVal[subject]![0][ProjectPermissionIdentityActions.Read] = true; - if (canCreate) formVal[subject]![0][ProjectPermissionIdentityActions.Create] = true; - if (canEdit) formVal[subject]![0][ProjectPermissionIdentityActions.Edit] = true; - if (canDelete) formVal[subject]![0][ProjectPermissionIdentityActions.Delete] = true; - if (canGrantPrivileges) - formVal[subject]![0][ProjectPermissionIdentityActions.GrantPrivileges] = true; - if (canAssumePrivileges) - formVal[subject]![0][ProjectPermissionIdentityActions.AssumePrivileges] = true; - return; - } - if (subject === ProjectPermissionSub.Groups) { const canRead = action.includes(ProjectPermissionGroupActions.Read); const canCreate = action.includes(ProjectPermissionGroupActions.Create); @@ -1453,7 +1469,11 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Modify", value: ProjectPermissionIdentityActions.Edit }, { label: "Remove", value: ProjectPermissionIdentityActions.Delete }, { label: "Grant Privileges", value: ProjectPermissionIdentityActions.GrantPrivileges }, - { label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges } + { label: "Assume Privileges", value: ProjectPermissionIdentityActions.AssumePrivileges }, + { label: "Revoke Auth", value: ProjectPermissionIdentityActions.RevokeAuth }, + { label: "Create Token", value: ProjectPermissionIdentityActions.CreateToken }, + { label: "Get Token", value: ProjectPermissionIdentityActions.GetToken }, + { label: "Delete Token", value: ProjectPermissionIdentityActions.DeleteToken } ] }, [ProjectPermissionSub.Groups]: { diff --git a/frontend/src/pages/public/ViewSharedSecretByIDPage/components/SecretContainer.tsx b/frontend/src/pages/public/ViewSharedSecretByIDPage/components/SecretContainer.tsx index 1b2e785fe..3193a1bf5 100644 --- a/frontend/src/pages/public/ViewSharedSecretByIDPage/components/SecretContainer.tsx +++ b/frontend/src/pages/public/ViewSharedSecretByIDPage/components/SecretContainer.tsx @@ -13,6 +13,8 @@ import { Button, IconButton } from "@app/components/v2"; import { useTimedReset, useToggle } from "@app/hooks"; import { TViewSharedSecretResponse } from "@app/hooks/api/secretSharing"; +import { SecretShareInfo } from "./SecretShareInfo"; + type Props = { secret: TViewSharedSecretResponse["secret"]; secretKey: string | null; @@ -71,6 +73,7 @@ export const SecretContainer = ({ secret, secretKey: key }: Props) => {
+
+ } + tooltipClassName="max-w-md" isError={Boolean(errors?.value)} errorText={errors?.value?.message} > diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx index 0700b3322..da48a04f0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx @@ -11,6 +11,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({ secretPath: z.string().catch("/"), search: z.string().catch(""), tags: z.string().catch(""), + filterBy: z.string().catch(""), connectionId: z.string().optional(), connectionName: z.string().optional() }); @@ -20,7 +21,7 @@ export const Route = createFileRoute( component: SecretDashboardPage, validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema), search: { - middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "" })] + middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "", filterBy: "" })] }, beforeLoad: ({ context, params, search }) => { const secretPathSegments = search.secretPath.split("/").filter(Boolean); diff --git a/helm-charts/infisical-gateway/CHANGELOG.md b/helm-charts/infisical-gateway/CHANGELOG.md index 30ce66072..f576c6551 100644 --- a/helm-charts/infisical-gateway/CHANGELOG.md +++ b/helm-charts/infisical-gateway/CHANGELOG.md @@ -1,3 +1,6 @@ +## 1.0.3 (November 14, 2025) +* Added support for setting the image repository by setting `image.repository`. Defaults to `infisical/cli`. + ## 0.0.41 (June 10, 2025) * Added new gateway action for fully off-loading CA certificate, cluster URL, and token management to the gateway. * Structural improvements diff --git a/helm-charts/infisical-gateway/Chart.yaml b/helm-charts/infisical-gateway/Chart.yaml index 35b0a2f7d..674cbb25e 100644 --- a/helm-charts/infisical-gateway/Chart.yaml +++ b/helm-charts/infisical-gateway/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 1.0.2 +version: 1.0.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "1.0.2" +appVersion: "1.0.3" diff --git a/helm-charts/infisical-gateway/templates/deployment.yaml b/helm-charts/infisical-gateway/templates/deployment.yaml index d31a9c9e9..175a69671 100644 --- a/helm-charts/infisical-gateway/templates/deployment.yaml +++ b/helm-charts/infisical-gateway/templates/deployment.yaml @@ -35,7 +35,7 @@ spec: securityContext: {{- toYaml . | nindent 12 }} {{- end }} - image: "infisical/cli:{{ .Values.image.tag | default .Chart.AppVersion }}" + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" imagePullPolicy: {{ .Values.image.pullPolicy }} args: - gateway diff --git a/helm-charts/infisical-gateway/values.yaml b/helm-charts/infisical-gateway/values.yaml index dab76f6a0..d298c71a2 100644 --- a/helm-charts/infisical-gateway/values.yaml +++ b/helm-charts/infisical-gateway/values.yaml @@ -1,6 +1,7 @@ image: - pullPolicy: IfNotPresent + repository: infisical/cli tag: "0.43.0" + pullPolicy: IfNotPresent secret: # The secret that contains the environment variables to be used by the gateway, such as INFISICAL_API_URL and TOKEN