diff --git a/.github/values.yaml b/.github/values.yaml deleted file mode 100644 index 1b3ffd87a..000000000 --- a/.github/values.yaml +++ /dev/null @@ -1,57 +0,0 @@ -## @section Common parameters -## - -## @param nameOverride Override release name -## -nameOverride: "" -## @param fullnameOverride Override release fullname -## -fullnameOverride: "" - -## @section Infisical backend parameters -## Documentation : https://infisical.com/docs/self-hosting/deployments/kubernetes -## - -infisical: - autoDatabaseSchemaMigration: false - - enabled: false - - name: infisical - replicaCount: 3 - image: - repository: infisical/staging_infisical - tag: "latest" - pullPolicy: Always - - deploymentAnnotations: - secrets.infisical.com/auto-reload: "true" - - kubeSecretRef: "managed-secret" - -ingress: - ## @param ingress.enabled Enable ingress - ## - enabled: true - ## @param ingress.ingressClassName Ingress class name - ## - ingressClassName: nginx - ## @param ingress.nginx.enabled Ingress controller - ## - # nginx: - # enabled: true - ## @param ingress.annotations Ingress annotations - ## - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" - hostName: "gamma.infisical.com" - tls: - - secretName: letsencrypt-prod - hosts: - - gamma.infisical.com - -postgresql: - enabled: false - -redis: - enabled: false diff --git a/.github/workflows/helm-release-infisical-core.yml b/.github/workflows/helm-release-infisical-core.yml index 6c317cc27..49118a5ae 100644 --- a/.github/workflows/helm-release-infisical-core.yml +++ b/.github/workflows/helm-release-infisical-core.yml @@ -56,7 +56,7 @@ jobs: --config ct.yaml \ --charts helm-charts/infisical-standalone-postgres \ --helm-extra-args="--timeout=300s" \ - --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.autoDatabaseSchemaMigration=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.132.2-postgres" \ + --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.151.0" \ --namespace infisical-standalone-postgres release: diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index 5d9f384f7..f3265f3e9 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -135,10 +135,10 @@ jobs: TAG_NAME="${{ github.ref_name }}" echo "Checking for tag: $TAG_NAME" - EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME") + EXACT_MATCH=$(gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME 2>/dev/null | jq -r 'if type == "array" then .[].ref else .ref end' | grep -x "refs/tags/$TAG_NAME" || true) if [ "$EXACT_MATCH" == "refs/tags/$TAG_NAME" ]; then - echo "Tag $TAG_NAME already exists, skipping..." + echo "Tag $TAG_NAME already exists, skipping..." else echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" LATEST_SHA=$(gh api repos/Infisical/infisical-omnibus/git/refs/heads/main --jq '.object.sha') diff --git a/.github/workflows/release_helm_gateway.yaml b/.github/workflows/release_helm_gateway.yaml index 7fd0eb03a..cfd29a56e 100644 --- a/.github/workflows/release_helm_gateway.yaml +++ b/.github/workflows/release_helm_gateway.yaml @@ -24,6 +24,8 @@ jobs: - name: Set up chart-testing uses: helm/chart-testing-action@v2.7.0 + with: + yamale_version: "6.0.0" - name: Run chart-testing (lint) run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway diff --git a/.github/workflows/run-helm-chart-tests-infisical-gateway.yml b/.github/workflows/run-helm-chart-tests-infisical-gateway.yml index eff42506e..b382669e3 100644 --- a/.github/workflows/run-helm-chart-tests-infisical-gateway.yml +++ b/.github/workflows/run-helm-chart-tests-infisical-gateway.yml @@ -27,6 +27,8 @@ jobs: - name: Set up chart-testing uses: helm/chart-testing-action@v2.7.0 + with: + yamale_version: "6.0.0" - name: Run chart-testing (lint) run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway diff --git a/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml b/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml index d48562fc6..f960aa6ff 100644 --- a/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml +++ b/.github/workflows/run-helm-chart-tests-infisical-standalone-postgres.yml @@ -66,5 +66,5 @@ jobs: --config ct.yaml \ --charts helm-charts/infisical-standalone-postgres \ --helm-extra-args="--timeout=300s" \ - --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.autoDatabaseSchemaMigration=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.132.2-postgres --set infisical.autoBootstrap.enabled=true" \ + --helm-extra-set-args="--set ingress.nginx.enabled=false --set infisical.replicaCount=1 --set infisical.image.tag=v0.151.0 --set infisical.autoBootstrap.enabled=true" \ --namespace infisical-standalone-postgres diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index 4dbf7872b..ab1d6fbb7 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -158,7 +158,7 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.42.6 \ + && apt-get update && apt-get install -y infisical=0.43.14 \ && rm -rf /var/lib/apt/lists/* RUN groupadd -r -g 1001 nodejs && useradd -r -u 1001 -g nodejs non-root-user diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index bc80130be..01c9a737b 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -142,7 +142,7 @@ RUN apt-get update && apt-get install -y \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical=0.42.6 \ + && apt-get update && apt-get install -y infisical=0.43.14 \ && rm -rf /var/lib/apt/lists/* WORKDIR / diff --git a/backend/Dockerfile b/backend/Dockerfile index fa3d0e509..a16d2b1fe 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -55,7 +55,7 @@ COPY --from=build /app . # Install Infisical CLI RUN apt-get install -y curl bash && \ curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ - apt-get update && apt-get install -y infisical=0.41.89 git + apt-get update && apt-get install -y infisical=0.43.14 git HEALTHCHECK --interval=10s --timeout=3s --start-period=10s \ CMD node healthcheck.js diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 5e17cf2bb..5e0612128 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -49,25 +49,26 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - # ? App setup # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.89 + apt-get install -y infisical=0.43.14 WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . ENV HOST=0.0.0.0 +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index db5107985..f30b059f9 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -50,9 +50,6 @@ RUN rm -fr ${SOFTHSM2_SOURCES} # Install pkcs11-tool RUN apt-get install -y opensc -RUN mkdir -p /etc/softhsm2/tokens && \ - softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 - WORKDIR /openssl-build RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && tar -xf openssl-3.1.2.tar.gz \ @@ -70,13 +67,16 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash && \ apt-get update && \ - apt-get install -y infisical=0.41.89 + apt-get install -y infisical=0.43.14 WORKDIR /app COPY package.json package.json COPY package-lock.json package-lock.json +COPY dev-entrypoint.sh dev-entrypoint.sh +RUN chmod +x dev-entrypoint.sh + RUN npm install COPY . . @@ -87,4 +87,5 @@ ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules # ENV NODE_OPTIONS=--force-fips # Note(Daniel): We can't set this on the node options because it may break for existing folks using the infisical/infisical-fips image. Instead we call crypto.setFips(true) at runtime. ENV FIPS_ENABLED=true +ENTRYPOINT ["/app/dev-entrypoint.sh"] CMD ["npm", "run", "dev:docker"] diff --git a/backend/dev-entrypoint.sh b/backend/dev-entrypoint.sh new file mode 100755 index 000000000..9cb3c0a5e --- /dev/null +++ b/backend/dev-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +update-ca-certificates + +# Initialize SoftHSM token if it doesn't exist +if [ ! -f /etc/softhsm2/tokens/auth-app.db ]; then + echo "Initializing SoftHSM token..." + mkdir -p /etc/softhsm2/tokens + softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 + echo "SoftHSM token initialized" +else + echo "SoftHSM token already exists, skipping initialization" +fi + + +exec "$@" \ No newline at end of file diff --git a/backend/e2e-test/routes/v2/service-token.spec.ts b/backend/e2e-test/routes/v2/service-token.spec.ts index 4f72987cb..d3a8b0f67 100644 --- a/backend/e2e-test/routes/v2/service-token.spec.ts +++ b/backend/e2e-test/routes/v2/service-token.spec.ts @@ -146,7 +146,8 @@ describe("Service token secret ops", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); serviceToken = await createServiceToken( [{ secretPath: "/**", environment: seedData1.environment.slug }], diff --git a/backend/e2e-test/routes/v3/secrets.spec.ts b/backend/e2e-test/routes/v3/secrets.spec.ts index 1e58c7f4a..db5953f29 100644 --- a/backend/e2e-test/routes/v3/secrets.spec.ts +++ b/backend/e2e-test/routes/v3/secrets.spec.ts @@ -158,7 +158,7 @@ describe("Secret V3 Router", async () => { let folderId = ""; beforeAll(async () => { initLogger(); - await initEnvConfig(testSuperAdminDAL, logger); + await initEnvConfig(testHsmService, testKmsRootConfigDAL, testSuperAdminDAL, logger); const projectKeyRes = await testServer.inject({ method: "GET", diff --git a/backend/e2e-test/vitest-environment-knex.ts b/backend/e2e-test/vitest-environment-knex.ts index 085b8fe30..0f84dbee2 100644 --- a/backend/e2e-test/vitest-environment-knex.ts +++ b/backend/e2e-test/vitest-environment-knex.ts @@ -6,7 +6,7 @@ import { crypto } from "@app/lib/crypto/cryptography"; import path from "path"; import { seedData1 } from "@app/db/seed-data"; -import { getDatabaseCredentials, initEnvConfig } from "@app/lib/config/env"; +import { getDatabaseCredentials, getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger } from "@app/lib/logger"; import { main } from "@app/server/app"; import { AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; @@ -20,6 +20,8 @@ import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; import { buildRedisFromConfig } from "@app/lib/config/redis"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { bootstrapCheck } from "@app/server/boot-strap-check"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; dotenv.config({ path: path.join(__dirname, "../../.env.test"), debug: true }); export default { @@ -28,6 +30,7 @@ export default { async setup() { const logger = initLogger(); const databaseCredentials = getDatabaseCredentials(logger); + const hsmConfig = getHsmConfig(logger); const db = initDbConnection({ dbConnectionUri: databaseCredentials.dbConnectionUri, @@ -35,7 +38,19 @@ export default { }); const superAdminDAL = superAdminDALFactory(db); - const envCfg = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + const envCfg = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const redis = buildRedisFromConfig(envCfg); await redis.flushdb("SYNC"); @@ -68,16 +83,14 @@ export default { await queue.initialize(); - const hsmModule = initializeHsmModule(envCfg); - hsmModule.initialize(); - const server = await main({ db, smtp, logger, queue, keyStore, - hsmModule: hsmModule.getModule(), + hsmService, + kmsRootConfigDAL, superAdminDAL, redis, envConfig: envCfg @@ -92,6 +105,10 @@ export default { // @ts-expect-error type globalThis.testSuperAdminDAL = superAdminDAL; // @ts-expect-error type + globalThis.testKmsRootConfigDAL = kmsRootConfigDAL; + // @ts-expect-error type + globalThis.testHsmService = hsmService; + // @ts-expect-error type globalThis.jwtAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.ACCESS_TOKEN, diff --git a/backend/package-lock.json b/backend/package-lock.json index adda83043..a6cef3888 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -150,7 +150,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.17.30", + "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.14", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", @@ -182,10 +182,10 @@ "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", - "tsup": "^8.0.1", + "tsup": "^8.5.0", "tsx": "^4.4.0", "typescript": "^5.3.2", - "vitest": "^1.2.2" + "vitest": "^3.0.6" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -7451,9 +7451,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -7464,269 +7464,285 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", - "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -7741,25 +7757,26 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", - "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -7774,19 +7791,20 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openharmony-arm64": { @@ -7807,67 +7825,71 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -9280,18 +9302,6 @@ "node": ">=12" } }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -13245,9 +13255,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", - "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "cpu": [ "arm" ], @@ -13259,9 +13269,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", - "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "cpu": [ "arm64" ], @@ -13273,9 +13283,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", - "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "cpu": [ "arm64" ], @@ -13287,9 +13297,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", - "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "cpu": [ "x64" ], @@ -13300,10 +13310,38 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", - "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "cpu": [ "arm" ], @@ -13315,9 +13353,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", - "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "cpu": [ "arm" ], @@ -13329,9 +13367,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", - "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "cpu": [ "arm64" ], @@ -13343,9 +13381,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", - "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "cpu": [ "arm64" ], @@ -13356,10 +13394,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", - "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "cpu": [ "ppc64" ], @@ -13371,9 +13423,23 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", - "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "cpu": [ "riscv64" ], @@ -13385,9 +13451,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", - "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "cpu": [ "s390x" ], @@ -13399,9 +13465,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", - "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "cpu": [ "x64" ], @@ -13413,9 +13479,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", - "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "cpu": [ "x64" ], @@ -13426,10 +13492,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", - "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "cpu": [ "arm64" ], @@ -13441,9 +13521,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", - "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "cpu": [ "ia32" ], @@ -13454,10 +13534,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", - "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "cpu": [ "x64" ], @@ -13582,12 +13676,6 @@ "split2": "^4.0.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, "node_modules/@sindresorhus/slugify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.0.tgz", @@ -14944,6 +15032,17 @@ "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/command-line-args": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/command-line-args/-/command-line-args-5.2.3.tgz", @@ -14983,6 +15082,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -15006,9 +15112,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -15147,12 +15253,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.17.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.30.tgz", - "integrity": "sha512-7zf4YyHA+jvBNfVrk2Gtvs6x7E8V+YDW05bNfG2XkWDJfYRXrTiP/DsB2zSYTaHX0bGIujTBQdMVAhb+j7mwpg==", + "version": "20.19.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", + "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", "license": "MIT", "dependencies": { - "undici-types": "~6.19.2" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-fetch": { @@ -15165,6 +15271,12 @@ "form-data": "^4.0.0" } }, + "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", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@types/nodemailer": { "version": "6.4.14", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.14.tgz", @@ -15904,96 +16016,88 @@ "dev": true }, "node_modules/@vitest/expect": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.2.tgz", - "integrity": "sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/spy": "1.2.2", - "@vitest/utils": "1.2.2", - "chai": "^4.3.10" + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.2.tgz", - "integrity": "sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/utils": "1.2.2", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/runner/node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@vitest/snapshot": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.2.tgz", - "integrity": "sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, + "license": "MIT", "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.2.tgz", - "integrity": "sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, + "license": "MIT", "dependencies": { - "tinyspy": "^2.2.0" + "tinyspy": "^4.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.2.tgz", - "integrity": "sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, + "license": "MIT", "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -17030,12 +17134,13 @@ } }, "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, + "license": "MIT", "engines": { - "node": "*" + "node": ">=12" } }, "node_modules/async": { @@ -17931,10 +18036,11 @@ } }, "node_modules/bundle-require": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-4.0.2.tgz", - "integrity": "sha512-jwzPOChofl67PSTW2SGubV9HBQAhhR2i6nskiOThauo9dzwDUgOWQScFVaJkjEfYX+UXiD+LEx8EblQMc2wIag==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", "dev": true, + "license": "MIT", "dependencies": { "load-tsconfig": "^0.2.3" }, @@ -17942,7 +18048,7 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "peerDependencies": { - "esbuild": ">=0.17" + "esbuild": ">=0.18" } }, "node_modules/bytes": { @@ -18189,21 +18295,20 @@ } }, "node_modules/chai": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", - "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, + "license": "MIT", "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.0.8" + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" }, "engines": { - "node": ">=4" + "node": ">=18" } }, "node_modules/chalk": { @@ -18270,15 +18375,13 @@ } }, "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.2" - }, + "license": "MIT", "engines": { - "node": "*" + "node": ">= 16" } }, "node_modules/chokidar": { @@ -19056,13 +19159,11 @@ } }, "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, - "dependencies": { - "type-detect": "^4.0.0" - }, + "license": "MIT", "engines": { "node": ">=6" } @@ -19297,15 +19398,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -19837,40 +19929,62 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/escalade": { @@ -20405,6 +20519,7 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, + "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -20478,6 +20593,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", @@ -20867,6 +20992,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fecha": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", @@ -21004,6 +21147,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, "node_modules/flat-cache": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", @@ -21534,15 +21689,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -21596,18 +21742,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", @@ -21625,10 +21759,11 @@ } }, "node_modules/get-tsconfig": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.2.tgz", - "integrity": "sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, + "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" }, @@ -22343,15 +22478,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -22902,18 +23028,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-string": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", @@ -23392,12 +23506,6 @@ "node": ">=6" } }, - "node_modules/jsonc-parser": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz", - "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", - "dev": true - }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -23809,12 +23917,16 @@ "license": "MIT" }, "node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/limiter": { @@ -23856,6 +23968,7 @@ "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", "dev": true, + "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } @@ -23870,22 +23983,6 @@ "node": ">=6.11.5" } }, - "node_modules/local-pkg": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", - "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", - "dev": true, - "dependencies": { - "mlly": "^1.4.2", - "pkg-types": "^1.0.3" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/localforage": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", @@ -24035,13 +24132,11 @@ "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==" }, "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } + "license": "MIT" }, "node_modules/lru-cache": { "version": "6.0.0", @@ -24072,15 +24167,13 @@ } }, "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/make-dir": { @@ -24513,15 +24606,16 @@ "dev": true }, "node_modules/mlly": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.4.2.tgz", - "integrity": "sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "dev": true, + "license": "MIT", "dependencies": { - "acorn": "^8.10.0", - "pathe": "^1.1.1", - "pkg-types": "^1.0.3", - "ufo": "^1.3.0" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mnemonist": { @@ -24828,9 +24922,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -25279,33 +25373,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.1.0.tgz", - "integrity": "sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==", - "dev": true, - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -25355,13 +25422,6 @@ "node": "^14.16.0 || >=16.10.0" } }, - "node_modules/nypm/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, "node_modules/nypm/node_modules/pkg-types": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", @@ -27958,18 +28018,20 @@ } }, "node_modules/pathe": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", - "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", - "dev": true + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" }, "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, + "license": "MIT", "engines": { - "node": "*" + "node": ">= 14.16" } }, "node_modules/pause": { @@ -28382,16 +28444,24 @@ } }, "node_modules/pkg-types": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz", - "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "dev": true, + "license": "MIT", "dependencies": { - "jsonc-parser": "^3.2.0", - "mlly": "^1.2.0", - "pathe": "^1.1.0" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, "node_modules/pkijs": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.2.4.tgz", @@ -28430,9 +28500,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -28450,8 +28520,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -28725,32 +28795,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/prism-react-renderer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", @@ -29339,397 +29383,6 @@ "node": ">=18.0.0" } }, - "node_modules/react-email/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", - "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", - "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", - "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", - "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", - "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/darwin-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", - "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", - "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", - "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-arm": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", - "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", - "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", - "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-loong64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", - "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", - "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", - "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", - "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-s390x": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", - "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", - "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", - "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", - "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/sunos-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", - "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-arm64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", - "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-ia32": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", - "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-x64": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", - "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/react-email/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -29772,47 +29425,6 @@ "node": ">=18" } }, - "node_modules/react-email/node_modules/esbuild": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", - "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.0", - "@esbuild/android-arm": "0.25.0", - "@esbuild/android-arm64": "0.25.0", - "@esbuild/android-x64": "0.25.0", - "@esbuild/darwin-arm64": "0.25.0", - "@esbuild/darwin-x64": "0.25.0", - "@esbuild/freebsd-arm64": "0.25.0", - "@esbuild/freebsd-x64": "0.25.0", - "@esbuild/linux-arm": "0.25.0", - "@esbuild/linux-arm64": "0.25.0", - "@esbuild/linux-ia32": "0.25.0", - "@esbuild/linux-loong64": "0.25.0", - "@esbuild/linux-mips64el": "0.25.0", - "@esbuild/linux-ppc64": "0.25.0", - "@esbuild/linux-riscv64": "0.25.0", - "@esbuild/linux-s390x": "0.25.0", - "@esbuild/linux-x64": "0.25.0", - "@esbuild/netbsd-arm64": "0.25.0", - "@esbuild/netbsd-x64": "0.25.0", - "@esbuild/openbsd-arm64": "0.25.0", - "@esbuild/openbsd-x64": "0.25.0", - "@esbuild/sunos-x64": "0.25.0", - "@esbuild/win32-arm64": "0.25.0", - "@esbuild/win32-ia32": "0.25.0", - "@esbuild/win32-x64": "0.25.0" - } - }, "node_modules/react-email/node_modules/glob": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", @@ -30104,12 +29716,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -30609,13 +30215,13 @@ } }, "node_modules/rollup": { - "version": "4.24.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", - "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -30625,22 +30231,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.24.0", - "@rollup/rollup-android-arm64": "4.24.0", - "@rollup/rollup-darwin-arm64": "4.24.0", - "@rollup/rollup-darwin-x64": "4.24.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", - "@rollup/rollup-linux-arm-musleabihf": "4.24.0", - "@rollup/rollup-linux-arm64-gnu": "4.24.0", - "@rollup/rollup-linux-arm64-musl": "4.24.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", - "@rollup/rollup-linux-riscv64-gnu": "4.24.0", - "@rollup/rollup-linux-s390x-gnu": "4.24.0", - "@rollup/rollup-linux-x64-gnu": "4.24.0", - "@rollup/rollup-linux-x64-musl": "4.24.0", - "@rollup/rollup-win32-arm64-msvc": "4.24.0", - "@rollup/rollup-win32-ia32-msvc": "4.24.0", - "@rollup/rollup-win32-x64-msvc": "4.24.0", + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, @@ -31145,7 +30757,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/signal-exit": { "version": "3.0.7", @@ -31829,7 +31442,8 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/stacktrace-parser": { "version": "0.1.11", @@ -31868,10 +31482,11 @@ } }, "node_modules/std-env": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.6.0.tgz", - "integrity": "sha512-aFZ19IgVmhdB2uX599ve2kE6BIE3YMnQ6Gp6BURhW/oIzpXGKr878TQfAQZn1+i0Flcc/UKUy1gOlcfaUBCryg==", - "dev": true + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" }, "node_modules/stdin-discarder": { "version": "0.1.0", @@ -32104,18 +31719,6 @@ "node": ">=4" } }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -32128,17 +31731,25 @@ } }, "node_modules/strip-literal": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", - "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, + "license": "MIT", "dependencies": { - "acorn": "^8.10.0" + "js-tokens": "^9.0.1" }, "funding": { "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/strnum": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", @@ -32175,14 +31786,15 @@ } }, "node_modules/sucrase": { - "version": "3.34.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", - "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", - "glob": "7.1.6", + "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", @@ -32193,7 +31805,17 @@ "sucrase-node": "bin/sucrase-node" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, "node_modules/sucrase/node_modules/commander": { @@ -32206,25 +31828,68 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "7.1.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", - "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.0.4", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sucrase/node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -32708,10 +32373,11 @@ } }, "node_modules/tinybench": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.1.tgz", - "integrity": "sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==", - "dev": true + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", @@ -32720,20 +32386,62 @@ "dev": true, "license": "MIT" }, - "node_modules/tinypool": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.2.tgz", - "integrity": "sha512-SUszKYe5wgsxnNOVlBYO6IC+8VGWdVGZWAqUxp3UErNBtptZvWbwyUOyzNL59zigz2rCA92QiL3wvG+JDSdJdQ==", + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -32995,24 +32703,28 @@ "license": "0BSD" }, "node_modules/tsup": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.0.1.tgz", - "integrity": "sha512-hvW7gUSG96j53ZTSlT4j/KL0q1Q2l6TqGBFc6/mu/L46IoNWqLLUzLRLP1R8Q7xrJTmkDxxDoojV5uCVs1sVOg==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", + "integrity": "sha512-VmBp77lWNQq6PfuMqCHD3xWl22vEoWsKajkF8t+yMBawlUS8JzEI+vOVMeuNZIuMML8qXRizFKi9oD5glKQVcQ==", "dev": true, + "license": "MIT", "dependencies": { - "bundle-require": "^4.0.0", - "cac": "^6.7.12", - "chokidar": "^3.5.1", - "debug": "^4.3.1", - "esbuild": "^0.19.2", - "execa": "^5.0.0", - "globby": "^11.0.3", - "joycon": "^3.0.1", - "postcss-load-config": "^4.0.1", + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.25.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", - "rollup": "^4.0.2", + "rollup": "^4.34.8", "source-map": "0.8.0-beta.0", - "sucrase": "^3.20.3", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "bin": { @@ -33043,365 +32755,30 @@ } } }, - "node_modules/tsup/node_modules/@esbuild/android-arm": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.9.tgz", - "integrity": "sha512-jkYjjq7SdsWuNI6b5quymW0oC83NN5FdRPuCbs9HZ02mfVdAP8B8eeqLSYU3gb6OJEaY5CQabtTFbqBf26H3GA==", - "cpu": [ - "arm" - ], + "node_modules/tsup/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/android-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.9.tgz", - "integrity": "sha512-q4cR+6ZD0938R19MyEW3jEsMzbb/1rulLXiNAJQADD/XYp7pT+rOS5JGxvpRW8dFDEfjW4wLgC/3FXIw4zYglQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/android-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.9.tgz", - "integrity": "sha512-KOqoPntWAH6ZxDwx1D6mRntIgZh9KodzgNOy5Ebt9ghzffOk9X2c1sPwtM9P+0eXbefnDhqYfkh5PLP5ULtWFA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.9.tgz", - "integrity": "sha512-KBJ9S0AFyLVx2E5D8W0vExqRW01WqRtczUZ8NRu+Pi+87opZn5tL4Y0xT0mA4FtHctd0ZgwNoN639fUUGlNIWw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/darwin-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.9.tgz", - "integrity": "sha512-vE0VotmNTQaTdX0Q9dOHmMTao6ObjyPm58CHZr1UK7qpNleQyxlFlNCaHsHx6Uqv86VgPmR4o2wdNq3dP1qyDQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.9.tgz", - "integrity": "sha512-uFQyd/o1IjiEk3rUHSwUKkqZwqdvuD8GevWF065eqgYfexcVkxh+IJgwTaGZVu59XczZGcN/YMh9uF1fWD8j1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.9.tgz", - "integrity": "sha512-WMLgWAtkdTbTu1AWacY7uoj/YtHthgqrqhf1OaEWnZb7PQgpt8eaA/F3LkV0E6K/Lc0cUr/uaVP/49iE4M4asA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-arm": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.9.tgz", - "integrity": "sha512-C/ChPohUYoyUaqn1h17m/6yt6OB14hbXvT8EgM1ZWaiiTYz7nWZR0SYmMnB5BzQA4GXl3BgBO1l8MYqL/He3qw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.9.tgz", - "integrity": "sha512-PiPblfe1BjK7WDAKR1Cr9O7VVPqVNpwFcPWgfn4xu0eMemzRp442hXyzF/fSwgrufI66FpHOEJk0yYdPInsmyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-ia32": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.9.tgz", - "integrity": "sha512-f37i/0zE0MjDxijkPSQw1CO/7C27Eojqb+r3BbHVxMLkj8GCa78TrBZzvPyA/FNLUMzP3eyHCVkAopkKVja+6Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-loong64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.9.tgz", - "integrity": "sha512-t6mN147pUIf3t6wUt3FeumoOTPfmv9Cc6DQlsVBpB7eCpLOqQDyWBP1ymXn1lDw4fNUSb/gBcKAmvTP49oIkaA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.9.tgz", - "integrity": "sha512-jg9fujJTNTQBuDXdmAg1eeJUL4Jds7BklOTkkH80ZgQIoCTdQrDaHYgbFZyeTq8zbY+axgptncko3v9p5hLZtw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.9.tgz", - "integrity": "sha512-tkV0xUX0pUUgY4ha7z5BbDS85uI7ABw3V1d0RNTii7E9lbmV8Z37Pup2tsLV46SQWzjOeyDi1Q7Wx2+QM8WaCQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.9.tgz", - "integrity": "sha512-DfLp8dj91cufgPZDXr9p3FoR++m3ZJ6uIXsXrIvJdOjXVREtXuQCjfMfvmc3LScAVmLjcfloyVtpn43D56JFHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-s390x": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.9.tgz", - "integrity": "sha512-zHbglfEdC88KMgCWpOl/zc6dDYJvWGLiUtmPRsr1OgCViu3z5GncvNVdf+6/56O2Ca8jUU+t1BW261V6kp8qdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/linux-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.9.tgz", - "integrity": "sha512-JUjpystGFFmNrEHQnIVG8hKwvA2DN5o7RqiO1CVX8EN/F/gkCjkUMgVn6hzScpwnJtl2mPR6I9XV1oW8k9O+0A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.9.tgz", - "integrity": "sha512-GThgZPAwOBOsheA2RUlW5UeroRfESwMq/guy8uEe3wJlAOjpOXuSevLRd70NZ37ZrpO6RHGHgEHvPg1h3S1Jug==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.9.tgz", - "integrity": "sha512-Ki6PlzppaFVbLnD8PtlVQfsYw4S9n3eQl87cqgeIw+O3sRr9IghpfSKY62mggdt1yCSZ8QWvTZ9jo9fjDSg9uw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/sunos-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.9.tgz", - "integrity": "sha512-MLHj7k9hWh4y1ddkBpvRj2b9NCBhfgBt3VpWbHQnXRedVun/hC7sIyTGDGTfsGuXo4ebik2+3ShjcPbhtFwWDw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/win32-arm64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.9.tgz", - "integrity": "sha512-GQoa6OrQ8G08guMFgeXPH7yE/8Dt0IfOGWJSfSH4uafwdC7rWwrfE6P9N8AtPGIjUzdo2+7bN8Xo3qC578olhg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/win32-ia32": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.9.tgz", - "integrity": "sha512-UOozV7Ntykvr5tSOlGCrqU3NBr3d8JqPes0QWN2WOXfvkWVGRajC+Ym0/Wj88fUgecUCLDdJPDF0Nna2UK3Qtg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/tsup/node_modules/@esbuild/win32-x64": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.9.tgz", - "integrity": "sha512-oxoQgglOP7RH6iasDrhY+R/3cHrfwIDvRlT4CGChflq6twk8iENeVvMJjmvBb94Ik1Z+93iGO27err7w6l54GQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/tsup/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -33412,94 +32789,61 @@ } } }, - "node_modules/tsup/node_modules/esbuild": { - "version": "0.19.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.9.tgz", - "integrity": "sha512-U9CHtKSy+EpPsEBa+/A2gMs/h3ylBC0H0KSqIg7tpztHerLi6nrrcoUJAkNCEPumx8yJ+Byic4BVwHgRbN0TBg==", + "node_modules/tsup/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" }, "engines": { - "node": ">=12" + "node": ">= 18" }, - "optionalDependencies": { - "@esbuild/android-arm": "0.19.9", - "@esbuild/android-arm64": "0.19.9", - "@esbuild/android-x64": "0.19.9", - "@esbuild/darwin-arm64": "0.19.9", - "@esbuild/darwin-x64": "0.19.9", - "@esbuild/freebsd-arm64": "0.19.9", - "@esbuild/freebsd-x64": "0.19.9", - "@esbuild/linux-arm": "0.19.9", - "@esbuild/linux-arm64": "0.19.9", - "@esbuild/linux-ia32": "0.19.9", - "@esbuild/linux-loong64": "0.19.9", - "@esbuild/linux-mips64el": "0.19.9", - "@esbuild/linux-ppc64": "0.19.9", - "@esbuild/linux-riscv64": "0.19.9", - "@esbuild/linux-s390x": "0.19.9", - "@esbuild/linux-x64": "0.19.9", - "@esbuild/netbsd-x64": "0.19.9", - "@esbuild/openbsd-x64": "0.19.9", - "@esbuild/sunos-x64": "0.19.9", - "@esbuild/win32-arm64": "0.19.9", - "@esbuild/win32-ia32": "0.19.9", - "@esbuild/win32-x64": "0.19.9" + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/tsup/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/tsup/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 14.18.0" }, "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/tsup/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tsup/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/tsup/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/tsup/node_modules/resolve-from": { @@ -33523,15 +32867,6 @@ "node": ">= 8" } }, - "node_modules/tsup/node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/tsup/node_modules/tr46": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", @@ -33559,13 +32894,14 @@ } }, "node_modules/tsx": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.4.0.tgz", - "integrity": "sha512-4fwcEjRUxW20ciSaMB8zkpGwCPxuRGnadDuj/pBk5S9uT29zvWz15PK36GrKJo45mSJomDxVejZ73c6lr3811Q==", + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.18.20", - "get-tsconfig": "^4.7.2" + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" @@ -33646,15 +32982,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -33767,10 +33094,11 @@ } }, "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", - "dev": true + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "dev": true, + "license": "MIT" }, "node_modules/uglify-js": { "version": "3.17.4", @@ -34155,22 +33483,100 @@ "node": ">=0.6.0" } }, - "node_modules/vite": { - "version": "5.4.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.9.tgz", - "integrity": "sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==", + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "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", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "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", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -34179,19 +33585,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -34212,509 +33624,61 @@ }, "terser": { "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.2.tgz", - "integrity": "sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==", - "dev": true, - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/vite-node/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, "node_modules/vitest": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.2.tgz", - "integrity": "sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, + "license": "MIT", "dependencies": { - "@vitest/expect": "1.2.2", - "@vitest/runner": "1.2.2", - "@vitest/snapshot": "1.2.2", - "@vitest/spy": "1.2.2", - "@vitest/utils": "1.2.2", - "acorn-walk": "^8.3.2", - "cac": "^6.7.14", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^1.3.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.2", - "vite": "^5.0.0", - "vite-node": "1.2.2", - "why-is-node-running": "^2.2.2" + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "^1.0.0", - "@vitest/ui": "^1.0.0", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -34722,6 +33686,9 @@ "@edge-runtime/vm": { "optional": true }, + "@types/debug": { + "optional": true + }, "@types/node": { "optional": true }, @@ -34739,13 +33706,41 @@ } } }, - "node_modules/vitest/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -34756,93 +33751,92 @@ } } }, - "node_modules/vitest/node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/vitest/node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/vitest/node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vitest/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/vitest/node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/vitest/node_modules/vite": { + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, + "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/vitest/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" + "url": "https://github.com/vitejs/vite?sponsor=1" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, "node_modules/w3c-xmlserializer": { @@ -34944,13 +33938,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -35061,10 +34048,11 @@ } }, "node_modules/why-is-node-running": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", - "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "license": "MIT", "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -35455,11 +34443,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 14" + "node": ">= 14.6" } }, "node_modules/yargs": { diff --git a/backend/package.json b/backend/package.json index 17be524f2..9bcd63cf1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -40,10 +40,10 @@ "type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit", "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", - "test:unit": "vitest run -c vitest.unit.config.ts", - "test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", - "test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", - "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts", + "test:unit": "vitest run -c vitest.unit.config.mts", + "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", "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", @@ -98,7 +98,7 @@ "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", "@types/lodash.isequal": "^4.5.8", - "@types/node": "^20.17.30", + "@types/node": "^20.19.0", "@types/nodemailer": "^6.4.14", "@types/passport-google-oauth20": "^2.0.14", "@types/pg": "^8.10.9", @@ -130,10 +130,10 @@ "ts-node": "^10.9.2", "tsc-alias": "^1.8.8", "tsconfig-paths": "^4.2.0", - "tsup": "^8.0.1", + "tsup": "^8.5.0", "tsx": "^4.4.0", "typescript": "^5.3.2", - "vitest": "^1.2.2" + "vitest": "^3.0.6" }, "dependencies": { "@aws-sdk/client-elasticache": "^3.637.0", diff --git a/backend/src/@types/fastify-zod.d.ts b/backend/src/@types/fastify-zod.d.ts index f0240d1a0..91cd00605 100644 --- a/backend/src/@types/fastify-zod.d.ts +++ b/backend/src/@types/fastify-zod.d.ts @@ -1,7 +1,9 @@ import { FastifyInstance, RawReplyDefaultExpression, RawRequestDefaultExpression, RawServerDefault } from "fastify"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { CustomLogger } from "@app/lib/logger/logger"; import { ZodTypeProvider } from "@app/server/plugins/fastify-zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; declare global { @@ -16,5 +18,7 @@ declare global { // used only for testing const testServer: FastifyZodProvider; const testSuperAdminDAL: TSuperAdminDALFactory; + const testKmsRootConfigDAL: TKmsRootConfigDALFactory; + const testHsmService: THsmServiceFactory; const jwtAuthToken: string; } diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 273e6d982..a2bc332f8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -135,9 +135,23 @@ import { TWorkflowIntegrationServiceFactory } from "@app/services/workflow-integ declare module "@fastify/request-context" { interface RequestContextData { reqId: string; + ip?: string; + userAgent?: string; orgId?: string; + orgName?: string; + userAuthInfo?: { + userId: string; + email: string; + }; + projectDetails?: { + id: string; + name: string; + slug: string; + }; identityAuthInfo?: { identityId: string; + identityName: string; + authMethod: string; oidc?: { claims: Record; }; diff --git a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts index 09a346abb..2fbf68128 100644 --- a/backend/src/db/migrations/20250210101840_webhook-to-kms.ts +++ b/backend/src/db/migrations/20250210101840_webhook-to-kms.ts @@ -3,13 +3,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,10 +26,12 @@ export async function up(knex: Knex): Promise { if (hasUrl) t.string("url").nullable().alter(); }); } - initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts index 94e30a7b8..179cb9bd6 100644 --- a/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_dynamic-secret-root-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -30,8 +31,12 @@ export async function up(knex: Knex): Promise { } initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts index bbda48dac..aef429ab9 100644 --- a/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts +++ b/backend/src/db/migrations/20250210101841_secret-rotation-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -24,8 +25,11 @@ export async function up(knex: Knex): Promise { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts index a24bfdf0c..f3fa63028 100644 --- a/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-k8-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityK8sAuth = async (knex: Knex) => { @@ -55,9 +56,11 @@ const reencryptIdentityK8sAuth = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = diff --git a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts index 25db615fa..f970043f0 100644 --- a/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts +++ b/backend/src/db/migrations/20250210101842_identity-oidc-auth-to-kms.ts @@ -4,13 +4,14 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName, TOrgBots } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; const reencryptIdentityOidcAuth = async (knex: Knex) => { @@ -35,8 +36,11 @@ const reencryptIdentityOidcAuth = async (knex: Knex) => { } initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts index 783693da6..62b4e8556 100644 --- a/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts +++ b/backend/src/db/migrations/20250210101845_directory-config-to-kms.ts @@ -4,16 +4,18 @@ import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; -const reencryptSamlConfig = async (knex: Knex) => { +const reencryptSamlConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedEntrypointColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlEntryPoint"); const hasEncryptedIssuerColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlIssuer"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.SamlConfig, "encryptedSamlCertificate"); @@ -28,10 +30,6 @@ const reencryptSamlConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -159,7 +157,7 @@ const reencryptSamlConfig = async (knex: Knex) => { } }; -const reencryptLdapConfig = async (knex: Knex) => { +const reencryptLdapConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedLdapBindDNColum = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindDN"); const hasEncryptedLdapBindPassColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapBindPass"); const hasEncryptedCertificateColumn = await knex.schema.hasColumn(TableName.LdapConfig, "encryptedLdapCaCertificate"); @@ -194,10 +192,6 @@ const reencryptLdapConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -323,7 +317,7 @@ const reencryptLdapConfig = async (knex: Knex) => { } }; -const reencryptOidcConfig = async (knex: Knex) => { +const reencryptOidcConfig = async (knex: Knex, kmsService: TKmsServiceFactory) => { const hasEncryptedOidcClientIdColumn = await knex.schema.hasColumn(TableName.OidcConfig, "encryptedOidcClientId"); const hasEncryptedOidcClientSecretColumn = await knex.schema.hasColumn( TableName.OidcConfig, @@ -354,10 +348,6 @@ const reencryptOidcConfig = async (knex: Knex) => { } initLogger(); - const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); - const keyStore = inMemoryKeyStore(); - const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); const orgEncryptionRingBuffer = createCircularCache>>(25); @@ -462,9 +452,18 @@ const reencryptOidcConfig = async (knex: Knex) => { }; export async function up(knex: Knex): Promise { - await reencryptSamlConfig(knex); - await reencryptLdapConfig(knex); - await reencryptOidcConfig(knex); + initLogger(); + + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); + const keyStore = inMemoryKeyStore(); + const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); + + await reencryptSamlConfig(knex, kmsService); + await reencryptLdapConfig(knex, kmsService); + await reencryptOidcConfig(knex, kmsService); } const dropSamlConfigColumns = async (knex: Knex) => { diff --git a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts index a0985471f..dd9ff2d6a 100644 --- a/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts +++ b/backend/src/db/migrations/20250513081738_remove-gateway-project-link.ts @@ -3,12 +3,13 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; import { initLogger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; // Note(daniel): We aren't dropping tables or columns in this migrations so we can easily rollback if needed. // In the future we need to drop the projectGatewayId on the dynamic secrets table, and drop the project_gateways table entirely. @@ -40,8 +41,10 @@ export async function up(knex: Knex): Promise { ); initLogger(); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts index 548d6207a..f2bc0a96a 100644 --- a/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts +++ b/backend/src/db/migrations/20250711005900_github-app-connection-to-environments.ts @@ -2,19 +2,23 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { selectAllTableCols } from "@app/lib/knex"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; export async function up(knex: Knex) { const existingSuperAdminsWithGithubConnection = await knex(TableName.SuperAdmin) .select(selectAllTableCols(TableName.SuperAdmin)) .whereNotNull(`${TableName.SuperAdmin}.encryptedGitHubAppConnectionClientId`); + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts index a70dcb8b9..82fa4a039 100644 --- a/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts +++ b/backend/src/db/migrations/20250903191434_audit-log-stream-v2.ts @@ -2,13 +2,14 @@ import { Knex } from "knex"; import { inMemoryKeyStore } from "@app/keystore/memory"; import { crypto } from "@app/lib/crypto/cryptography"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { SecretKeyEncoding, TableName } from "../schemas"; -import { getMigrationEnvConfig } from "./utils/env-config"; +import { getMigrationEnvConfig, getMigrationHsmConfig } from "./utils/env-config"; import { createCircularCache } from "./utils/ring-buffer"; -import { getMigrationEncryptionServices } from "./utils/services"; +import { getMigrationEncryptionServices, getMigrationHsmService } from "./utils/services"; const BATCH_SIZE = 500; export async function up(knex: Knex): Promise { @@ -25,8 +26,10 @@ export async function up(knex: Knex): Promise { }); if (!hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); @@ -131,8 +134,11 @@ export async function down(knex: Knex): Promise { const hasEncryptedCredentials = await knex.schema.hasColumn(TableName.AuditLogStream, "encryptedCredentials"); if (hasEncryptedCredentials) { + const { hsmService } = await getMigrationHsmService({ envConfig: getMigrationHsmConfig() }); + const superAdminDAL = superAdminDALFactory(knex); - const envConfig = await getMigrationEnvConfig(superAdminDAL); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const envConfig = await getMigrationEnvConfig(superAdminDAL, hsmService, kmsRootConfigDAL); const keyStore = inMemoryKeyStore(); const { kmsService } = await getMigrationEncryptionServices({ envConfig, keyStore, db: knex }); diff --git a/backend/src/db/migrations/20251015042917_pam-account-rotation.ts b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts new file mode 100644 index 000000000..b83dae0ae --- /dev/null +++ b/backend/src/db/migrations/20251015042917_pam-account-rotation.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.boolean("rotationEnabled").notNullable().defaultTo(false); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.integer("rotationIntervalSeconds").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.timestamp("lastRotatedAt").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials"))) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.binary("encryptedRotationAccountCredentials").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamResource, "encryptedRotationAccountCredentials")) { + await knex.schema.alterTable(TableName.PamResource, (t) => { + t.dropColumn("encryptedRotationAccountCredentials"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationEnabled")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationEnabled"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationIntervalSeconds")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationIntervalSeconds"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "lastRotatedAt")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("lastRotatedAt"); + }); + } +} diff --git a/backend/src/db/migrations/20251018061215_sub-org.ts b/backend/src/db/migrations/20251018061215_sub-org.ts index 1c1d6df74..089aeef90 100644 --- a/backend/src/db/migrations/20251018061215_sub-org.ts +++ b/backend/src/db/migrations/20251018061215_sub-org.ts @@ -2,7 +2,7 @@ import { Knex } from "knex"; import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; -import { AccessScope, TableName } from "../schemas"; +import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { const hasParentOrgId = await knex.schema.hasColumn(TableName.Organization, "parentOrgId"); @@ -18,8 +18,6 @@ export async function up(knex: Knex): Promise { await dropConstraintIfExists(TableName.Organization, "organizations_slug_unique", knex); t.unique(["rootOrgId", "parentOrgId", "slug"]); }); - - // had to switch to raw for null not distinct } const hasIdentityOrgCol = await knex.schema.hasColumn(TableName.Identity, "orgId"); @@ -28,22 +26,6 @@ export async function up(knex: Knex): Promise { t.uuid("orgId"); t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); }); - - await knex.raw( - ` - UPDATE ?? AS identity - SET "orgId" = membership."scopeOrgId" - FROM ?? AS membership - WHERE - membership."actorIdentityId" = identity."id" - AND membership."scope" = ? -`, - [TableName.Identity, TableName.Membership, AccessScope.Organization] - ); - - await knex.schema.alterTable(TableName.Identity, (t) => { - t.uuid("orgId").notNullable().alter(); - }); } } diff --git a/backend/src/db/migrations/20251019061215_sub-org-identity-backfill.ts b/backend/src/db/migrations/20251019061215_sub-org-identity-backfill.ts new file mode 100644 index 000000000..9054a03ee --- /dev/null +++ b/backend/src/db/migrations/20251019061215_sub-org-identity-backfill.ts @@ -0,0 +1,48 @@ +import { Knex } from "knex"; + +import { chunkArray } from "@app/lib/fn"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.transaction(async (tx) => { + const hasIdentityOrgCol = await tx.schema.hasColumn(TableName.Identity, "orgId"); + if (hasIdentityOrgCol) { + const identityMemberships = await tx(TableName.Membership) + .where({ + scope: AccessScope.Organization + }) + .whereNotNull("actorIdentityId") + .select("actorIdentityId", "scopeOrgId"); + + const identityToOrgMapping: Record = {}; + identityMemberships.forEach((el) => { + if (el.actorIdentityId) { + identityToOrgMapping[el.actorIdentityId] = el.scopeOrgId; + } + }); + + const batchMemberships = chunkArray(identityMemberships, 500); + for await (const membership of batchMemberships) { + const identityIds = membership.map((el) => el.actorIdentityId).filter(Boolean) as string[]; + if (identityIds.length) { + const identities = await tx(TableName.Identity).whereIn("id", identityIds).select("*"); + await tx(TableName.Identity) + .insert( + identities.map((el) => ({ + ...el, + orgId: identityToOrgMapping[el.id] + })) + ) + .onConflict("id") + .merge(); + } + } + } + }); +} + +export async function down(): Promise {} + +const config = { transaction: false }; +export { config }; diff --git a/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts new file mode 100644 index 000000000..583ffec90 --- /dev/null +++ b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts @@ -0,0 +1,51 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "autoRenewDays")) { + await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.renameColumn("autoRenewDays", "renewBeforeDays"); + }); + } + + if (!(await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays"))) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.integer("renewBeforeDays").nullable(); + t.uuid("renewedFromCertificateId").nullable(); + t.uuid("renewedByCertificateId").nullable(); + t.text("renewalError").nullable(); + t.string("keyAlgorithm").nullable(); + t.string("signatureAlgorithm").nullable(); + t.foreign("renewedFromCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.foreign("renewedByCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.index("renewedFromCertificateId"); + t.index("renewedByCertificateId"); + t.index("renewBeforeDays"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign(["renewedFromCertificateId"]); + t.dropForeign(["renewedByCertificateId"]); + t.dropIndex("renewedFromCertificateId"); + t.dropIndex("renewedByCertificateId"); + t.dropIndex("renewBeforeDays"); + t.dropColumn("renewBeforeDays"); + t.dropColumn("renewedFromCertificateId"); + t.dropColumn("renewedByCertificateId"); + t.dropColumn("renewalError"); + t.dropColumn("keyAlgorithm"); + t.dropColumn("signatureAlgorithm"); + }); + } + + if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "renewBeforeDays")) { + await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.renameColumn("renewBeforeDays", "autoRenewDays"); + }); + } +} diff --git a/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts b/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts new file mode 100644 index 000000000..88a38c6d6 --- /dev/null +++ b/backend/src/db/migrations/20251021124744_fix-project-deletion-approval-policy-constraint.ts @@ -0,0 +1,68 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +// Fix for 20250722152841_add-policies-environments-table.ts migration. +// 20250722152841_add-policies-environments-table.ts introduced a bug where you can no longer delete a project if it has any approval policy environments. + +export async function up(knex: Knex): Promise { + // Fix SecretApprovalPolicyEnvironment to cascade delete when environment is deleted + // note: this won't actually happen, as we prevent deletion of environments with active approval policies + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.SecretApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix AccessApprovalPolicyEnvironment to cascade delete when environment is deleted + // note: this won't actually happen, as we prevent deletion of environments with active approval policies + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.AccessApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix SecretApprovalPolicy to CASCADE instead of SET NULL + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + // Fix AccessApprovalPolicy to CASCADE instead of SET NULL + + // in the old migration it was ON DELETE SET NULL, which doesn't work because envId is not a nullable col + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); +} + +export async function down(knex: Knex): Promise { + // Revert SecretApprovalPolicyEnvironment + await knex.schema.alterTable(TableName.SecretApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment); + }); + + // Revert AccessApprovalPolicyEnvironment + await knex.schema.alterTable(TableName.AccessApprovalPolicyEnvironment, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment); + }); + + // Revert SecretApprovalPolicy back to SET NULL + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); + + // Revert AccessApprovalPolicy back to SET NULL + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); +} diff --git a/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts new file mode 100644 index 000000000..56fe82b2c --- /dev/null +++ b/backend/src/db/migrations/20251023121055_fix-missing-group-memberships.ts @@ -0,0 +1,60 @@ +import { Knex } from "knex"; + +import { AccessScope, TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasGroupsTable = await knex.schema.hasTable(TableName.Groups); + const hasMembershipTable = await knex.schema.hasTable(TableName.Membership); + const hasMembershipRoleTable = await knex.schema.hasTable(TableName.MembershipRole); + + if (!hasGroupsTable || !hasMembershipTable || !hasMembershipRoleTable) { + return; + } + + const groupsWithoutMembership = await knex + .select( + `${TableName.Groups}.id`, + `${TableName.Groups}.orgId`, + `${TableName.Groups}.role`, + `${TableName.Groups}.roleId` + ) + .from(TableName.Groups) + .leftJoin(TableName.Membership, `${TableName.Groups}.id`, `${TableName.Membership}.actorGroupId`) + .whereNull(`${TableName.Membership}.actorGroupId`); + + if (groupsWithoutMembership.length > 0) { + const membershipInserts = groupsWithoutMembership.map((group) => ({ + actorGroupId: group.id, + scope: AccessScope.Organization, + scopeOrgId: group.orgId, + isActive: true + })); + + const insertedMemberships = await knex(TableName.Membership).insert(membershipInserts).returning("*"); + + const membershipRoleInserts = insertedMemberships.map((membership, index) => { + const group = groupsWithoutMembership[index]; + return { + membershipId: membership.id, + role: group.role, + customRoleId: group.roleId + }; + }); + + await knex(TableName.MembershipRole).insert(membershipRoleInserts); + } + + await knex.schema.alterTable(TableName.Membership, (t) => { + t.check( + `("actorUserId" IS NOT NULL OR "actorIdentityId" IS NOT NULL OR "actorGroupId" IS NOT NULL)`, + undefined, + "at_least_one_actor" + ); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TableName.Membership, (t) => { + t.dropChecks("at_least_one_actor"); + }); +} diff --git a/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts new file mode 100644 index 000000000..7675eb3e1 --- /dev/null +++ b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (!hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.boolean("blockDuplicateSecretSyncDestinations").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.dropColumn("blockDuplicateSecretSyncDestinations"); + }); + } +} diff --git a/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts new file mode 100644 index 000000000..b3ad123e8 --- /dev/null +++ b/backend/src/db/migrations/20251028064623_pam-account-rotation-status.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.string("rotationStatus").nullable(); + }); + } + if (!(await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage"))) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.binary("encryptedLastRotationMessage").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.PamAccount, "rotationStatus")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("rotationStatus"); + }); + } + if (await knex.schema.hasColumn(TableName.PamAccount, "encryptedLastRotationMessage")) { + await knex.schema.alterTable(TableName.PamAccount, (t) => { + t.dropColumn("encryptedLastRotationMessage"); + }); + } +} diff --git a/backend/src/db/migrations/20251028155708_identity-access-token-remove-fk-for-identity-id.ts b/backend/src/db/migrations/20251028155708_identity-access-token-remove-fk-for-identity-id.ts new file mode 100644 index 000000000..0974f39c2 --- /dev/null +++ b/backend/src/db/migrations/20251028155708_identity-access-token-remove-fk-for-identity-id.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + await knex.transaction(async (tx) => { + await tx.schema.alterTable(TableName.IdentityAccessToken, (table) => { + table.dropForeign("identityId"); + }); + }); +} + +export async function down(knex: Knex): Promise { + await knex.transaction(async (tx) => { + await tx.schema.alterTable(TableName.IdentityAccessToken, (table) => { + table.foreign("identityId").references("id").inTable(TableName.Identity); + }); + }); +} + +const config = { transaction: false }; +export { config }; diff --git a/backend/src/db/migrations/20251028160921_delete-no-org-identities.ts b/backend/src/db/migrations/20251028160921_delete-no-org-identities.ts new file mode 100644 index 000000000..0eb0e7ae0 --- /dev/null +++ b/backend/src/db/migrations/20251028160921_delete-no-org-identities.ts @@ -0,0 +1,30 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const MIGRATION_TIMEOUT = 30 * 60 * 1000; // 30 minutes + +export async function up(knex: Knex): Promise { + const result = await knex.raw("SHOW statement_timeout"); + const originalTimeout = result.rows[0].statement_timeout; + + await knex.transaction(async (tx) => { + try { + await tx.raw(`SET statement_timeout = ${MIGRATION_TIMEOUT}`); + const hasIdentityOrgCol = await tx.schema.hasColumn(TableName.Identity, "orgId"); + if (hasIdentityOrgCol) { + await tx(TableName.Identity).whereNull("orgId").delete(); + await tx.schema.alterTable(TableName.Identity, (t) => { + t.uuid("orgId").notNullable().alter(); + }); + } + } finally { + await tx.raw(`SET statement_timeout = '${originalTimeout}'`); + } + }); +} + +export async function down(): Promise {} + +const config = { transaction: false }; +export { config }; diff --git a/backend/src/db/migrations/utils/env-config.ts b/backend/src/db/migrations/utils/env-config.ts index de32f4db9..3a08f0123 100644 --- a/backend/src/db/migrations/utils/env-config.ts +++ b/backend/src/db/migrations/utils/env-config.ts @@ -1,7 +1,10 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; +import { removeTrailingSlash } from "@app/lib/fn"; import { zpStr } from "@app/lib/zod"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; const envSchema = z @@ -22,13 +25,17 @@ const envSchema = z HSM_LIB_PATH: zpStr(z.string().optional()), HSM_PIN: zpStr(z.string().optional()), HSM_KEY_LABEL: zpStr(z.string().optional()), - HSM_SLOT: z.coerce.number().optional().default(0) + HSM_SLOT: z.coerce.number().optional().default(0), + + LICENSE_SERVER_URL: zpStr(z.string().optional().default("https://portal.infisical.com")), + LICENSE_SERVER_KEY: zpStr(z.string().optional()), + LICENSE_KEY: zpStr(z.string().optional()), + LICENSE_KEY_OFFLINE: zpStr(z.string().optional()), + INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()), + + SITE_URL: zpStr(z.string().transform((val) => (val ? removeTrailingSlash(val) : val))).optional() }) // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .transform((data) => ({ ...data, isHsmConfigured: @@ -37,7 +44,27 @@ const envSchema = z export type TMigrationEnvConfig = z.infer; -export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory) => { +export const getMigrationHsmConfig = () => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + console.error("Invalid environment variables. Check the error below"); + console.error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + +export const getMigrationEnvConfig = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { // eslint-disable-next-line no-console @@ -53,7 +80,7 @@ export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory let envCfg = Object.freeze(parsedEnv.data); - const fipsEnabled = await crypto.initialize(superAdminDAL, envCfg); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL, envCfg); // Fix for 128-bit entropy encryption key expansion issue: // In FIPS it is not ideal to expand a 128-bit key into 256-bit. We solved this issue in the past by creating the ROOT_ENCRYPTION_KEY. diff --git a/backend/src/db/migrations/utils/services.ts b/backend/src/db/migrations/utils/services.ts index 0e071e6fe..cd3e5ac23 100644 --- a/backend/src/db/migrations/utils/services.ts +++ b/backend/src/db/migrations/utils/services.ts @@ -1,28 +1,23 @@ import { Knex } from "knex"; -import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { initializeHsmModule, isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { licenseDALFactory } from "@app/ee/services/license/license-dal"; +import { licenseServiceFactory } from "@app/ee/services/license/license-service"; +import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; +import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; -import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; -import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; -import { folderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service"; -import { folderCommitChangesDALFactory } from "@app/services/folder-commit-changes/folder-commit-changes-dal"; -import { folderTreeCheckpointDALFactory } from "@app/services/folder-tree-checkpoint/folder-tree-checkpoint-dal"; -import { folderTreeCheckpointResourcesDALFactory } from "@app/services/folder-tree-checkpoint-resources/folder-tree-checkpoint-resources-dal"; +import { BadRequestError } from "@app/lib/errors"; import { identityDALFactory } from "@app/services/identity/identity-dal"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { orgDALFactory } from "@app/services/org/org-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; -import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; -import { secretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; -import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; -import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; -import { secretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; -import { secretVersionV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { roleDALFactory } from "@app/services/role/role-dal"; +import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; import { userDALFactory } from "@app/services/user/user-dal"; import { TMigrationEnvConfig } from "./env-config"; @@ -33,8 +28,11 @@ type TDependencies = { keyStore: TKeyStoreFactory; }; -export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { - // eslint-disable-next-line no-param-reassign +type THsmServiceDependencies = { + envConfig: Pick; +}; + +export const getMigrationHsmService = async ({ envConfig }: THsmServiceDependencies) => { const hsmModule = initializeHsmModule(envConfig); hsmModule.initialize(); @@ -43,67 +41,72 @@ export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore } envConfig }); - const orgDAL = orgDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); - const kmsDAL = kmskeyDALFactory(db); - const internalKmsDAL = internalKmsDALFactory(db); - const projectDAL = projectDALFactory(db); - - const kmsService = kmsServiceFactory({ - kmsRootConfigDAL, - keyStore, - kmsDAL, - internalKmsDAL, - orgDAL, - projectDAL, - hsmService, - envConfig - }); - await hsmService.startService(); - await kmsService.startService(); - return { kmsService }; + return { hsmService }; }; -export const getMigrationPITServices = async ({ - db, - keyStore, - envConfig -}: { - db: Knex; - keyStore: TKeyStoreFactory; - envConfig: TMigrationEnvConfig; -}) => { +export const getMigrationEncryptionServices = async ({ envConfig, db, keyStore }: TDependencies) => { + // ----- DAL dependencies ----- + const orgDAL = orgDALFactory(db); + const licenseDAL = licenseDALFactory(db); + const permissionDAL = permissionDALFactory(db); const projectDAL = projectDALFactory(db); - const folderCommitDAL = folderCommitDALFactory(db); - const folderCommitChangesDAL = folderCommitChangesDALFactory(db); - const folderCheckpointDAL = folderCheckpointDALFactory(db); - const folderTreeCheckpointDAL = folderTreeCheckpointDALFactory(db); + const roleDAL = roleDALFactory(db); const userDAL = userDALFactory(db); const identityDAL = identityDALFactory(db); - const folderDAL = secretFolderDALFactory(db); - const folderVersionDAL = secretFolderVersionDALFactory(db); - const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db); - const folderCheckpointResourcesDAL = folderCheckpointResourcesDALFactory(db); - const secretV2BridgeDAL = secretV2BridgeDALFactory({ db, keyStore }); - const folderTreeCheckpointResourcesDAL = folderTreeCheckpointResourcesDALFactory(db); - const secretTagDAL = secretTagDALFactory(db); - - const orgDAL = orgDALFactory(db); + const serviceTokenDAL = serviceTokenDALFactory(db); const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); - const resourceMetadataDAL = resourceMetadataDALFactory(db); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); + // ----- Service dependencies ----- + const permissionService = permissionServiceFactory({ + permissionDAL, + serviceTokenDAL, + projectDAL, + keyStore, + roleDAL, + userDAL, + identityDAL + }); - const hsmService = hsmServiceFactory({ - hsmModule: hsmModule.getModule(), + const licenseService = licenseServiceFactory({ + permissionService, + orgDAL, + licenseDAL, + keyStore, + projectDAL, envConfig }); + // ----- HSM startup ----- + + const { hsmService } = await getMigrationHsmService({ envConfig }); + + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + + // ----- KMS startup ----- + const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, @@ -115,27 +118,7 @@ export const getMigrationPITServices = async ({ envConfig }); - await hsmService.startService(); - await kmsService.startService(); + await kmsService.startService(hsmStatus); - const folderCommitService = folderCommitServiceFactory({ - folderCommitDAL, - folderCommitChangesDAL, - folderCheckpointDAL, - folderTreeCheckpointDAL, - userDAL, - identityDAL, - folderDAL, - folderVersionDAL, - secretVersionV2BridgeDAL, - projectDAL, - folderCheckpointResourcesDAL, - secretV2BridgeDAL, - folderTreeCheckpointResourcesDAL, - kmsService, - secretTagDAL, - resourceMetadataDAL - }); - - return { folderCommitService }; + return { kmsService, hsmService }; }; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 63122f662..8a3ae8f84 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -27,7 +27,13 @@ export const CertificatesSchema = z.object({ extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), pkiSubscriberId: z.string().uuid().nullable().optional(), - profileId: z.string().uuid().nullable().optional() + profileId: z.string().uuid().nullable().optional(), + renewBeforeDays: z.number().nullable().optional(), + renewedFromCertificateId: z.string().uuid().nullable().optional(), + renewedByCertificateId: z.string().uuid().nullable().optional(), + renewalError: z.string().nullable().optional(), + keyAlgorithm: z.string().nullable().optional(), + signatureAlgorithm: z.string().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a1c01151f..3cc7fe858 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({ googleSsoAuthEnforced: z.boolean().default(false), googleSsoAuthLastUsed: z.date().nullable().optional(), parentOrgId: z.string().uuid().nullable().optional(), - rootOrgId: z.string().uuid().nullable().optional() + rootOrgId: z.string().uuid().nullable().optional(), + blockDuplicateSecretSyncDestinations: z.boolean().default(false) }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/pam-accounts.ts b/backend/src/db/schemas/pam-accounts.ts index 5a9a45617..4f097a16d 100644 --- a/backend/src/db/schemas/pam-accounts.ts +++ b/backend/src/db/schemas/pam-accounts.ts @@ -18,7 +18,12 @@ export const PamAccountsSchema = z.object({ description: z.string().nullable().optional(), encryptedCredentials: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + rotationEnabled: z.boolean().default(false), + rotationIntervalSeconds: z.number().nullable().optional(), + lastRotatedAt: z.date().nullable().optional(), + rotationStatus: z.string().nullable().optional(), + encryptedLastRotationMessage: zodBuffer.nullable().optional() }); export type TPamAccounts = z.infer; diff --git a/backend/src/db/schemas/pam-resources.ts b/backend/src/db/schemas/pam-resources.ts index d34017d0f..325f6eddc 100644 --- a/backend/src/db/schemas/pam-resources.ts +++ b/backend/src/db/schemas/pam-resources.ts @@ -17,7 +17,8 @@ export const PamResourcesSchema = z.object({ resourceType: z.string(), encryptedConnectionDetails: zodBuffer, createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + encryptedRotationAccountCredentials: zodBuffer.nullable().optional() }); export type TPamResources = z.infer; diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts index 710b0dee4..7a1beccdb 100644 --- a/backend/src/db/schemas/pki-api-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiApiEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), autoRenew: z.boolean().default(false).nullable().optional(), - autoRenewDays: z.number().nullable().optional(), + renewBeforeDays: z.number().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/seeds/1-user.ts b/backend/src/db/seeds/1-user.ts index 43ce4dadf..9f42ef12b 100644 --- a/backend/src/db/seeds/1-user.ts +++ b/backend/src/db/seeds/1-user.ts @@ -1,7 +1,10 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AuthMethod } from "../../services/auth/auth-type"; @@ -17,7 +20,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); await knex(TableName.SuperAdmin).insert([ // eslint-disable-next-line diff --git a/backend/src/db/seeds/3-project.ts b/backend/src/db/seeds/3-project.ts index d0294022f..99083ab94 100644 --- a/backend/src/db/seeds/3-project.ts +++ b/backend/src/db/seeds/3-project.ts @@ -1,11 +1,14 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto, SymmetricKeySize } from "@app/lib/crypto/cryptography"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { initLogger, logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { AuthMethod } from "@app/services/auth/auth-type"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { membershipUserDALFactory } from "@app/services/membership-user/membership-user-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "@app/services/project/project-fns"; @@ -192,7 +195,21 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const [project] = await knex(TableName.Project) .insert({ diff --git a/backend/src/db/seeds/5-machine-identity.ts b/backend/src/db/seeds/5-machine-identity.ts index 85507c890..4e4e3eb7f 100644 --- a/backend/src/db/seeds/5-machine-identity.ts +++ b/backend/src/db/seeds/5-machine-identity.ts @@ -1,8 +1,11 @@ import { Knex } from "knex"; -import { initEnvConfig } from "@app/lib/config/env"; +import { initializeHsmModule } from "@app/ee/services/hsm/hsm-fns"; +import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { getHsmConfig, initEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { initLogger, logger } from "@app/lib/logger"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { superAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { AccessScope, IdentityAuthMethod, OrgMembershipRole, ProjectMembershipRole, TableName } from "../schemas"; @@ -15,7 +18,20 @@ export async function seed(knex: Knex): Promise { initLogger(); const superAdminDAL = superAdminDALFactory(knex); - await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(knex); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + + await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); // Inserts seed entries await knex(TableName.Identity).insert([ diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts index 26c27d0d3..62911d523 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-router.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { DynamicSecretLeasesSchema } from "@app/db/schemas"; import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; -import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -32,8 +31,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), @@ -127,8 +126,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }), projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug), path: z diff --git a/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts index f2751c635..30de7f08a 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-lease-routers/kubernetes-lease-router.ts @@ -2,7 +2,6 @@ import { z } from "zod"; import { DynamicSecretLeasesSchema } from "@app/db/schemas"; import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; -import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; import { writeLimit } from "@app/server/config/rateLimiter"; @@ -32,8 +31,8 @@ export const registerKubernetesDynamicSecretLeaseRouter = async (server: Fastify const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path), environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), diff --git a/backend/src/ee/routes/v1/dynamic-secret-router.ts b/backend/src/ee/routes/v1/dynamic-secret-router.ts index b1b3cea8e..0e48206dc 100644 --- a/backend/src/ee/routes/v1/dynamic-secret-router.ts +++ b/backend/src/ee/routes/v1/dynamic-secret-router.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { DynamicSecretLeasesSchema } from "@app/db/schemas"; import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models"; import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs"; -import { daysToMillisecond } from "@app/lib/dates"; import { removeTrailingSlash } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars"; @@ -60,8 +59,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }), maxTTL: z .string() @@ -72,8 +71,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }) .nullable(), path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), @@ -130,8 +129,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }), maxTTL: z .string() @@ -142,8 +141,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) => const valMs = ms(val); if (valMs < 60 * 1000) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); - if (valMs > daysToMillisecond(1)) - ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + if (valMs > ms("10y")) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" }); }) .nullable(), newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index 6fcf05d99..1e3305ac7 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -182,7 +182,8 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { algorithm: z.string(), isActive: z.boolean(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + kmipMetadata: z.record(z.any()).nullish() }) } }, @@ -384,7 +385,8 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { isActive: z.boolean(), algorithm: z.string(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + kmipMetadata: z.record(z.any()).nullish() }) .array() }) diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 568412c84..60d621467 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateMySQLAccountSchema, + SanitizedMySQLAccountWithResourceSchema, + UpdateMySQLAccountSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresAccountSchema, @@ -16,5 +21,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + accountResponseSchema: SanitizedMySQLAccountWithResourceSchema, + createAccountSchema: CreateMySQLAccountSchema, + updateAccountSchema: UpdateMySQLAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts index 0ed7e238a..44e2a5ea1 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-endpoints.ts @@ -22,11 +22,15 @@ export const registerPamResourceEndpoints = ({ folderId?: C["folderId"]; name: C["name"]; description?: C["description"]; + rotationEnabled: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; updateAccountSchema: z.ZodType<{ credentials?: C["credentials"]; name?: C["name"]; description?: C["description"]; + rotationEnabled?: C["rotationEnabled"]; + rotationIntervalSeconds?: C["rotationIntervalSeconds"]; }>; accountResponseSchema: z.ZodTypeAny; }) => { @@ -60,7 +64,9 @@ export const registerPamResourceEndpoints = ({ resourceType, folderId: req.body.folderId, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); @@ -108,7 +114,9 @@ export const registerPamResourceEndpoints = ({ resourceId: account.resourceId, resourceType, name: req.body.name, - description: req.body.description + description: req.body.description, + rotationEnabled: req.body.rotationEnabled, + rotationIntervalSeconds: req.body.rotationIntervalSeconds } } }); diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 647f39d8d..d2e0183ff 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { BadRequestError } from "@app/lib/errors"; @@ -10,8 +11,10 @@ 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"; -// Use z.union([...]) when more resources are added -const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema; +const SanitizedAccountSchema = z.union([ + SanitizedPostgresAccountWithResourceSchema, + SanitizedMySQLAccountWithResourceSchema +]); export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index a63b67d94..821532598 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -1,7 +1,12 @@ +import { + CreateMySQLResourceSchema, + MySQLResourceSchema, + UpdateMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresResourceSchema, - PostgresResourceSchema, + SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; @@ -12,9 +17,18 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + resourceResponseSchema: MySQLResourceSchema, + createResourceSchema: CreateMySQLResourceSchema, + updateResourceSchema: UpdateMySQLResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts index 776de8e48..ffbeae5c0 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-endpoints.ts @@ -21,11 +21,13 @@ export const registerPamResourceEndpoints = ({ connectionDetails: T["connectionDetails"]; gatewayId: T["gatewayId"]; name: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; updateResourceSchema: z.ZodType<{ connectionDetails?: T["connectionDetails"]; gatewayId?: T["gatewayId"]; name?: T["name"]; + rotationAccountCredentials?: T["rotationAccountCredentials"]; }>; resourceResponseSchema: z.ZodTypeAny; }) => { diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index c19c2030d..6563c86c7 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -1,18 +1,24 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + MySQLResourceListItemSchema, + SanitizedMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresResourceListItemSchema, - PostgresResourceSchema + SanitizedPostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// Use z.union([...]) when more resources are added -const ResourceSchema = PostgresResourceSchema; +const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]); -const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]); +const ResourceOptionsSchema = z.discriminatedUnion("resource", [ + PostgresResourceListItemSchema, + MySQLResourceListItemSchema +]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { server.route({ @@ -50,7 +56,7 @@ export const registerPamResourceRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - resources: ResourceSchema.array() + resources: SanitizedResourceSchema.array() }) } }, diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index c353fddfa..5fe10e434 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -2,14 +2,14 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// Use z.union([]) once there's multiple -const SessionCredentialsSchema = PostgresSessionCredentialsSchema; +const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { // Meant to be hit solely by gateway identities diff --git a/backend/src/ee/routes/v1/saml-router.ts b/backend/src/ee/routes/v1/saml-router.ts index 76bff60e8..00add4adc 100644 --- a/backend/src/ee/routes/v1/saml-router.ts +++ b/backend/src/ee/routes/v1/saml-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import { MultiSamlStrategy } from "@node-saml/passport-saml"; import { FastifyRequest } from "fastify"; @@ -17,6 +18,7 @@ import { ApiDocsTags, SamlSso } from "@app/lib/api-docs"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { SanitizedSamlConfigSchema } from "@app/server/routes/sanitizedSchema/directory-config"; @@ -102,15 +104,15 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req, profile, cb) => { + if (!profile) throw new BadRequestError({ message: "Missing profile" }); + + const email = + profile?.email ?? + // entra sends data in this format + (profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ?? + (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\ + try { - if (!profile) throw new BadRequestError({ message: "Missing profile" }); - - const email = - profile?.email ?? - // entra sends data in this format - (profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/email"] as string) ?? - (profile?.emailAddress as string); // emailRippling is added because in Rippling the field `email` reserved\ - const firstName = (profile.firstName ?? // entra sends data in this format profile["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstName"]) as string; @@ -144,7 +146,7 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { }) .filter((el) => el.key && !["email", "firstName", "lastName"].includes(el.key)); - const { isUserCompleted, providerAuthToken } = await server.services.saml.samlLogin({ + const { isUserCompleted, providerAuthToken, user, organization } = await server.services.saml.samlLogin({ externalId: profile.nameID, email: email.toLowerCase(), firstName, @@ -154,8 +156,32 @@ export const registerSamlRouter = async (server: FastifyZodProvider) => { orgId: (req as unknown as FastifyRequest).ssoConfig?.orgId, metadata: userMetadata }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email.toLowerCase(), + "infisical.user.id": user.id, + "infisical.organization.id": organization.id, + "infisical.organization.name": organization.name, + "infisical.auth.method": AuthAttemptAuthMethod.SAML, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email.toLowerCase(), + "infisical.auth.method": AuthAttemptAuthMethod.SAML, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + logger.error(error); cb(error as Error); } 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 7c73e590b..b58503110 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -340,6 +340,8 @@ export enum EventType { ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-subscriber-cert", + AUTOMATED_RENEW_CERTIFICATE = "automated-renew-certificate", + AUTOMATED_RENEW_CERTIFICATE_FAILED = "automated-renew-certificate-failed", LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs", GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", CREATE_KMS = "create-kms", @@ -367,6 +369,9 @@ export enum EventType { ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", + RENEW_CERTIFICATE = "renew-certificate", + UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", + DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -527,6 +532,8 @@ export enum EventType { PAM_ACCOUNT_CREATE = "pam-account-create", PAM_ACCOUNT_UPDATE = "pam-account-update", PAM_ACCOUNT_DELETE = "pam-account-delete", + PAM_ACCOUNT_CREDENTIAL_ROTATION = "pam-account-credential-rotation", + PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED = "pam-account-credential-rotation-failed", PAM_RESOURCE_LIST = "pam-resource-list", PAM_RESOURCE_GET = "pam-resource-get", PAM_RESOURCE_CREATE = "pam-resource-create", @@ -2456,6 +2463,29 @@ interface AutomatedRenewPkiSubscriberCert { }; } +interface AutomatedRenewCertificate { + type: EventType.AUTOMATED_RENEW_CERTIFICATE; + metadata: { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays: string; + profileName: string; + }; +} + +interface AutomatedRenewCertificateFailed { + type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED; + metadata: { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays: string; + profileName: string; + error: string; + }; +} + interface SignPkiSubscriberCert { type: EventType.SIGN_PKI_SUBSCRIBER_CERT; metadata: { @@ -2718,6 +2748,16 @@ interface OrderCertificateFromProfile { }; } +interface RenewCertificate { + type: EventType.RENEW_CERTIFICATE; + metadata: { + originalCertificateId: string; + newCertificateId: string; + profileName: string; + commonName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -3915,6 +3955,8 @@ interface PamAccountCreateEvent { folderId?: string | null; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds?: number | null; }; } @@ -3926,6 +3968,8 @@ interface PamAccountUpdateEvent { resourceType: string; name?: string; description?: string | null; + rotationEnabled?: boolean; + rotationIntervalSeconds?: number | null; }; } @@ -3939,6 +3983,27 @@ interface PamAccountDeleteEvent { }; } +interface PamAccountCredentialRotationEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + }; +} + +interface PamAccountCredentialRotationFailedEvent { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED; + metadata: { + accountName: string; + accountId: string; + resourceId: string; + resourceType: string; + errorMessage: string; + }; +} + interface PamResourceListEvent { type: EventType.PAM_RESOURCE_LIST; metadata: { @@ -3982,6 +4047,23 @@ interface PamResourceDeleteEvent { }; } +interface UpdateCertificateRenewalConfigEvent { + type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG; + metadata: { + certificateId: string; + renewBeforeDays: string; + commonName: string; + }; +} + +interface DisableCertificateRenewalConfigEvent { + type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG; + metadata: { + certificateId: string; + commonName: string; + }; +} + export type Event = | CreateSubOrganizationEvent | UpdateSubOrganizationEvent @@ -4189,6 +4271,7 @@ export type Event = | IssueCertificateFromProfile | SignCertificateFromProfile | OrderCertificateFromProfile + | RenewCertificate | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration @@ -4340,8 +4423,14 @@ export type Event = | PamAccountCreateEvent | PamAccountUpdateEvent | PamAccountDeleteEvent + | PamAccountCredentialRotationEvent + | PamAccountCredentialRotationFailedEvent | PamResourceListEvent | PamResourceGetEvent | PamResourceCreateEvent | PamResourceUpdateEvent - | PamResourceDeleteEvent; + | PamResourceDeleteEvent + | UpdateCertificateRenewalConfigEvent + | DisableCertificateRenewalConfigEvent + | AutomatedRenewCertificate + | AutomatedRenewCertificateFailed; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts index d2d683a84..137207659 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-service.ts @@ -112,7 +112,7 @@ export const dynamicSecretServiceFactory = ({ const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); if (existingDynamicSecret) - throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + throw new BadRequestError({ message: "Provided dynamic secret already exists under the folder" }); const selectedProvider = dynamicSecretProviders[provider.type]; const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId }); @@ -265,7 +265,7 @@ export const dynamicSecretServiceFactory = ({ if (newName) { const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id }); if (existingDynamicSecret) - throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + throw new BadRequestError({ message: "Provided dynamic secret already exists under the folder" }); } const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ diff --git a/backend/src/ee/services/hsm/hsm-fns.ts b/backend/src/ee/services/hsm/hsm-fns.ts index 1afccdafe..400fa31e9 100644 --- a/backend/src/ee/services/hsm/hsm-fns.ts +++ b/backend/src/ee/services/hsm/hsm-fns.ts @@ -1,8 +1,14 @@ import * as pkcs11js from "pkcs11js"; import { TEnvConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; +import { KMS_ROOT_CONFIG_UUID } from "@app/services/kms/kms-fns"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; +import { TLicenseServiceFactory } from "../license/license-service"; +import { THsmServiceFactory } from "./hsm-service"; import { HsmModule } from "./hsm-types"; export const initializeHsmModule = (envConfig: Pick) => { @@ -25,10 +31,9 @@ export const initializeHsmModule = (envConfig: Pick; + kmsRootConfigDAL: Pick; + licenseService?: Pick; +}) => { + const isHsmConfigured = await hsmService.isActive(); + + // null if the root kms config does not exist + let rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null = null; + + const rootKmsConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID).catch(() => null); + + rootKmsConfigEncryptionStrategy = (rootKmsConfig?.encryptionStrategy || null) as RootKeyEncryptionStrategy | null; + if ( + rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.HSM && + licenseService && + !licenseService.onPremFeatures.hsm + ) { + throw new BadRequestError({ + message: "Your license does not include HSM integration. Please upgrade to the Enterprise plan to use HSM." + }); + } + + return { + rootKmsConfigEncryptionStrategy, + isHsmConfigured + }; +}; diff --git a/backend/src/ee/services/hsm/hsm-service.ts b/backend/src/ee/services/hsm/hsm-service.ts index 0ed4c5faf..1207b1cd3 100644 --- a/backend/src/ee/services/hsm/hsm-service.ts +++ b/backend/src/ee/services/hsm/hsm-service.ts @@ -25,6 +25,8 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon const AES_KEY_SIZE = 256; const HMAC_KEY_SIZE = 256; + let pkcs11TestPassed = false; + const $withSession = async (callbackWithSession: SessionCallback): Promise => { const RETRY_INTERVAL = 200; // 200ms between attempts const MAX_TIMEOUT = 90_000; // 90 seconds maximum total time @@ -363,7 +365,9 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon return false; } - let pkcs11TestPassed = false; + if (pkcs11TestPassed) { + return true; + } try { pkcs11TestPassed = await $withSession($testPkcs11Module); @@ -371,7 +375,7 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon logger.error(err, "HSM: Error testing PKCS#11 module"); } - return envConfig.isHsmConfigured && isInitialized && pkcs11TestPassed; + return pkcs11TestPassed; }; const startService = async () => { @@ -460,10 +464,23 @@ export const hsmServiceFactory = ({ hsmModule: { isInitialized, pkcs11 }, envCon } }; + const randomBytes = async (length: number) => { + if (!pkcs11 || !isInitialized) { + throw new Error("PKCS#11 module is not initialized"); + } + + const randomData = await $withSession((sessionHandle) => + pkcs11.C_GenerateRandom(sessionHandle, Buffer.alloc(length)) + ); + + return randomData; + }; + return { encrypt, startService, isActive, - decrypt + decrypt, + randomBytes }; }; diff --git a/backend/src/ee/services/hsm/hsm-types.ts b/backend/src/ee/services/hsm/hsm-types.ts index b688147f5..ada527329 100644 --- a/backend/src/ee/services/hsm/hsm-types.ts +++ b/backend/src/ee/services/hsm/hsm-types.ts @@ -1,5 +1,7 @@ import pkcs11js from "pkcs11js"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; + export type HsmModule = { pkcs11: pkcs11js.PKCS11; isInitialized: boolean; @@ -9,3 +11,8 @@ export enum HsmKeyType { AES = "AES", HMAC = "hmac" } + +export type THsmStatus = { + rootKmsConfigEncryptionStrategy: RootKeyEncryptionStrategy | null; + isHsmConfigured: boolean; +}; diff --git a/backend/src/ee/services/kmip/kmip-operation-service.ts b/backend/src/ee/services/kmip/kmip-operation-service.ts index b3eace6bc..3955fd0ca 100644 --- a/backend/src/ee/services/kmip/kmip-operation-service.ts +++ b/backend/src/ee/services/kmip/kmip-operation-service.ts @@ -341,7 +341,8 @@ export const kmipOperationServiceFactory = ({ algorithm: completeKeyDetails.internalKms.encryptionAlgorithm, isActive: !key.isDisabled, createdAt: key.createdAt, - updatedAt: key.updatedAt + updatedAt: key.updatedAt, + kmipMetadata: key.kmipMetadata as Record }; }; diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 5a53f924c..bbd6147ed 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -11,7 +11,7 @@ import { Knex } from "knex"; import { OrganizationActionScope } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; -import { getConfig } from "@app/lib/config/env"; +import { TEnvConfig } from "@app/lib/config/env"; import { verifyOfflineLicense } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -40,11 +40,16 @@ import { TOrgPlanDTO, TOrgPlansTableDTO, TOrgPmtMethodsDTO, + TPlanBillingInfo, TStartOrgTrialDTO, TUpdateOrgBillingDetailsDTO } from "./license-types"; type TLicenseServiceFactoryDep = { + envConfig: Pick< + TEnvConfig, + "LICENSE_SERVER_URL" | "LICENSE_SERVER_KEY" | "LICENSE_KEY" | "LICENSE_KEY_OFFLINE" | "INTERNAL_REGION" | "SITE_URL" + >; orgDAL: Pick; permissionService: Pick; licenseDAL: TLicenseDALFactory; @@ -65,26 +70,26 @@ export const licenseServiceFactory = ({ permissionService, licenseDAL, keyStore, - projectDAL + projectDAL, + envConfig }: TLicenseServiceFactoryDep) => { let isValidLicense = false; let instanceType = InstanceType.OnPrem; let onPremFeatures: TFeatureSet = getDefaultOnPremFeatures(); let selfHostedLicense: TOfflineLicense | null = null; - const appCfg = getConfig(); const licenseServerCloudApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_CLOUD_LOGIN, - appCfg.LICENSE_SERVER_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_SERVER_KEY || "", + envConfig.INTERNAL_REGION ); const licenseServerOnPremApi = setupLicenseRequestWithStore( - appCfg.LICENSE_SERVER_URL || "", + envConfig.LICENSE_SERVER_URL || "", LICENSE_SERVER_ON_PREM_LOGIN, - appCfg.LICENSE_KEY || "", - appCfg.INTERNAL_REGION + envConfig.LICENSE_KEY || "", + envConfig.INTERNAL_REGION ); const syncLicenseKeyOnPremFeatures = async (shouldThrow: boolean = false) => { @@ -118,7 +123,7 @@ export const licenseServiceFactory = ({ const init = async () => { try { - if (appCfg.LICENSE_SERVER_KEY) { + if (envConfig.LICENSE_SERVER_KEY) { const token = await licenseServerCloudApi.refreshLicense(); if (token) instanceType = InstanceType.Cloud; logger.info(`Instance type: ${InstanceType.Cloud}`); @@ -126,7 +131,7 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicense(); if (token) { await syncLicenseKeyOnPremFeatures(true); @@ -137,10 +142,10 @@ export const licenseServiceFactory = ({ return; } - if (appCfg.LICENSE_KEY_OFFLINE) { + if (envConfig.LICENSE_KEY_OFFLINE) { let isValidOfflineLicense = true; const contents: TOfflineLicenseContents = JSON.parse( - Buffer.from(appCfg.LICENSE_KEY_OFFLINE, "base64").toString("utf8") + Buffer.from(envConfig.LICENSE_KEY_OFFLINE, "base64").toString("utf8") ); const isVerified = await verifyOfflineLicense(JSON.stringify(contents.license), contents.signature); @@ -179,7 +184,7 @@ export const licenseServiceFactory = ({ }; const initializeBackgroundSync = async () => { - if (appCfg.LICENSE_KEY) { + if (envConfig.LICENSE_KEY) { logger.info("Setting up background sync process for refresh onPremFeatures"); const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures); job.start(); @@ -212,9 +217,8 @@ export const licenseServiceFactory = ({ const membersUsed = await licenseDAL.countOfOrgMembers(rootOrgId); currentPlan.membersUsed = membersUsed; const identityUsed = await licenseDAL.countOrgUsersAndIdentities(rootOrgId); - currentPlan.identitiesUsed = identityUsed; - if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + if (currentPlan?.identitiesUsed && currentPlan.identitiesUsed !== identityUsed) { try { await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { quantity: membersUsed, @@ -227,6 +231,7 @@ export const licenseServiceFactory = ({ ); } } + currentPlan.identitiesUsed = identityUsed; await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), @@ -440,8 +445,8 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/payment-methods`, { - success_url: `${appCfg.SITE_URL}/organization/billing`, - cancel_url: `${appCfg.SITE_URL}/organization/billing` + success_url: `${envConfig.SITE_URL}/organization/billing`, + cancel_url: `${envConfig.SITE_URL}/organization/billing` } ); @@ -454,13 +459,28 @@ export const licenseServiceFactory = ({ } = await licenseServerCloudApi.request.post( `/api/license-server/v1/customers/${organization.customerId}/billing-details/billing-portal`, { - return_url: `${appCfg.SITE_URL}/organization/billing` + return_url: `${envConfig.SITE_URL}/organization/billing` } ); return { url }; }; + const getUsageMetrics = async (orgId: string) => { + const [orgMembersUsed, identityUsed, projectCount] = await Promise.all([ + orgDAL.countAllOrgMembers(orgId), + licenseDAL.countOfOrgIdentities(orgId), + projectDAL.countOfOrgProjects(orgId) + ]); + + return { + orgMembersUsed, + identityUsed, + projectCount, + totalIdentities: identityUsed + orgMembersUsed + }; + }; + const getOrgBillingInfo = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission({ actorId, @@ -479,10 +499,16 @@ export const licenseServiceFactory = ({ }); } if (instanceType === InstanceType.Cloud) { - const { data } = await licenseServerCloudApi.request.get( + const { data } = await licenseServerCloudApi.request.get( `/api/license-server/v1/customers/${organization.customerId}/cloud-plan/billing` ); - return data; + const { identityUsed, orgMembersUsed } = await getUsageMetrics(orgId); + + return { + ...data, + users: orgMembersUsed, + identities: identityUsed + }; } return { @@ -491,7 +517,9 @@ export const licenseServiceFactory = ({ interval: "month", intervalCount: 1, amount: 0, - quantity: 1 + quantity: 1, + users: 0, + identities: 0 }; }; @@ -535,21 +563,6 @@ export const licenseServiceFactory = ({ throw new Error(`Unsupported instance type for server-based plan table: ${instanceType}`); }; - const getUsageMetrics = async (orgId: string) => { - const [orgMembersUsed, identityUsed, projectCount] = await Promise.all([ - orgDAL.countAllOrgMembers(orgId), - licenseDAL.countOfOrgIdentities(orgId), - projectDAL.countOfOrgProjects(orgId) - ]); - - return { - orgMembersUsed, - identityUsed, - projectCount, - totalIdentities: identityUsed + orgMembersUsed - }; - }; - // returns org current plan feature table const getOrgPlanTable = async ({ orgId, actor, actorId, actorAuthMethod, actorOrgId }: TGetOrgBillInfoDTO) => { const { permission } = await permissionService.getOrgPermission({ diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 93f40ae6e..c4ff6a8fa 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -22,6 +22,15 @@ export type TOfflineLicense = { features: TFeatureSet; }; +export type TPlanBillingInfo = { + currentPeriodStart: number; + currentPeriodEnd: number; + interval: "month" | "year"; + intervalCount: number; + amount: number; + quantity: number; +}; + export type TFeatureSet = { _id: null; slug: string | null; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index e80ec7cf5..cbe1bed7e 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { Issuer, Issuer as OpenIdIssuer, Strategy as OpenIdStrategy, TokenSet } from "openid-client"; import { AccessScope, OrganizationActionScope, OrgMembershipStatus, TableName, TUsers } from "@app/db/schemas"; @@ -15,6 +16,7 @@ import { TPermissionServiceFactory } from "@app/ee/services/permission/permissio import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError, OidcAuthError } from "@app/lib/errors"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType, AuthMethod, AuthTokenType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -471,7 +473,7 @@ export const oidcConfigServiceFactory = ({ }); } - return { isUserCompleted, providerAuthToken }; + return { isUserCompleted, providerAuthToken, user }; }; const updateOidcCfg = async ({ @@ -754,10 +756,35 @@ export const oidcConfigServiceFactory = ({ callbackPort, manageGroupMemberships: oidcCfg.manageGroupMemberships }) - .then(({ isUserCompleted, providerAuthToken }) => { + .then(({ isUserCompleted, providerAuthToken, user }) => { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": claims?.email?.toLowerCase(), + "infisical.user.id": user.id, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.auth.method": AuthAttemptAuthMethod.OIDC, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); }) .catch((error) => { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": claims?.email?.toLowerCase(), + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.auth.method": AuthAttemptAuthMethod.OIDC, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(error); }); } diff --git a/backend/src/ee/services/pam-account/pam-account-dal.ts b/backend/src/ee/services/pam-account/pam-account-dal.ts index b62e940fe..6ef7df76e 100644 --- a/backend/src/ee/services/pam-account/pam-account-dal.ts +++ b/backend/src/ee/services/pam-account/pam-account-dal.ts @@ -18,7 +18,8 @@ export const pamAccountDALFactory = (db: TDbClient) => { .select( // resource db.ref("name").withSchema(TableName.PamResource).as("resourceName"), - db.ref("resourceType").withSchema(TableName.PamResource) + db.ref("resourceType").withSchema(TableName.PamResource), + db.ref("encryptedRotationAccountCredentials").withSchema(TableName.PamResource) ); if (filter) { @@ -28,16 +29,35 @@ export const pamAccountDALFactory = (db: TDbClient) => { const accounts = await query; - return accounts.map(({ resourceId, resourceName, resourceType, ...account }) => ({ - ...account, - resourceId, - resource: { - id: resourceId, - name: resourceName, - resourceType - } - })); + return accounts.map( + ({ resourceId, resourceName, resourceType, encryptedRotationAccountCredentials, ...account }) => ({ + ...account, + resourceId, + resource: { + id: resourceId, + name: resourceName, + resourceType, + encryptedRotationAccountCredentials + } + }) + ); }; - return { ...orm, findWithResourceDetails }; + const findAccountsDueForRotation = async (tx?: Knex) => { + const dbClient = tx || db.replicaNode(); + + const accounts = await dbClient(TableName.PamAccount) + .innerJoin(TableName.PamResource, `${TableName.PamAccount}.resourceId`, `${TableName.PamResource}.id`) + .whereNotNull(`${TableName.PamResource}.encryptedRotationAccountCredentials`) + .whereNotNull(`${TableName.PamAccount}.rotationIntervalSeconds`) + .where(`${TableName.PamAccount}.rotationEnabled`, true) + .whereRaw( + `COALESCE("${TableName.PamAccount}"."lastRotatedAt", "${TableName.PamAccount}"."createdAt") + "${TableName.PamAccount}"."rotationIntervalSeconds" * interval '1 second' < NOW()` + ) + .select(selectAllTableCols(TableName.PamAccount)); + + return accounts; + }; + + return { ...orm, findWithResourceDetails, findAccountsDueForRotation }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-fns.ts b/backend/src/ee/services/pam-account/pam-account-fns.ts index fdc440991..aae703eeb 100644 --- a/backend/src/ee/services/pam-account/pam-account-fns.ts +++ b/backend/src/ee/services/pam-account/pam-account-fns.ts @@ -45,17 +45,47 @@ export const decryptAccountCredentials = async ({ return JSON.parse(decryptedPlainTextBlob.toString()) as TPamAccountCredentials; }; -export const decryptAccount = async ( +export const decryptAccountMessage = async ({ + projectId, + encryptedMessage, + kmsService +}: { + projectId: string; + encryptedMessage: Buffer; + kmsService: Pick; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedMessage + }); + + return decryptedPlainTextBlob.toString(); +}; + +export const decryptAccount = async < + T extends { encryptedCredentials: Buffer; encryptedLastRotationMessage?: Buffer | null } +>( account: T, projectId: string, kmsService: Pick -): Promise => { +): Promise => { return { ...account, credentials: await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId, kmsService - }) - } as T & { credentials: TPamAccountCredentials }; + }), + lastRotationMessage: account.encryptedLastRotationMessage + ? await decryptAccountMessage({ + encryptedMessage: account.encryptedLastRotationMessage, + projectId, + kmsService + }) + : null + }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index b8dad991a..00b84943f 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -11,12 +11,15 @@ import { } from "@app/ee/services/permission/project-permission"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; +import { EventType, TAuditLogServiceFactory } from "../audit-log/audit-log-types"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; @@ -45,10 +48,12 @@ type TPamAccountServiceFactoryDep = { "getPAMConnectionDetails" | "getPlatformConnectionDetailsByGatewayId" >; userDAL: TUserDALFactory; + auditLogService: Pick; }; - export type TPamAccountServiceFactory = ReturnType; +const ROTATION_CONCURRENCY_LIMIT = 10; + export const pamAccountServiceFactory = ({ pamResourceDAL, pamSessionDAL, @@ -59,10 +64,19 @@ export const pamAccountServiceFactory = ({ permissionService, licenseService, kmsService, - gatewayV2Service + gatewayV2Service, + auditLogService }: TPamAccountServiceFactoryDep) => { const create = async ( - { credentials, resourceId, name, description, folderId }: TCreateAccountDTO, + { + credentials, + resourceId, + name, + description, + folderId, + rotationEnabled, + rotationIntervalSeconds + }: TCreateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -72,6 +86,12 @@ export const pamAccountServiceFactory = ({ }); } + if (rotationEnabled && (rotationIntervalSeconds === undefined || rotationIntervalSeconds === null)) { + throw new BadRequestError({ + message: "Rotation interval must be defined when rotation is enabled." + }); + } + const resource = await pamResourceDAL.findById(resourceId); if (!resource) throw new NotFoundError({ message: `Resource with ID '${resourceId}' not found` }); @@ -84,6 +104,10 @@ export const pamAccountServiceFactory = ({ actionProjectType: ActionProjectType.PAM }); + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + const accountPath = await getFullPamFolderPath({ pamFolderDAL, folderId, @@ -126,12 +150,19 @@ export const pamAccountServiceFactory = ({ encryptedCredentials, name, description, - folderId + folderId, + rotationEnabled, + rotationIntervalSeconds }); return { ...(await decryptAccount(account, resource.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; } catch (err) { if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { @@ -145,7 +176,7 @@ export const pamAccountServiceFactory = ({ }; const updateById = async ( - { accountId, credentials, description, name }: TUpdateAccountDTO, + { accountId, credentials, description, name, rotationEnabled, rotationIntervalSeconds }: TUpdateAccountDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -195,6 +226,17 @@ export const pamAccountServiceFactory = ({ updateDoc.description = description; } + if (rotationEnabled !== undefined) { + if (!resource.encryptedRotationAccountCredentials && rotationEnabled) { + throw new NotFoundError({ message: "Rotation credentials are not configured for this account's resource" }); + } + updateDoc.rotationEnabled = rotationEnabled; + } + + if (rotationIntervalSeconds !== undefined) { + updateDoc.rotationIntervalSeconds = rotationIntervalSeconds; + } + if (credentials !== undefined) { const connectionDetails = await decryptResourceConnectionDetails({ projectId: account.projectId, @@ -211,7 +253,7 @@ export const pamAccountServiceFactory = ({ // Logic to prevent overwriting unedited censored values const finalCredentials = { ...credentials }; - if (credentials.password === "******") { + if (credentials.password === "__INFISICAL_UNCHANGED__") { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, projectId: account.projectId, @@ -239,7 +281,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(updatedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; @@ -278,7 +325,12 @@ export const pamAccountServiceFactory = ({ return { ...(await decryptAccount(deletedAccount, account.projectId, kmsService)), - resource: { id: resource.id, name: resource.name, resourceType: resource.resourceType } + resource: { + id: resource.id, + name: resource.name, + resourceType: resource.resourceType, + rotationCredentialsConfigured: !!resource.encryptedRotationAccountCredentials + } }; }; @@ -300,8 +352,9 @@ export const pamAccountServiceFactory = ({ const decryptedAndPermittedAccounts: Array< TPamAccounts & { - resource: Pick; + resource: Pick & { rotationCredentialsConfigured: boolean }; credentials: TPamAccountCredentials; + lastRotationMessage: string | null; } > = []; @@ -325,12 +378,14 @@ export const pamAccountServiceFactory = ({ ) { // Decrypt the account only if the user has permission to read it const decryptedAccount = await decryptAccount(account, account.projectId, kmsService); + decryptedAndPermittedAccounts.push({ ...decryptedAccount, resource: { id: account.resource.id, name: account.resource.name, - resourceType: account.resource.resourceType + resourceType: account.resource.resourceType, + rotationCredentialsConfigured: !!account.resource.encryptedRotationAccountCredentials } }); } @@ -517,12 +572,131 @@ export const pamAccountServiceFactory = ({ }; }; + const rotateAllDueAccounts = async () => { + const accounts = await pamAccountDAL.findAccountsDueForRotation(); + + for (let i = 0; i < accounts.length; i += ROTATION_CONCURRENCY_LIMIT) { + const batch = accounts.slice(i, i + ROTATION_CONCURRENCY_LIMIT); + + const rotationPromises = batch.map(async (account) => { + let logResourceType = "unknown"; + try { + await pamAccountDAL.transaction(async (tx) => { + const resource = await pamResourceDAL.findById(account.resourceId, tx); + if (!resource || !resource.encryptedRotationAccountCredentials) return; + logResourceType = resource.resourceType; + + const { connectionDetails, rotationAccountCredentials, gatewayId, resourceType } = await decryptResource( + resource, + account.projectId, + kmsService + ); + + if (!rotationAccountCredentials) return; + + const accountCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); + + const factory = PAM_RESOURCE_FACTORY_MAP[resourceType as PamResource]( + resourceType as PamResource, + connectionDetails, + gatewayId, + gatewayV2Service + ); + + const newCredentials = await factory.rotateAccountCredentials( + rotationAccountCredentials, + accountCredentials + ); + + const encryptedCredentials = await encryptAccountCredentials({ + credentials: newCredentials, + projectId: account.projectId, + kmsService + }); + + await pamAccountDAL.updateById( + account.id, + { + encryptedCredentials, + lastRotatedAt: new Date(), + rotationStatus: "success", + encryptedLastRotationMessage: null + }, + tx + ); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: resource.id, + resourceType: logResourceType + } + } + }); + }); + } catch (error) { + logger.error(error, `Failed to rotate credentials for account [accountId=${account.id}]`); + + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: account.projectId + }); + + const { cipherTextBlob: encryptedMessage } = encryptor({ + plainText: Buffer.from(errorMessage) + }); + + await pamAccountDAL.updateById(account.id, { + rotationStatus: "failed", + encryptedLastRotationMessage: encryptedMessage + }); + + await auditLogService.createAuditLog({ + projectId: account.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.PAM_ACCOUNT_CREDENTIAL_ROTATION_FAILED, + metadata: { + accountId: account.id, + accountName: account.name, + resourceId: account.resourceId, + resourceType: logResourceType, + errorMessage + } + } + }); + } + }); + + // eslint-disable-next-line no-await-in-loop + await Promise.all(rotationPromises); + } + }; + return { create, updateById, deleteById, list, access, - getSessionCredentials + getSessionCredentials, + rotateAllDueAccounts }; }; diff --git a/backend/src/ee/services/pam-account/pam-account-types.ts b/backend/src/ee/services/pam-account/pam-account-types.ts index 514d7d780..4bbccc6fa 100644 --- a/backend/src/ee/services/pam-account/pam-account-types.ts +++ b/backend/src/ee/services/pam-account/pam-account-types.ts @@ -1,7 +1,10 @@ import { TPamAccount } from "../pam-resource/pam-resource-types"; // DTOs -export type TCreateAccountDTO = Pick; +export type TCreateAccountDTO = Pick< + TPamAccount, + "name" | "description" | "credentials" | "folderId" | "resourceId" | "rotationEnabled" | "rotationIntervalSeconds" +>; export type TUpdateAccountDTO = Partial> & { accountId: string; diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts new file mode 100644 index 000000000..4010d208b --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts @@ -0,0 +1,8 @@ +import { MySQLResourceListItemSchema } from "./mysql-resource-schemas"; + +export const getMySQLResourceListItem = () => { + return { + name: MySQLResourceListItemSchema.shape.name.value, + resource: MySQLResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts new file mode 100644 index 000000000..8d3589a8a --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const MySQLResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema.extend({ + // MySQL db in many cases the db will not be provided when making connection + database: z.string().trim() +}); +export const MySQLAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +const BaseMySQLResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.MySQL) }); + +export const MySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedMySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + +export const MySQLResourceListItemSchema = z.object({ + name: z.literal("MySQL"), + resource: z.literal(PamResource.MySQL) +}); + +export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const MySQLAccountSchema = BasePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const CreateMySQLAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const UpdateMySQLAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema.optional() +}); + +export const SanitizedMySQLAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const MySQLSessionCredentialsSchema = MySQLResourceConnectionDetailsSchema.and(MySQLAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts new file mode 100644 index 000000000..43c2eea51 --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + MySQLAccountCredentialsSchema, + MySQLAccountSchema, + MySQLResourceConnectionDetailsSchema, + MySQLResourceSchema +} from "./mysql-resource-schemas"; + +// Resources +export type TMySQLResource = z.infer; +export type TMySQLResourceConnectionDetails = z.infer; + +// Accounts +export type TMySQLAccount = z.infer; +export type TMySQLAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index fbc260fba..dff1cc650 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,3 +1,4 @@ export enum PamResource { - Postgres = "postgres" + Postgres = "postgres", + MySQL = "mysql" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 298b1664c..151fa7ea1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -5,5 +5,6 @@ import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; type TPamResourceFactoryImplementation = TPamResourceFactory; export const PAM_RESOURCE_FACTORY_MAP: Record = { - [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation + [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, + [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index 1d79e892e..cad087d2f 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -2,11 +2,13 @@ import { TPamResources } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { - return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); }; // Resource @@ -63,6 +65,13 @@ export const decryptResource = async ( encryptedConnectionDetails: resource.encryptedConnectionDetails, projectId, kmsService - }) + }), + rotationAccountCredentials: resource.encryptedRotationAccountCredentials + ? await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId, + kmsService + }) + : null } as TPamResource; }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts index 80a50a9a4..17ed1ccd1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-schemas.ts @@ -6,6 +6,7 @@ import { slugSchema } from "@app/server/lib/schemas"; // Resources export const BasePamResourceSchema = PamResourcesSchema.omit({ encryptedConnectionDetails: true, + encryptedRotationAccountCredentials: true, resourceType: true }); @@ -30,17 +31,25 @@ export const BasePamAccountSchemaWithResource = BasePamAccountSchema.extend({ id: true, name: true, resourceType: true - }) + }).extend({ + rotationCredentialsConfigured: z.boolean() + }), + lastRotationMessage: z.string().nullable().optional(), + rotationStatus: z.string().nullable().optional() }); export const BaseCreatePamAccountSchema = z.object({ resourceId: z.string().uuid(), folderId: z.string().uuid().optional(), name: slugSchema({ field: "name" }), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); export const BaseUpdatePamAccountSchema = z.object({ name: slugSchema({ field: "name" }).optional(), - description: z.string().max(512).nullable().optional() + description: z.string().max(512).nullable().optional(), + rotationEnabled: z.boolean().optional(), + rotationIntervalSeconds: z.number().min(3600).nullable().optional() }); diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index 312795a50..d97905dbe 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -10,10 +10,16 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; +import { decryptAccountCredentials, encryptAccountCredentials } from "../pam-account/pam-account-fns"; import { TPamResourceDALFactory } from "./pam-resource-dal"; import { PamResource } from "./pam-resource-enums"; import { PAM_RESOURCE_FACTORY_MAP } from "./pam-resource-factory"; -import { decryptResource, encryptResourceConnectionDetails, listResourceOptions } from "./pam-resource-fns"; +import { + decryptResource, + decryptResourceConnectionDetails, + encryptResourceConnectionDetails, + listResourceOptions +} from "./pam-resource-fns"; import { TCreateResourceDTO, TUpdateResourceDTO } from "./pam-resource-types"; type TPamResourceServiceFactoryDep = { @@ -61,7 +67,7 @@ export const pamResourceServiceFactory = ({ }; const create = async ( - { resourceType, connectionDetails, gatewayId, name, projectId }: TCreateResourceDTO, + { resourceType, connectionDetails, gatewayId, name, projectId, rotationAccountCredentials }: TCreateResourceDTO, actor: OrgServiceActor ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); @@ -88,26 +94,42 @@ export const pamResourceServiceFactory = ({ gatewayId, gatewayV2Service ); - const validatedConnectionDetails = await factory.validateConnection(); + const validatedConnectionDetails = await factory.validateConnection(); const encryptedConnectionDetails = await encryptResourceConnectionDetails({ connectionDetails: validatedConnectionDetails, projectId, kmsService }); + let encryptedRotationAccountCredentials: Buffer | null = null; + + if (rotationAccountCredentials) { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(rotationAccountCredentials); + + encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId, + kmsService + }); + } + const resource = await pamResourceDAL.create({ resourceType, encryptedConnectionDetails, gatewayId, name, - projectId + projectId, + encryptedRotationAccountCredentials }); return decryptResource(resource, projectId, kmsService); }; - const updateById = async ({ connectionDetails, resourceId, name }: TUpdateResourceDTO, actor: OrgServiceActor) => { + const updateById = async ( + { connectionDetails, resourceId, name, rotationAccountCredentials }: TUpdateResourceDTO, + actor: OrgServiceActor + ) => { const orgLicensePlan = await licenseService.getPlan(actor.orgId); if (!orgLicensePlan.pam) { throw new BadRequestError({ @@ -151,6 +173,60 @@ export const pamResourceServiceFactory = ({ updateDoc.encryptedConnectionDetails = encryptedConnectionDetails; } + if (rotationAccountCredentials !== undefined) { + updateDoc.encryptedRotationAccountCredentials = null; + + if (rotationAccountCredentials) { + const decryptedConnectionDetails = + connectionDetails ?? + (await decryptResourceConnectionDetails({ + encryptedConnectionDetails: resource.encryptedConnectionDetails, + projectId: resource.projectId, + kmsService + })); + + const factory = PAM_RESOURCE_FACTORY_MAP[resource.resourceType as PamResource]( + resource.resourceType as PamResource, + decryptedConnectionDetails, + resource.gatewayId, + gatewayV2Service + ); + + // Logic to prevent overwriting unedited censored values + const finalCredentials = { ...rotationAccountCredentials }; + if ( + resource.encryptedRotationAccountCredentials && + rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" + ) { + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: resource.encryptedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + + finalCredentials.password = decryptedCredentials.password; + } + + try { + const validatedRotationAccountCredentials = await factory.validateAccountCredentials(finalCredentials); + + updateDoc.encryptedRotationAccountCredentials = await encryptAccountCredentials({ + credentials: validatedRotationAccountCredentials, + projectId: resource.projectId, + kmsService + }); + } catch (err) { + if (err instanceof BadRequestError) { + throw new BadRequestError({ + message: `Rotation Account Error: ${err.message}` + }); + } + + throw err; + } + } + } + // If nothing was updated, return the fetched resource if (Object.keys(updateDoc).length === 0) { return decryptResource(resource, resource.projectId, kmsService); diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index fb1b669ed..1ca9db3e2 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,4 +1,10 @@ import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TMySQLAccount, + TMySQLAccountCredentials, + TMySQLResource, + TMySQLResourceConnectionDetails +} from "./mysql/mysql-resource-types"; import { PamResource } from "./pam-resource-enums"; import { TPostgresAccount, @@ -8,17 +14,18 @@ import { } from "./postgres/postgres-resource-types"; // Resource types -export type TPamResource = TPostgresResource; -export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails; +export type TPamResource = TPostgresResource | TMySQLResource; +export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount; -export type TPamAccountCredentials = TPostgresAccountCredentials; +export type TPamAccount = TPostgresAccount | TMySQLAccount; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< TPamResource, - "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" + "name" | "connectionDetails" | "resourceType" | "gatewayId" | "projectId" | "rotationAccountCredentials" >; export type TUpdateResourceDTO = Partial> & { @@ -30,6 +37,10 @@ export type TPamResourceFactoryValidateConnection = ( credentials: C ) => Promise; +export type TPamResourceFactoryRotateAccountCredentials = ( + rotationAccountCredentials: C, + currentCredentials: C +) => Promise; export type TPamResourceFactory = ( resourceType: PamResource, @@ -39,4 +50,5 @@ export type TPamResourceFactory { validateConnection: TPamResourceFactoryValidateConnection; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; + rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; }; diff --git a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts index a97e3f2e7..bbe83a3a4 100644 --- a/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/postgres/postgres-resource-schemas.ts @@ -15,13 +15,24 @@ import { BaseSqlResourceConnectionDetailsSchema } from "../shared/sql/sql-resource-schemas"; -// Resources export const PostgresResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema; +export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; +// Resources const BasePostgresResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.Postgres) }); export const PostgresResourceSchema = BasePostgresResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedPostgresResourceSchema = BasePostgresResourceSchema.extend({ + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() }); export const PostgresResourceListItemSchema = z.object({ @@ -30,16 +41,16 @@ export const PostgresResourceListItemSchema = z.object({ }); export const CreatePostgresResourceSchema = BaseCreatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema + connectionDetails: PostgresResourceConnectionDetailsSchema, + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); export const UpdatePostgresResourceSchema = BaseUpdatePamResourceSchema.extend({ - connectionDetails: PostgresResourceConnectionDetailsSchema.optional() + connectionDetails: PostgresResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: PostgresAccountCredentialsSchema.nullable().optional() }); // Accounts -export const PostgresAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; - export const PostgresAccountSchema = BasePamAccountSchema.extend({ credentials: PostgresAccountCredentialsSchema }); 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 74a2c74ae..99ce2d25f 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 @@ -1,4 +1,6 @@ -import knex, { Knex } from "knex"; +import knex from "knex"; +import mysql, { Connection } from "mysql2/promise"; +import * as pg from "pg"; import tls, { PeerCertificate } from "tls"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; @@ -6,39 +8,176 @@ import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2 import { BadRequestError } from "@app/lib/errors"; import { GatewayProxyProtocol } from "@app/lib/gateway"; import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; import { PamResource } from "../../pam-resource-enums"; -import { TPamResourceFactory, TPamResourceFactoryValidateAccountCredentials } from "../../pam-resource-types"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../../pam-resource-types"; import { TSqlAccountCredentials, TSqlResourceConnectionDetails } from "./sql-resource-types"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test"; const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password"; +const SIMPLE_QUERY = "select 1"; -const SQL_CONNECTION_CLIENT_MAP = { - [PamResource.Postgres]: "pg" -}; +export interface SqlResourceConnection { + /** + * Check and see if the connection is good or not. + * + * @param connectOnly when true, if we only want to know that making the connection is possible or not, + * we don't care about authentication failures + * @returns Promise to be resolved when the connection is good, otherwise an error will be errbacked + */ + validate: (connectOnly: boolean) => Promise; -const getConnectionConfig = ( - resourceType: PamResource, - { host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails -) => { - switch (resourceType) { + /** + * Rotate password and return the new credentials. + * + * @param currentCredentials the current credentials to rotate + * + * @returns Promise to be resolved with the new credentials + */ + rotateCredentials: ( + currentCredentials: TSqlAccountCredentials, + newPassword: string + ) => Promise; + + /** + * Close the connection. + * + * @returns Promise for closing the connection + */ + close: () => Promise; +} + +const makeSqlConnection = ( + proxyPort: number, + config: { + connectionDetails: TSqlResourceConnectionDetails; + resourceType: PamResource; + username?: string; + password?: string; + } +): SqlResourceConnection => { + const { connectionDetails, resourceType, username, password } = config; + const { host, sslEnabled, sslRejectUnauthorized, sslCertificate } = connectionDetails; + const actualUsername = username ?? TEST_CONNECTION_USERNAME; // Use provided username or fallback + const actualPassword = password ?? TEST_CONNECTION_PASSWORD; // Use provided password or fallback + switch (config.resourceType) { case PamResource.Postgres: { + const client = knex({ + client: "pg", + connection: { + host: "localhost", + port: proxyPort, + user: actualUsername, + password: actualPassword, + database: connectionDetails.database, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host, + // When using proxy, we need to bypass hostname validation since we connect to localhost + // but validate the certificate against the actual hostname + checkServerIdentity: (hostname: string, cert: PeerCertificate) => { + return tls.checkServerIdentity(host, cert); + } + } + : false + } + }); return { - ssl: sslEnabled - ? { - rejectUnauthorized: sslRejectUnauthorized, - ca: sslCertificate, - servername: host, - // When using proxy, we need to bypass hostname validation since we connect to localhost - // but validate the certificate against the actual hostname - checkServerIdentity: (hostname: string, cert: PeerCertificate) => { - return tls.checkServerIdentity(host, cert); + validate: async (connectOnly) => { + try { + await client.raw(SIMPLE_QUERY); + } catch (error) { + if (error instanceof pg.DatabaseError) { + // Hacky way to know if we successfully hit the database. + // TODO: potentially two approaches to solve the problem. + // 1. change the work flow, add account first then resource + // 2. modify relay to add a new endpoint for returning if the target host is healthy or not + // (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")) + ) { + return; } } - : false + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }, + rotateCredentials: async (currentCredentials, newPassword) => { + // Note: The generated random password is not really going to make SQL Injection possible. + // The reason we are not using parameters binding is that the "ALTER USER" syntax is DDL, + // parameters binding is not supported. But just in case if the this code got copied + // around and repurposed, let's just do some naive escaping regardless + await client.raw(`ALTER USER :username: WITH PASSWORD '${newPassword.replace(/'/g, "''")}'`, { + username: currentCredentials.username + }); + return { username: currentCredentials.username, password: newPassword }; + }, + close: () => client.destroy() + }; + } + case PamResource.MySQL: { + return { + validate: async (connectOnly) => { + let client: Connection | null = null; + try { + // Notice: the reason we are not using Knex for mysql2 is because we don't need any fancy feature from Knex. + // mysql2 doesn't provide custom ssl verification function pass in. + // ref: https://github.com/sidorares/node-mysql2/blob/2543272a2ada8d8a07f74582549d7dd3fe948e2d/lib/base/connection.js#L358-L362 + // and then even I tried to workaround it with Knex's pool afterCreate hook, but then encounter a bug: + // ref: https://github.com/knex/knex/issues/5352 + // It appears that using Knex causing more troubles than not, we are just checking the connections, + // so it's much easier to create raw connection with the driver lib directly + client = await mysql.createConnection({ + host: "localhost", + port: proxyPort, + user: actualUsername, // Use provided username or fallback + password: actualPassword, // Use provided password or fallback + database: connectionDetails.database, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate + } + : undefined + }); + await client.query(SIMPLE_QUERY); + } catch (error) { + if (connectOnly) { + // Hacky way to know if we successfully hit the database. + if ( + error instanceof Error && + error.message.startsWith(`Access denied for user '${TEST_CONNECTION_USERNAME}'@`) + ) { + return; + } + } + // TODO: handle other errors, and throw standardlized errors providing user-friendly msg + throw error; + } finally { + await client?.end(); + } + }, + rotateCredentials: async () => { + // TODO: the pwd rotation for MySQL is not supported yet + throw new BadRequestError({ + message: "Unsupported operation" + }); + }, + close: async () => {} }; } default: @@ -57,10 +196,9 @@ export const executeWithGateway = async ( password?: string; }, gatewayV2Service: Pick, - operation: (client: Knex) => Promise + operation: (connection: SqlResourceConnection) => Promise ): Promise => { - const { connectionDetails, resourceType, gatewayId, username, password } = config; - + const { connectionDetails, gatewayId } = config; const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ gatewayId, @@ -74,22 +212,11 @@ export const executeWithGateway = async ( return withGatewayV2Proxy( async (proxyPort) => { - const client = knex({ - client: SQL_CONNECTION_CLIENT_MAP[resourceType], - connection: { - database: connectionDetails.database, - port: proxyPort, - host: "localhost", - user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback - password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback - connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, - ...getConnectionConfig(resourceType, connectionDetails) - } - }); + const connection = makeSqlConnection(proxyPort, config); try { - return await operation(client); + return await operation(connection); } finally { - await client.destroy(); + await connection.close(); } }, { @@ -110,25 +237,14 @@ export const sqlResourceFactory: TPamResourceFactory { try { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { - await client.raw("Select 1"); + await client.validate(true); }); return connectionDetails; } catch (error) { - // Hacky way to know if we successfully hit the database - if (error instanceof BadRequestError) { - if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) { - return connectionDetails; - } - - if (error.message.includes("no pg_hba.conf entry for host")) { - return connectionDetails; - } - - if (error.message === "Connection terminated unexpectedly") { - throw new BadRequestError({ - message: "Connection terminated unexpectedly. Verify that host and port are correct" - }); - } + if (error instanceof BadRequestError && error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); } throw new BadRequestError({ @@ -151,11 +267,12 @@ export const sqlResourceFactory: TPamResourceFactory { - await client.raw("Select 1"); + await client.validate(false); } ); return credentials; } catch (error) { + // TODO: extract these logic into each SQL connection if (error instanceof BadRequestError) { if (error.message === `password authentication failed for user "${credentials.username}"`) { throw new BadRequestError({ @@ -176,8 +293,55 @@ export const sqlResourceFactory: TPamResourceFactory = async ( + rotationAccountCredentials, + currentCredentials + ) => { + const newPassword = alphaNumericNanoId(32); + try { + return await executeWithGateway( + { + connectionDetails, + gatewayId, + resourceType, + username: rotationAccountCredentials.username, + password: rotationAccountCredentials.password + }, + gatewayV2Service, + (client) => client.rotateCredentials(currentCredentials, newPassword) + ); + } catch (error) { + if (error instanceof BadRequestError) { + if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) { + throw new BadRequestError({ + message: "Management credentials invalid: Username or password incorrect" + }); + } + + if (error.message.includes("permission denied")) { + throw new BadRequestError({ + message: `Management credentials lack permission to rotate password for user "${currentCredentials.username}"` + }); + } + + if (error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); + } + } + + const sanitizedErrorMessage = ((error as Error).message || String(error)).replaceAll(newPassword, "REDACTED"); + + throw new BadRequestError({ + message: `Unable to rotate account credentials for ${resourceType}: ${sanitizedErrorMessage}` + }); + } + }; + return { validateConnection, - validateAccountCredentials + validateAccountCredentials, + rotateAccountCredentials }; }; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts index cb3abf109..96b6a6a24 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-schemas.ts @@ -16,6 +16,6 @@ export const BaseSqlResourceConnectionDetailsSchema = z.object({ // Accounts export const BaseSqlAccountCredentialsSchema = z.object({ - username: z.string().trim().min(1), - password: z.string().trim().min(1) + username: z.string().trim().min(1).max(63), + password: z.string().trim().min(1).max(256) }); diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts index f56a2a3dc..a9ff49013 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts @@ -1,7 +1,9 @@ +import { TMySQLAccountCredentials, TMySQLResourceConnectionDetails } from "../../mysql/mysql-resource-types"; import { TPostgresAccountCredentials, TPostgresResourceConnectionDetails } from "../../postgres/postgres-resource-types"; -export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails; -export type TSqlAccountCredentials = TPostgresAccountCredentials; +export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TSqlAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 48b78d980..b71e63c10 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -337,6 +337,12 @@ export const permissionServiceFactory = ({ throw new NotFoundError({ message: `Project with ${projectId} not found` }); } + requestContext.set("projectDetails", { + id: projectDetails.id, + name: projectDetails.name, + slug: projectDetails.slug + }); + if (projectDetails.orgId !== actorOrgId) { throw new ForbiddenRequestError({ name: "You are not logged into this organization" }); } diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index 13b862343..7206bd293 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -84,7 +84,7 @@ type TSamlConfigServiceFactoryDep = { projectDAL: Pick; projectBotDAL: Pick; projectKeyDAL: Pick; - membershipGroupDAL: Pick; + membershipGroupDAL: Pick; }; export const samlConfigServiceFactory = ({ @@ -183,6 +183,22 @@ export const samlConfigServiceFactory = ({ transaction ); orgGroupsMap.set(groupName, newGroup); + const orgMembership = await membershipGroupDAL.create( + { + actorGroupId: newGroup.id, + scope: AccessScope.Organization, + scopeOrgId: orgId + }, + transaction + ); + await membershipRoleDAL.create( + { + membershipId: orgMembership.id, + role: OrgMembershipRole.NoAccess, + customRoleId: null + }, + transaction + ); } } @@ -753,7 +769,7 @@ export const samlConfigServiceFactory = ({ }); } - return { isUserCompleted, providerAuthToken }; + return { isUserCompleted, providerAuthToken, user, organization }; }; return { diff --git a/backend/src/ee/services/saml-config/saml-config-types.ts b/backend/src/ee/services/saml-config/saml-config-types.ts index 983ec4db7..5ca3e09fb 100644 --- a/backend/src/ee/services/saml-config/saml-config-types.ts +++ b/backend/src/ee/services/saml-config/saml-config-types.ts @@ -1,4 +1,4 @@ -import { TSamlConfigs } from "@app/db/schemas"; +import { TOrganizations, TSamlConfigs, TUsers } from "@app/db/schemas"; import { TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; @@ -78,5 +78,7 @@ export type TSamlConfigServiceFactory = { samlLogin: (arg: TSamlLoginDTO) => Promise<{ isUserCompleted: boolean; providerAuthToken: string; + user: TUsers; + organization: TOrganizations; }>; }; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index affec39ad..a10a3f568 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -1517,7 +1517,7 @@ export const secretApprovalRequestServiceFactory = ({ })) ); if (secrets.length) - throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` }); + throw new BadRequestError({ message: `Secret already exists: ${secrets.map((el) => el.key).join(",")}` }); commits.push( ...createdSecrets.map((createdSecret) => ({ diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index dde9b4e35..6b032c6f0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2348,6 +2348,9 @@ export const AppConnections = { RAILWAY: { apiToken: "The API token used to authenticate with Railway." }, + NORTHFLANK: { + apiToken: "The API token used to authenticate with Northflank." + }, CHECKLY: { apiKey: "The API key used to authenticate with Checkly." }, @@ -2620,6 +2623,12 @@ export const SecretSyncs = { siteName: "The name of the Netlify site to sync secrets to.", siteId: "The ID of the Netlify site to sync secrets to.", context: "The Netlify context to sync secrets to." + }, + NORTHFLANK: { + projectId: "The ID of the Northflank project to sync secrets to.", + projectName: "The name of the Northflank project to sync secrets to.", + secretGroupId: "The ID of the Northflank secret group to sync secrets to.", + secretGroupName: "The name of the Northflank secret group to sync secrets to." } } }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 15f878323..9fc4cff92 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -1,7 +1,9 @@ import { z } from "zod"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { crypto } from "@app/lib/crypto/cryptography"; import { QueueWorkerProfile } from "@app/lib/types"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { BadRequestError } from "../errors"; @@ -363,11 +365,6 @@ const envSchema = z /* INTERNAL ----------------------------------------------------------------------------- */ INTERNAL_REGION: zpStr(z.enum(["us", "eu"]).optional()) }) - // To ensure that basic encryption is always possible. - .refine( - (data) => Boolean(data.ENCRYPTION_KEY) || Boolean(data.ROOT_ENCRYPTION_KEY), - "Either ENCRYPTION_KEY or ROOT_ENCRYPTION_KEY must be defined." - ) .refine( (data) => Boolean(data.REDIS_URL) || Boolean(data.REDIS_SENTINEL_HOSTS) || Boolean(data.REDIS_CLUSTER_HOSTS), "Either REDIS_URL, REDIS_SENTINEL_HOSTS or REDIS_CLUSTER_HOSTS must be defined." @@ -453,7 +450,12 @@ export const getConfig = () => envCfg; export const getOriginalConfig = () => originalEnvConfig; // cannot import singleton logger directly as it needs config to load various transport -export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logger?: CustomLogger) => { +export const initEnvConfig = async ( + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + superAdminDAL?: TSuperAdminDALFactory, + logger?: CustomLogger +) => { const parsedEnv = envSchema.safeParse(process.env); if (!parsedEnv.success) { (logger ?? console).error("Invalid environment variables. Check the error below"); @@ -469,7 +471,7 @@ export const initEnvConfig = async (superAdminDAL?: TSuperAdminDALFactory, logge } if (superAdminDAL) { - const fipsEnabled = await crypto.initialize(superAdminDAL); + const fipsEnabled = await crypto.initialize(superAdminDAL, hsmService, kmsRootConfigDAL); if (fipsEnabled) { const newEnvCfg = { @@ -532,6 +534,22 @@ export const getDatabaseCredentials = (logger?: CustomLogger) => { }; }; +export const getHsmConfig = (logger?: CustomLogger) => { + const parsedEnv = envSchema.safeParse(process.env); + if (!parsedEnv.success) { + (logger ?? console).error("Invalid environment variables. Check the error below"); + (logger ?? console).error(parsedEnv.error.issues); + process.exit(-1); + } + return { + isHsmConfigured: parsedEnv.data.isHsmConfigured, + HSM_PIN: parsedEnv.data.HSM_PIN, + HSM_SLOT: parsedEnv.data.HSM_SLOT, + HSM_LIB_PATH: parsedEnv.data.HSM_LIB_PATH, + HSM_KEY_LABEL: parsedEnv.data.HSM_KEY_LABEL + }; +}; + // A list of environment variables that can be overwritten export const overwriteSchema: { [key: string]: { diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index 45c7a1986..6e2a15740 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -9,7 +9,11 @@ import nacl from "tweetnacl"; import naclUtils from "tweetnacl-util"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; import { ADMIN_CONFIG_DB_UUID } from "@app/services/super-admin/super-admin-service"; @@ -106,49 +110,73 @@ const cryptographyFactory = () => { } }; - const $setFipsModeEnabled = (enabled: boolean, envCfg?: Pick) => { + const $setFipsModeEnabled = async ( + enabled: boolean, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { // If FIPS is enabled, we need to validate that the ENCRYPTION_KEY is in a base64 format, and is a 256-bit key. if (enabled) { crypto.setFips(true); const appCfg = envCfg || getConfig(); - if (appCfg.ENCRYPTION_KEY) { - // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL + }); - // note(daniel): for some reason this resolves as true for some hex-encoded strings. - if (!isBase64(appCfg.ENCRYPTION_KEY)) { + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + // only perform encryption key validation if it's actually required. + if (needsEncryptionKey) { + if (appCfg.ENCRYPTION_KEY) { + // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key + + // note(daniel): for some reason this resolves as true for some hex-encoded strings. + if (!isBase64(appCfg.ENCRYPTION_KEY)) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + + if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { + throw new CryptographyError({ + message: + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + }); + } + } else { throw new CryptographyError({ message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a base64 encoded 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" + "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" }); } - - if (bytesToBits(Buffer.from(appCfg.ENCRYPTION_KEY, "base64").length) !== 256) { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not a 256-bit key.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); - } - } else { - throw new CryptographyError({ - message: - "FIPS mode is enabled, but the ENCRYPTION_KEY environment variable is not set.\nYou can generate a 256-bit key using the following command: `openssl rand -base64 32`" - }); } } $fipsEnabled = enabled; $isInitialized = true; }; - const initialize = async (superAdminDAL: TSuperAdminDALFactory, envCfg?: Pick) => { + const initialize = async ( + superAdminDAL: TSuperAdminDALFactory, + hsmService: THsmServiceFactory, + kmsRootConfigDAL: TKmsRootConfigDALFactory, + envCfg?: Pick + ) => { if ($isInitialized) { return isFipsModeEnabled(); } if (process.env.FIPS_ENABLED !== "true") { logger.info("Cryptography module initialized in normal operation mode."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -158,11 +186,11 @@ const cryptographyFactory = () => { if (serverCfg) { if (serverCfg.fipsEnabled) { logger.info("[FIPS]: Instance is configured for FIPS mode of operation. Continuing startup with FIPS enabled."); - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; } logger.info("[FIPS]: Instance age predates FIPS mode inception date. Continuing without FIPS."); - $setFipsModeEnabled(false, envCfg); + await $setFipsModeEnabled(false, hsmService, kmsRootConfigDAL, envCfg); return false; } @@ -171,7 +199,7 @@ const cryptographyFactory = () => { // TODO(daniel): check if it's an enterprise deployment // if there is no server cfg, and FIPS_MODE is `true`, its a fresh FIPS deployment. We need to set the fipsEnabled to true. - $setFipsModeEnabled(true, envCfg); + await $setFipsModeEnabled(true, hsmService, kmsRootConfigDAL, envCfg); return true; }; @@ -258,6 +286,13 @@ const cryptographyFactory = () => { const rootEncryptionKey = appCfg.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg.ENCRYPTION_KEY; + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to encrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey) { const { iv, tag, ciphertext } = encrypt({ plaintext: data, @@ -303,6 +338,14 @@ const cryptographyFactory = () => { // the or gate is used used in migration const rootEncryptionKey = appCfg?.ROOT_ENCRYPTION_KEY || process.env.ROOT_ENCRYPTION_KEY; const encryptionKey = appCfg?.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY; + + // Sanity check + if (!rootEncryptionKey && !encryptionKey) { + throw new CryptographyError({ + message: "Tried to decrypt with instance root encryption key, but no root encryption key is set." + }); + } + if (rootEncryptionKey && keyEncoding === SecretKeyEncoding.BASE64) { const data = symmetric().decrypt({ key: rootEncryptionKey, diff --git a/backend/src/lib/gateway-v2/gateway-v2.ts b/backend/src/lib/gateway-v2/gateway-v2.ts index 5ae0e5b1d..46abe4bf1 100644 --- a/backend/src/lib/gateway-v2/gateway-v2.ts +++ b/backend/src/lib/gateway-v2/gateway-v2.ts @@ -7,6 +7,7 @@ import https from "https"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { splitPemChain } from "@app/services/certificate/certificate-fns"; +import { getConfig } from "../config/env"; import { BadRequestError } from "../errors"; import { GatewayProxyProtocol } from "../gateway/types"; import { logger } from "../logger"; @@ -80,6 +81,8 @@ const createGatewayConnection = async ( gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }, protocol: GatewayProxyProtocol ): Promise => { + const appCfg = getConfig(); + const protocolToAlpn = { [GatewayProxyProtocol.Http]: "infisical-http-proxy", [GatewayProxyProtocol.Tcp]: "infisical-tcp-proxy", @@ -94,7 +97,8 @@ const createGatewayConnection = async ( minVersion: "TLSv1.2", maxVersion: "TLSv1.3", rejectUnauthorized: true, - ALPNProtocols: [protocolToAlpn[protocol]] + ALPNProtocols: [protocolToAlpn[protocol]], + checkServerIdentity: appCfg.isDevelopmentMode ? () => undefined : tls.checkServerIdentity }; return new Promise((resolve, reject) => { diff --git a/backend/src/lib/telemetry/metrics.ts b/backend/src/lib/telemetry/metrics.ts new file mode 100644 index 000000000..5f650ffc6 --- /dev/null +++ b/backend/src/lib/telemetry/metrics.ts @@ -0,0 +1,100 @@ +import { requestContext } from "@fastify/request-context"; +import opentelemetry from "@opentelemetry/api"; + +import { getConfig } from "../config/env"; + +const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + +export enum AuthAttemptAuthMethod { + EMAIL = "email", + SAML = "saml", + OIDC = "oidc", + GOOGLE = "google", + GITHUB = "github", + GITLAB = "gitlab", + TOKEN_AUTH = "token-auth", + UNIVERSAL_AUTH = "universal-auth", + KUBERNETES_AUTH = "kubernetes-auth", + GCP_AUTH = "gcp-auth", + ALICLOUD_AUTH = "alicloud-auth", + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth", + TLS_CERT_AUTH = "tls-cert-auth", + OCI_AUTH = "oci-auth", + OIDC_AUTH = "oidc-auth", + JWT_AUTH = "jwt-auth", + LDAP_AUTH = "ldap-auth" +} + +export enum AuthAttemptAuthResult { + SUCCESS = "success", + FAILURE = "failure" +} + +export const authAttemptCounter = infisicalMeter.createCounter("infisical.auth.attempt.count", { + description: "Authentication attempts (both successful and failed)", + unit: "{attempt}" +}); + +export const secretReadCounter = infisicalMeter.createCounter("infisical.secret.read.count", { + description: "Number of secret read operations", + unit: "{operation}" +}); + +export const recordSecretReadMetric = (params: { environment: string; secretPath: string; name?: string }) => { + const appCfg = getConfig(); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + const attributes: Record = { + "infisical.environment": params.environment, + "infisical.secret.path": params.secretPath, + ...(params.name ? { "infisical.secret.name": params.name } : {}) + }; + + const orgId = requestContext.get("orgId"); + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + + const orgName = requestContext.get("orgName"); + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + const projectDetails = requestContext.get("projectDetails"); + if (projectDetails?.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails?.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + + const userAuthInfo = requestContext.get("userAuthInfo"); + if (userAuthInfo?.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo?.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + + const identityAuthInfo = requestContext.get("identityAuthInfo"); + if (identityAuthInfo?.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo?.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + + const userAgent = requestContext.get("userAgent"); + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + const ip = requestContext.get("ip"); + if (ip) { + attributes["client.address"] = ip; + } + + secretReadCounter.add(1, attributes); + } +}; diff --git a/backend/src/main.ts b/backend/src/main.ts index 7be9f43ec..400804804 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,14 +9,16 @@ import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { runMigrations } from "./auto-start-migrations"; import { initAuditLogDbConnection, initDbConnection } from "./db"; +import { hsmServiceFactory } from "./ee/services/hsm/hsm-service"; import { keyStoreFactory } from "./keystore/keystore"; -import { formatSmtpConfig, getDatabaseCredentials, initEnvConfig } from "./lib/config/env"; +import { formatSmtpConfig, getDatabaseCredentials, getHsmConfig, initEnvConfig } from "./lib/config/env"; import { buildRedisFromConfig } from "./lib/config/redis"; import { removeTemporaryBaseDirectory } from "./lib/files"; import { initLogger } from "./lib/logger"; import { queueServiceFactory } from "./queue"; import { main } from "./server/app"; import { bootstrapCheck } from "./server/boot-strap-check"; +import { kmsRootConfigDALFactory } from "./services/kms/kms-root-config-dal"; import { smtpServiceFactory } from "./services/smtp/smtp-service"; import { superAdminDALFactory } from "./services/super-admin/super-admin-dal"; @@ -26,6 +28,18 @@ const run = async () => { const logger = initLogger(); await removeTemporaryBaseDirectory(); + const hsmConfig = getHsmConfig(logger); + + const hsmModule = initializeHsmModule(hsmConfig); + hsmModule.initialize(); + + const hsmService = hsmServiceFactory({ + hsmModule: hsmModule.getModule(), + envConfig: hsmConfig + }); + + await hsmService.startService(); + const databaseCredentials = getDatabaseCredentials(logger); const db = initDbConnection({ @@ -35,7 +49,8 @@ const run = async () => { }); const superAdminDAL = superAdminDALFactory(db); - const envConfig = await initEnvConfig(superAdminDAL, logger); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const envConfig = await initEnvConfig(hsmService, kmsRootConfigDAL, superAdminDAL, logger); const auditLogDb = envConfig.AUDIT_LOGS_DB_CONNECTION_URI ? initAuditLogDbConnection({ @@ -59,14 +74,12 @@ const run = async () => { const keyStore = keyStoreFactory(envConfig, keyValueStoreDAL); const redis = buildRedisFromConfig(envConfig); - const hsmModule = initializeHsmModule(envConfig); - hsmModule.initialize(); - const server = await main({ db, auditLogDb, superAdminDAL, - hsmModule: hsmModule.getModule(), + kmsRootConfigDAL, + hsmService, smtp, logger, queue, diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7f45e3821..3e6b1dd19 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -77,7 +77,9 @@ export enum QueueName { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + CertificateV3AutoRenewal = "certificate-v3-auto-renewal", + PamAccountRotation = "pam-account-rotation" } export enum QueueJobs { @@ -126,7 +128,9 @@ export enum QueueJobs { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification-job", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal", + PamAccountRotation = "pam-account-rotation" } export type TQueueJobTypes = { @@ -357,6 +361,14 @@ export type TQueueJobTypes = { name: QueueJobs.HealthAlert; payload: undefined; }; + [QueueName.CertificateV3AutoRenewal]: { + name: QueueJobs.CertificateV3DailyAutoRenewal; + payload: undefined; + }; + [QueueName.PamAccountRotation]: { + name: QueueJobs.PamAccountRotation; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 8cf23f703..60b678f63 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -15,12 +15,13 @@ import fastify from "fastify"; import { Cluster, Redis } from "ioredis"; import { Knex } from "knex"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, IS_PACKAGED, TEnvConfig } from "@app/lib/config/env"; import { CustomLogger } from "@app/lib/logger/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TQueueServiceFactory } from "@app/queue"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { TSmtpService } from "@app/services/smtp/smtp-service"; import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal"; @@ -42,16 +43,16 @@ type TMain = { logger?: CustomLogger; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; - hsmModule: HsmModule; redis: Redis | Cluster; envConfig: TEnvConfig; superAdminDAL: TSuperAdminDALFactory; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; }; // Run the server! export const main = async ({ db, - hsmModule, auditLogDb, smtp, logger, @@ -59,7 +60,9 @@ export const main = async ({ keyStore, redis, envConfig, - superAdminDAL + superAdminDAL, + hsmService, + kmsRootConfigDAL }: TMain) => { const appCfg = getConfig(); @@ -138,7 +141,9 @@ export const main = async ({ await server.register(fastifyRequestContext, { defaultStoreValues: (req) => ({ reqId: req.id, - log: req.log.child({ reqId: req.id }) + log: req.log.child({ reqId: req.id }), + ip: req.realIp, + userAgent: req.headers["user-agent"] }) }); @@ -148,9 +153,10 @@ export const main = async ({ db, auditLogDb, keyStore, - hsmModule, + hsmService, envConfig, - superAdminDAL + superAdminDAL, + kmsRootConfigDAL }); await server.register(registerServeUI, { diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index d0750926d..035e645f4 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -43,6 +43,6 @@ export const GenericResourceNameSchema = z export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(":"), - "Secret name cannot contain colon." -).refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); + (el) => !el.includes(":") && !el.includes("/"), + "Secret name cannot contain colon or forward slash." +); diff --git a/backend/src/server/plugins/api-metrics.ts b/backend/src/server/plugins/api-metrics.ts index 2e3a20a23..4233bc86d 100644 --- a/backend/src/server/plugins/api-metrics.ts +++ b/backend/src/server/plugins/api-metrics.ts @@ -1,12 +1,26 @@ +import { requestContext } from "@fastify/request-context"; import opentelemetry from "@opentelemetry/api"; import fp from "fastify-plugin"; -export const apiMetrics = fp(async (fastify) => { - const apiMeter = opentelemetry.metrics.getMeter("API"); - const latencyHistogram = apiMeter.createHistogram("API_latency", { - unit: "ms" - }); +const apiMeter = opentelemetry.metrics.getMeter("API"); +const latencyHistogram = apiMeter.createHistogram("API_latency", { + unit: "ms" +}); + +const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + +const requestCounter = infisicalMeter.createCounter("infisical.http.server.request.count", { + description: "Total number of API requests to Infisical (covers both human users and machine identities)", + unit: "{request}" +}); + +const requestDurationHistogram = infisicalMeter.createHistogram("infisical.http.server.request.duration", { + description: "API request latency", + unit: "s" +}); + +export const apiMetrics = fp(async (fastify) => { fastify.addHook("onResponse", async (request, reply) => { const { method } = request; const route = request.routerPath; @@ -17,5 +31,67 @@ export const apiMetrics = fp(async (fastify) => { method, statusCode }); + + const orgId = requestContext.get("orgId"); + const orgName = requestContext.get("orgName"); + const userAuthInfo = requestContext.get("userAuthInfo"); + const identityAuthInfo = requestContext.get("identityAuthInfo"); + const projectDetails = requestContext.get("projectDetails"); + const userAgent = requestContext.get("userAgent"); + const ip = requestContext.get("ip"); + + const attributes: Record = { + "http.request.method": method, + "http.route": route, + "http.response.status_code": statusCode + }; + + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + if (userAuthInfo) { + if (userAuthInfo.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + } + + if (identityAuthInfo) { + if (identityAuthInfo.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + if (identityAuthInfo.authMethod) { + attributes["infisical.auth.method"] = identityAuthInfo.authMethod; + } + } + + if (projectDetails) { + if (projectDetails.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + } + + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + if (ip) { + attributes["client.address"] = ip; + } + + requestCounter.add(1, attributes); + requestDurationHistogram.record(reply.elapsedTime / 1000, attributes); }); }); diff --git a/backend/src/server/plugins/auth/inject-identity.ts b/backend/src/server/plugins/auth/inject-identity.ts index b33f2fbe6..31f3139ec 100644 --- a/backend/src/server/plugins/auth/inject-identity.ts +++ b/backend/src/server/plugins/auth/inject-identity.ts @@ -1,4 +1,4 @@ -import { requestContext } from "@fastify/request-context"; +import { requestContext, RequestContextData } from "@fastify/request-context"; import { FastifyRequest } from "fastify"; import fp from "fastify-plugin"; import type { JwtPayload } from "jsonwebtoken"; @@ -138,6 +138,11 @@ export const injectIdentity = fp( return; } + // Authentication is handled on a route-level + if (req.url === "/api/v1/relays/heartbeat-instance-relay") { + return; + } + // Authentication is handled on a route-level here. if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { return; @@ -154,10 +159,11 @@ export const injectIdentity = fp( switch (authMode) { case AuthMode.JWT: { - const { user, tokenVersionId, orgId, rootOrgId, parentOrgId } = + const { user, tokenVersionId, orgId, orgName, rootOrgId, parentOrgId } = await server.services.authToken.fnValidateJwtIdentity(token, subOrganizationSelector); requestContext.set("orgId", orgId); - + requestContext.set("orgName", orgName); + requestContext.set("userAuthInfo", { userId: user.id, email: user.email || "" }); req.auth = { authMode: AuthMode.JWT, user, @@ -181,6 +187,7 @@ export const injectIdentity = fp( ); const serverCfg = await getServerCfg(); requestContext.set("orgId", identity.orgId); + requestContext.set("orgName", identity.orgName); req.auth = { authMode: AuthMode.IDENTITY_ACCESS_TOKEN, actor, @@ -193,24 +200,23 @@ export const injectIdentity = fp( isInstanceAdmin: serverCfg?.adminIdentityIds?.includes(identity.identityId), token }; + const identityAuthInfo: RequestContextData["identityAuthInfo"] = { + identityId: identity.identityId, + identityName: identity.name, + authMethod: identity.authMethod + }; + if (token?.identityAuth?.oidc) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - oidc: token?.identityAuth?.oidc - }); + identityAuthInfo.oidc = token?.identityAuth?.oidc; } if (token?.identityAuth?.kubernetes) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - kubernetes: token?.identityAuth?.kubernetes - }); + identityAuthInfo.kubernetes = token?.identityAuth?.kubernetes; } if (token?.identityAuth?.aws) { - requestContext.set("identityAuthInfo", { - identityId: identity.identityId, - aws: token?.identityAuth?.aws - }); + identityAuthInfo.aws = token?.identityAuth?.aws; } + + requestContext.set("identityAuthInfo", identityAuthInfo); break; } case AuthMode.SERVICE_TOKEN: { diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 62df05eec..8d10a8630 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -1,4 +1,5 @@ import { ForbiddenError, PureAbility } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import opentelemetry from "@opentelemetry/api"; import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; @@ -47,6 +48,12 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider unit: "1" }); + const infisicalMeter = opentelemetry.metrics.getMeter("Infisical"); + const errorCounter = infisicalMeter.createCounter("infisical.http.server.error.count", { + description: "Total number of API errors in Infisical (covers both human users and machine identities)", + unit: "{error}" + }); + server.setErrorHandler((error, req, res) => { req.log.error(error); if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { @@ -61,6 +68,67 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider type: errorType, name: error.name }); + + const orgId = requestContext.get("orgId"); + const orgName = requestContext.get("orgName"); + const userAuthInfo = requestContext.get("userAuthInfo"); + const identityAuthInfo = requestContext.get("identityAuthInfo"); + const projectDetails = requestContext.get("projectDetails"); + + const attributes: Record = { + "http.request.method": method, + "http.route": route, + "error.type": errorType, + "error.name": error.name + }; + + if (orgId) { + attributes["infisical.organization.id"] = orgId; + } + if (orgName) { + attributes["infisical.organization.name"] = orgName; + } + + if (userAuthInfo) { + if (userAuthInfo.userId) { + attributes["infisical.user.id"] = userAuthInfo.userId; + } + if (userAuthInfo.email) { + attributes["infisical.user.email"] = userAuthInfo.email; + } + } + + if (identityAuthInfo) { + if (identityAuthInfo.identityId) { + attributes["infisical.identity.id"] = identityAuthInfo.identityId; + } + if (identityAuthInfo.identityName) { + attributes["infisical.identity.name"] = identityAuthInfo.identityName; + } + if (identityAuthInfo.authMethod) { + attributes["infisical.auth.method"] = identityAuthInfo.authMethod; + } + } + + if (projectDetails) { + if (projectDetails.id) { + attributes["infisical.project.id"] = projectDetails.id; + } + if (projectDetails.name) { + attributes["infisical.project.name"] = projectDetails.name; + } + } + + const userAgent = req.headers["user-agent"]; + if (userAgent) { + attributes["user_agent.original"] = userAgent; + } + + if (req.realIp) { + attributes["client.address"] = req.realIp; + } + + errorCounter.add(1, attributes); } if (error instanceof BadRequestError) { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 53cf1f961..cf268b24e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -46,8 +46,8 @@ import { githubOrgSyncServiceFactory } from "@app/ee/services/github-org-sync/gi import { groupDALFactory } from "@app/ee/services/group/group-dal"; import { groupServiceFactory } from "@app/ee/services/group/group-service"; import { userGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal"; -import { hsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; -import { HsmModule } from "@app/ee/services/hsm/hsm-types"; +import { isHsmActiveAndEnabled } from "@app/ee/services/hsm/hsm-fns"; +import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; import { identityAuthTemplateDALFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-dal"; import { identityAuthTemplateServiceFactory } from "@app/ee/services/identity-auth-template/identity-auth-template-service"; import { kmipClientCertificateDALFactory } from "@app/ee/services/kmip/kmip-client-certificate-dal"; @@ -138,6 +138,7 @@ import { keyValueStoreDALFactory } from "@app/keystore/key-value-store-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig, TEnvConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -176,6 +177,7 @@ import { certificateTemplateEstConfigDALFactory } from "@app/services/certificat import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/certificate-v3-queue"; import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; @@ -236,8 +238,9 @@ import { integrationAuthDALFactory } from "@app/services/integration-auth/integr import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { internalKmsDALFactory } from "@app/services/kms/internal-kms-dal"; import { kmskeyDALFactory } from "@app/services/kms/kms-key-dal"; -import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { TKmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; import { kmsServiceFactory } from "@app/services/kms/kms-service"; +import { RootKeyEncryptionStrategy } from "@app/services/kms/kms-types"; import { membershipDALFactory } from "@app/services/membership/membership-dal"; import { membershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { membershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; @@ -255,11 +258,11 @@ import { userNotificationDALFactory } from "@app/services/notification/user-noti import { offlineUsageReportDALFactory } from "@app/services/offline-usage-report/offline-usage-report-dal"; import { offlineUsageReportServiceFactory } from "@app/services/offline-usage-report/offline-usage-report-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; -import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; import { orgServiceFactory } from "@app/services/org/org-service"; import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; +import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rotation/pam-account-rotation-queue"; import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; @@ -364,20 +367,22 @@ export const registerRoutes = async ( auditLogDb, superAdminDAL, db, - hsmModule, smtp: smtpService, queue: queueService, keyStore, - envConfig + envConfig, + hsmService, + kmsRootConfigDAL }: { auditLogDb?: Knex; superAdminDAL: TSuperAdminDALFactory; db: Knex; - hsmModule: HsmModule; smtp: TSmtpService; queue: TQueueServiceFactory; keyStore: TKeyStoreFactory; envConfig: TEnvConfig; + hsmService: THsmServiceFactory; + kmsRootConfigDAL: TKmsRootConfigDALFactory; } ) => { const appCfg = getConfig(); @@ -392,7 +397,6 @@ export const registerRoutes = async ( const authTokenDAL = tokenDALFactory(db); const orgDAL = orgDALFactory(db); const orgMembershipDAL = orgMembershipDALFactory(db); - const orgBotDAL = orgBotDALFactory(db); const incidentContactDAL = incidentContactDALFactory(db); const rateLimitDAL = rateLimitDALFactory(db); const apiKeyDAL = apiKeyDALFactory(db); @@ -509,7 +513,6 @@ export const registerRoutes = async ( const kmsDAL = kmskeyDALFactory(db); const internalKmsDAL = internalKmsDALFactory(db); const externalKmsDAL = externalKmsDALFactory(db); - const kmsRootConfigDAL = kmsRootConfigDALFactory(db); const slackIntegrationDAL = slackIntegrationDALFactory(db); const projectSlackConfigDAL = projectSlackConfigDALFactory(db); @@ -569,7 +572,8 @@ export const registerRoutes = async ( orgDAL, licenseDAL, keyStore, - projectDAL + projectDAL, + envConfig }); const tokenService = tokenServiceFactory({ tokenDAL: authTokenDAL, userDAL, membershipUserDAL, orgDAL }); @@ -604,6 +608,10 @@ export const registerRoutes = async ( const membershipGroupService = membershipGroupServiceFactory({ membershipGroupDAL, membershipRoleDAL, + accessApprovalPolicyDAL, + accessApprovalPolicyApproverDAL, + secretApprovalPolicyDAL, + secretApprovalPolicyApproverDAL: sapApproverDAL, roleDAL, permissionService, orgDAL @@ -624,11 +632,6 @@ export const registerRoutes = async ( permissionService }); - const hsmService = hsmServiceFactory({ - hsmModule, - envConfig - }); - const kmsService = kmsServiceFactory({ kmsRootConfigDAL, keyStore, @@ -901,7 +904,6 @@ export const registerRoutes = async ( smtpService, userDAL, groupDAL, - orgBotDAL, oidcConfigDAL, ldapConfigDAL, loginService, @@ -1707,7 +1709,8 @@ export const registerRoutes = async ( licenseService, permissionService, kmsService, - membershipIdentityDAL + membershipIdentityDAL, + orgDAL }); const identityAwsAuthService = identityAwsAuthServiceFactory({ @@ -1960,6 +1963,8 @@ export const registerRoutes = async ( secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, folderDAL, secretSyncQueue, projectBotService, @@ -2139,6 +2144,7 @@ export const registerRoutes = async ( const certificateV3Service = certificateV3ServiceFactory({ certificateDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, @@ -2146,6 +2152,13 @@ export const registerRoutes = async ( permissionService }); + const certificateV3Queue = certificateV3QueueServiceFactory({ + queueService, + certificateDAL, + certificateV3Service, + auditLogService + }); + const certificateEstV3Service = certificateEstV3ServiceFactory({ internalCertificateAuthorityService, certificateTemplateV2Service, @@ -2262,7 +2275,13 @@ export const registerRoutes = async ( pamSessionDAL, permissionService, projectDAL, - userDAL + userDAL, + auditLogService + }); + + const pamAccountRotation = pamAccountRotationServiceFactory({ + queueService, + pamAccountService }); const pamSessionService = pamSessionServiceFactory({ @@ -2296,16 +2315,39 @@ export const registerRoutes = async ( // Start HSM service if it's configured/enabled. await hsmService.startService(); + const hsmStatus = await isHsmActiveAndEnabled({ + hsmService, + kmsRootConfigDAL, + licenseService + }); + + // if the encryption strategy is software - user needs to provide an encryption key + // if the encryption strategy is null AND the hsm is not configured - user needs to provide an encryption key + const needsEncryptionKey = + hsmStatus.rootKmsConfigEncryptionStrategy === RootKeyEncryptionStrategy.Software || + (hsmStatus.rootKmsConfigEncryptionStrategy === null && !hsmStatus.isHsmConfigured); + + if (needsEncryptionKey) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment.\nYou can enable HSM encryption in the Server Console." + }); + } + } + await telemetryQueue.startTelemetryCheck(); await telemetryQueue.startAggregatedEventsJob(); await dailyResourceCleanUp.init(); await healthAlert.init(); await pkiSyncCleanup.init(); + await pamAccountRotation.init(); await dailyReminderQueueService.startDailyRemindersJob(); await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); - await kmsService.startService(); + await certificateV3Queue.init(); + await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); await eventBusService.init(); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index c799ef0f0..a3250c6a9 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -88,6 +88,10 @@ import { NetlifyConnectionListItemSchema, SanitizedNetlifyConnectionSchema } from "@app/services/app-connection/netlify"; +import { + NorthflankConnectionListItemSchema, + SanitizedNorthflankConnectionSchema +} from "@app/services/app-connection/northflank"; import { OktaConnectionListItemSchema, SanitizedOktaConnectionSchema } from "@app/services/app-connection/okta"; import { PostgresConnectionListItemSchema, @@ -160,6 +164,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, + ...SanitizedNorthflankConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, @@ -203,6 +208,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, + NorthflankConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 2e3da4420..d8bcdce23 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -29,6 +29,7 @@ import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; +import { registerNorthflankConnectionRouter } from "./northflank-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; @@ -83,6 +84,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Northflank, + server, + sanitizedResponseSchema: SanitizedNorthflankConnectionSchema, + createSchema: CreateNorthflankConnectionSchema, + updateSchema: UpdateNorthflankConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z.object({ + projects: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const projects = await server.services.appConnection.northflank.listProjects(connectionId, req.permission); + return { projects }; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/projects/:projectId/secret-groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + projectId: z.string() + }), + response: { + 200: z.object({ + secretGroups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, projectId } = req.params; + const secretGroups = await server.services.appConnection.northflank.listSecretGroups( + connectionId, + projectId, + req.permission + ); + return { secretGroups }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 2292c3ba8..af32bd6c7 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -42,7 +42,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) @@ -150,7 +150,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ id: z.string(), autoRenew: z.boolean(), - autoRenewDays: z.number().optional() + renewBeforeDays: z.number().optional() }) .optional() }).array(), @@ -230,7 +230,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ id: z.string(), autoRenew: z.boolean(), - autoRenewDays: z.number().optional() + renewBeforeDays: z.number().optional() }) .optional(), metrics: z @@ -355,7 +355,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 76b3eae51..d4b0058f9 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .min(1, "Max Shared Secret view count cannot be lower than 1") .max(1000, "Max Shared Secret view count cannot exceed 1000") .nullable() + .optional(), + blockDuplicateSecretSyncDestinations: z + .boolean() .optional() + .describe("Block duplicate secret sync destinations across the organization") }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index c1f4140e5..c3bffa2fc 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1200,7 +1200,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - certificates: z.array(CertificatesSchema), + certificates: z.array(CertificatesSchema.extend({ hasPrivateKey: z.boolean() })), totalCount: z.number() }) } diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index e778dbd7c..2acf2dfc9 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -23,6 +23,7 @@ import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; +import { registerNorthflankSyncRouter } from "./northflank-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; import { registerSupabaseSyncRouter } from "./supabase-sync-router"; @@ -64,6 +65,7 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.Northflank, + server, + responseSchema: NorthflankSyncSchema, + createSchema: CreateNorthflankSyncSchema, + updateSchema: UpdateNorthflankSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 1bfa32eeb..e6fd39e34 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -46,6 +46,7 @@ import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; +import { NorthflankSyncListItemSchema, NorthflankSyncSchema } from "@app/services/secret-sync/northflank"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; import { SupabaseSyncListItemSchema, SupabaseSyncSchema } from "@app/services/secret-sync/supabase"; @@ -85,6 +86,7 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, + NorthflankSyncSchema, BitbucketSyncSchema, LaravelForgeSyncSchema ]); @@ -119,6 +121,7 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, + NorthflankSyncListItemSchema, BitbucketSyncListItemSchema, LaravelForgeSyncListItemSchema ]); diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 366fa331d..32b09b337 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -7,6 +7,7 @@ // All the any rules are disabled because passport typesense with fastify is really poor import { Authenticator } from "@fastify/passport"; +import { requestContext } from "@fastify/request-context"; import fastifySession from "@fastify/session"; import RedisStore from "connect-redis"; import { CronJob } from "cron"; @@ -21,6 +22,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; import { fetchGithubEmails, fetchGithubUser } from "@app/lib/requests/github"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { authRateLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthMethod } from "@app/services/auth/auth-type"; @@ -51,30 +53,54 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req, _accessToken, _refreshToken, profile, cb) => { - try { - // @ts-expect-error this is because this is express type and not fastify - const callbackPort = req.session.get("callbackPort"); - // @ts-expect-error this is because this is express type and not fastify - const orgSlug = req.session.get("orgSlug"); + // @ts-expect-error this is because this is express type and not fastify + const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); - const email = profile?.emails?.[0]?.value; - if (!email) - throw new NotFoundError({ - message: "Email not found", - name: "OauthGoogleRegister" + const email = profile?.emails?.[0]?.value; + if (!email) + throw new NotFoundError({ + message: "Email not found", + name: "OauthGoogleRegister" + }); + + try { + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: profile?.name?.givenName || "", + lastName: profile?.name?.familyName || "", + authMethod: AuthMethod.GOOGLE, + callbackPort, + orgSlug }); - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: profile?.name?.givenName || "", - lastName: profile?.name?.familyName || "", - authMethod: AuthMethod.GOOGLE, - callbackPort, - orgSlug - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { logger.error(error); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GOOGLE, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } cb(error as Error, false); } } @@ -101,27 +127,50 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { }, // eslint-disable-next-line async (req: any, accessToken: string, _refreshToken: string, _profile: any, done: Function) => { + const ghEmails = await fetchGithubEmails(accessToken); + const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; + + if (!email) throw new Error("No primary email found"); + try { - const ghEmails = await fetchGithubEmails(accessToken); - const { email } = ghEmails.filter((gitHubEmail) => gitHubEmail.primary)[0]; - - if (!email) throw new Error("No primary email found"); - // profile does not get automatically populated so we need to manually fetch user info - const user = await fetchGithubUser(accessToken); + const githubUser = await fetchGithubUser(accessToken); const callbackPort = req.session.get("callbackPort"); - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: user.name || user.login, - lastName: "", - authMethod: AuthMethod.GITHUB, - callbackPort - }); + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: githubUser.name || githubUser.login, + lastName: "", + authMethod: AuthMethod.GITHUB, + callbackPort + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GITHUB, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } done(null, { isUserCompleted, providerAuthToken, externalProviderAccessToken: accessToken }); } catch (err) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GITHUB, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } logger.error(err); done(err as Error, false); } @@ -147,20 +196,45 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { pkce: true }, async (req: any, _accessToken: string, _refreshToken: string, profile: any, cb: any) => { + const email = profile.emails[0].value; + try { const callbackPort = req.session.get("callbackPort"); - const email = profile.emails[0].value; - const { isUserCompleted, providerAuthToken } = await server.services.login.oauth2Login({ - email, - firstName: profile.displayName || profile.username || "", - lastName: "", - authMethod: AuthMethod.GITLAB, - callbackPort - }); + const { isUserCompleted, providerAuthToken, user, orgId, orgName } = + await server.services.login.oauth2Login({ + email, + firstName: profile.displayName || profile.username || "", + lastName: "", + authMethod: AuthMethod.GITLAB, + callbackPort + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.user.id": user.id, + "infisical.organization.id": orgId, + "infisical.organization.name": orgName, + "infisical.auth.method": AuthAttemptAuthMethod.GITLAB, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } return cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.GITLAB, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + logger.error(error); cb(error as Error, false); } diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 549310738..d2d696596 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -18,6 +18,7 @@ import { CertKeyUsageType, CertSubjectAlternativeNameType } from "@app/services/certificate-common/certificate-constants"; +import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; @@ -84,8 +85,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }) ) .optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) }) .refine(validateTtlAndDateFields, { message: @@ -169,9 +170,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .min(1, "TTL cannot be empty") .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + notAfter: validateCaDateField.optional() }) .refine(validateTtlAndDateFields, { message: @@ -192,6 +191,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const certificateRequest = extractCertificateRequestFromCSR(req.body.csr); + const data = await server.services.certificateV3.signCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, @@ -203,9 +204,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined }); await server.services.auditLog.createAuditLog({ @@ -217,7 +216,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: "" + commonName: certificateRequest.commonName || "" } } }); @@ -260,8 +259,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), commonName: validateTemplateRegexField.optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) }) .refine(validateTtlAndDateFields, { message: @@ -343,4 +342,145 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => return data; } }); + + server.route({ + method: "POST", + url: "/:certificateId/renew", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + certificateId: z.string().uuid() + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.certificateId, + newCertificateId: data.certificateId, + profileName: data.profileName, + commonName: data.commonName + } + } + }); + + return data; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateId/config", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + certificateId: z.string().uuid() + }), + body: z + .object({ + renewBeforeDays: z.number().int().min(1).max(30).optional(), + enableAutoRenewal: z.boolean().optional() + }) + .refine((data) => !(data.renewBeforeDays !== undefined && data.enableAutoRenewal === false), { + message: "Cannot specify both renewBeforeDays and enableAutoRenewal=false" + }), + response: { + 200: z.object({ + message: z.string(), + renewBeforeDays: z.number().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.body.enableAutoRenewal === false) { + const data = await server.services.certificateV3.disableRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.certificateId, + commonName: data.commonName + } + } + }); + + return { + message: "Auto-renewal disabled successfully" + }; + } + + if (req.body.renewBeforeDays !== undefined) { + const data = await server.services.certificateV3.updateRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId, + renewBeforeDays: req.body.renewBeforeDays + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.certificateId, + renewBeforeDays: req.body.renewBeforeDays.toString(), + commonName: data.commonName + } + } + }); + + return { + message: "Certificate configuration updated successfully", + renewBeforeDays: data.renewBeforeDays + }; + } + + return { + message: "No configuration changes requested" + }; + } + }); }; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index f0737e01d..367490c82 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -393,6 +393,56 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider } }); + server.route({ + method: "GET", + url: "/vault/kubernetes-roles", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + allowed_kubernetes_namespaces: z.array(z.string()).nullish(), + allowed_kubernetes_namespace_selector: z.string().nullish(), + token_max_ttl: z.number().nullish(), + token_default_ttl: z.number().nullish(), + token_default_audiences: z.array(z.string()).nullish(), + service_account_name: z.string().nullish(), + kubernetes_role_name: z.string().nullish(), + kubernetes_role_type: z.string().nullish(), + generated_role_rules: z.string().nullish(), + name_template: z.string().nullish(), + extra_annotations: z.record(z.string()).nullish(), + extra_labels: z.record(z.string()).nullish(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().nullish() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); + server.route({ method: "GET", url: "/vault/secret-paths", diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 54b70c7d3..1e731ed77 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -38,7 +38,8 @@ export enum AppConnection { Netlify = "netlify", Okta = "okta", Redis = "redis", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Northflank = "northflank" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 464f718ce..efc3deb99 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -113,6 +113,11 @@ import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; import { getMySqlConnectionListItem } from "./mysql/mysql-connection-fns"; import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } from "./netlify"; +import { + getNorthflankConnectionListItem, + NorthflankConnectionMethod, + validateNorthflankConnectionCredentials +} from "./northflank"; import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; @@ -203,6 +208,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), + getNorthflankConnectionListItem(), getOktaConnectionListItem(), getRedisConnectionListItem() ] @@ -332,8 +338,9 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Checkly]: validateChecklyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -345,6 +352,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case GitHubConnectionMethod.App: case GitHubRadarConnectionMethod.App: return "GitHub App"; + case GitHubConnectionMethod.Pat: + return "Personal Access Token"; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: @@ -374,6 +383,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return "API Token"; @@ -470,6 +480,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, + [AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index c01d9d1b4..c684765bd 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -40,7 +40,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.DigitalOcean]: "DigitalOcean App Platform", [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", - [AppConnection.Redis]: "Redis" + [AppConnection.Redis]: "Redis", + [AppConnection.Northflank]: "Northflank" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -83,5 +84,6 @@ export const APP_CONNECTION_PLAN_MAP: Record( ): Promise => { const { credentials, method } = appConnection; - const token = - method === GitHubConnectionMethod.OAuth - ? credentials.accessToken - : await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + let token: string; + + switch (method) { + case GitHubConnectionMethod.OAuth: + token = credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service); + } const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); @@ -460,6 +468,35 @@ export const validateGitHubConnectionCredentials = async ( gatewayV2Service: Pick ) => { const { credentials, method } = config; + + // PAT validation + if (method === GitHubConnectionMethod.Pat) { + try { + const apiUrl = await getGitHubInstanceApiUrl(config); + await requestWithGitHubGateway(config, gatewayService, gatewayV2Service, { + url: `https://${apiUrl}/user`, + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${credentials.personalAccessToken}`, + "X-GitHub-Api-Version": "2022-11-28" + } + }); + + return { + personalAccessToken: credentials.personalAccessToken, + instanceType: credentials.instanceType, + host: credentials.host + }; + } catch (e: unknown) { + logger.error(e, "Unable to verify GitHub PAT connection"); + + throw new BadRequestError({ + message: "Unable to validate Personal Access Token: verify token has proper permissions" + }); + } + } + const { INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID, INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET, 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 1b8aa9c3f..20ef2c0e0 100644 --- a/backend/src/services/app-connection/github/github-connection-schemas.ts +++ b/backend/src/services/app-connection/github/github-connection-schemas.ts @@ -38,6 +38,19 @@ export const GitHubConnectionAppInputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatInputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("server"), + host: z.string().trim().min(1, "Host is required for server instance type") + }), + z.object({ + personalAccessToken: z.string().trim().min(1, "Personal Access Token required"), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([ z.object({ accessToken: z.string(), @@ -64,6 +77,19 @@ export const GitHubConnectionAppOutputCredentialsSchema = z.union([ }) ]); +export const GitHubConnectionPatOutputCredentialsSchema = z.union([ + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("server"), + host: z.string().trim().min(1) + }), + z.object({ + personalAccessToken: z.string(), + instanceType: z.literal("cloud").optional(), + host: z.string().trim().optional() + }) +]); + export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method), @@ -76,6 +102,12 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m credentials: GitHubConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.GitHub).credentials ) + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat).describe(AppConnections.CREATE(AppConnection.GitHub).method), + credentials: GitHubConnectionPatInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.GitHub).credentials + ) }) ]); @@ -88,7 +120,11 @@ export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsS export const UpdateGitHubConnectionSchema = z .object({ credentials: z - .union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema]) + .union([ + GitHubConnectionAppInputCredentialsSchema, + GitHubConnectionOAuthInputCredentialsSchema, + GitHubConnectionPatInputCredentialsSchema + ]) .optional() .describe(AppConnections.UPDATE(AppConnection.GitHub).credentials) }) @@ -110,6 +146,10 @@ export const GitHubConnectionSchema = z.intersection( z.object({ method: z.literal(GitHubConnectionMethod.OAuth), credentials: GitHubConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: GitHubConnectionPatOutputCredentialsSchema }) ]) ); @@ -128,6 +168,13 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [ instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), host: z.string().optional() }) + }), + BaseGitHubConnectionSchema.extend({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: z.object({ + instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(), + host: z.string().optional() + }) }) ]); diff --git a/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts b/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts index 3bd01d147..f409a61cf 100644 --- a/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts +++ b/backend/src/services/app-connection/gitlab/gitlab-connection-enums.ts @@ -5,5 +5,6 @@ export enum GitLabConnectionMethod { export enum GitLabAccessTokenType { Project = "project", - Personal = "personal" + Personal = "personal", + Group = "group" } diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 38f97700c..5eb9202fd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -21,10 +21,29 @@ import { THCVaultKubernetesAuthConfig, THCVaultKubernetesAuthRole, THCVaultKubernetesAuthRoleWithConfig, + THCVaultKubernetesRole, + THCVaultKubernetesSecretsConfig, THCVaultMount, THCVaultMountResponse } from "./hc-vault-connection-types"; +// HashiCorp Vault stores JSON data, so values can be any valid JSON type +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export const convertVaultValueToString = (value: JsonValue): string => { + if (value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + // For objects and arrays, serialize as JSON + return JSON.stringify(value); +}; + // Concurrency limit for HC Vault API requests to avoid rate limiting const HC_VAULT_CONCURRENCY_LIMIT = 20; @@ -598,7 +617,7 @@ export const getHCVaultSecretsForPath = async ( // For KV v2: /v1/{mount}/data/{path} const { data } = await requestWithHCVaultGateway<{ data: { - data: Record; // KV v2 has nested data structure + data: Record; // KV v2 has nested data structure, supports all JSON types metadata: { created_time: string; deletion_time: string; @@ -620,7 +639,7 @@ export const getHCVaultSecretsForPath = async ( // For KV v1: /v1/{mount}/{path} const { data } = await requestWithHCVaultGateway<{ - data: Record; // KV v1 has flat data structure + data: Record; // KV v1 has flat data structure, supports all JSON types lease_duration: number; lease_id: string; renewable: boolean; @@ -799,3 +818,122 @@ export const getHCVaultKubernetesAuthRoles = async ( }); } }; + +export const getHCVaultKubernetesRoles = async ( + namespace: string, + mountPath: string, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + // Remove trailing slash from mount path + const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath; + + try { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + // 1. Get the Kubernetes secrets engine configuration for this mount + const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesSecretsConfig }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/${cleanMountPath}/config`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const kubernetesConfig = configResponse.data; + + // 2. List all roles in this mount + let roleNames: string[] = []; + try { + const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/${cleanMountPath}/roles?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + roleNames = roleListResponse.data.keys || []; + } catch (error) { + // Vault returns 404 when no roles are configured yet + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as { response?: { status?: number } }; + if (axiosError.response?.status === 404) { + return []; + } + } + + throw error; + } + + if (!roleNames || roleNames.length === 0) { + return []; + } + + // 3. Fetch details for each role with concurrency control + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const roleDetailsPromises = roleNames.map((roleName) => + limiter(async () => { + const { data: roleResponse } = await requestWithHCVaultGateway<{ + data: { + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${cleanMountPath}/roles/${roleName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + // 4. Merge the role with the config + return { + ...roleResponse.data, + name: roleName, + config: kubernetesConfig, + mountPath: cleanMountPath + } as THCVaultKubernetesRole; + }) + ); + + const roles = await Promise.all(roleDetailsPromises); + + return roles; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault Kubernetes secrets engine roles"); + + if (error instanceof AxiosError) { + const errorMessage = + (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error"; + throw new BadRequestError({ + message: `Failed to list Kubernetes secrets engine roles: ${errorMessage}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Kubernetes secrets engine roles from HashiCorp Vault" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index d25dbc9e6..2b1956c84 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -95,3 +95,26 @@ export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & config: THCVaultKubernetesAuthConfig; mountPath: string; }; + +export type THCVaultKubernetesSecretsConfig = { + kubernetes_host: string; + kubernetes_ca_cert?: string; +}; + +export type THCVaultKubernetesRole = { + name: string; + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + config: THCVaultKubernetesSecretsConfig; + mountPath: string; +}; diff --git a/backend/src/services/app-connection/northflank/index.ts b/backend/src/services/app-connection/northflank/index.ts new file mode 100644 index 000000000..94aee2a74 --- /dev/null +++ b/backend/src/services/app-connection/northflank/index.ts @@ -0,0 +1,5 @@ +export * from "./northflank-connection-enums"; +export * from "./northflank-connection-fns"; +export * from "./northflank-connection-schemas"; +export * from "./northflank-connection-service"; +export * from "./northflank-connection-types"; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-enums.ts b/backend/src/services/app-connection/northflank/northflank-connection-enums.ts new file mode 100644 index 000000000..0f4f1bd06 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-enums.ts @@ -0,0 +1,3 @@ +export enum NorthflankConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/northflank/northflank-connection-fns.ts b/backend/src/services/app-connection/northflank/northflank-connection-fns.ts new file mode 100644 index 000000000..462934800 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-fns.ts @@ -0,0 +1,114 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { NorthflankConnectionMethod } from "./northflank-connection-enums"; +import { + TNorthflankConnection, + TNorthflankConnectionConfig, + TNorthflankProject, + TNorthflankSecretGroup +} from "./northflank-connection-types"; + +const NORTHFLANK_API_URL = "https://api.northflank.com"; + +export const getNorthflankConnectionListItem = () => { + return { + name: "Northflank" as const, + app: AppConnection.Northflank as const, + methods: Object.values(NorthflankConnectionMethod) + }; +}; + +export const validateNorthflankConnectionCredentials = async (config: TNorthflankConnectionConfig) => { + const { credentials } = config; + + try { + await request.get(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate Northflank credentials: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Failed to validate Northflank credentials - verify API token is correct` + }); + } + + return credentials; +}; + +export const listProjects = async (appConnection: TNorthflankConnection): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { projects } + } + } = await request.get<{ data: { projects: TNorthflankProject[] } }>(`${NORTHFLANK_API_URL}/v1/projects`, { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + }); + + return projects; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank projects: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank projects", + error + }); + } +}; + +export const listSecretGroups = async ( + appConnection: TNorthflankConnection, + projectId: string +): Promise => { + const { credentials } = appConnection; + + try { + const { + data: { + data: { secrets } + } + } = await request.get<{ data: { secrets: TNorthflankSecretGroup[] } }>( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets`, + { + headers: { + Authorization: `Bearer ${credentials.apiToken}`, + Accept: "application/json" + } + } + ); + + return secrets; + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Northflank secret groups: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Northflank secret groups", + error + }); + } +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts new file mode 100644 index 000000000..95be757f6 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-schemas.ts @@ -0,0 +1,60 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { NorthflankConnectionMethod } from "./northflank-connection-enums"; + +export const NorthflankConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.NORTHFLANK.apiToken) +}); + +const BaseNorthflankConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Northflank) +}); + +export const NorthflankConnectionSchema = BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema +}); + +export const SanitizedNorthflankConnectionSchema = z.discriminatedUnion("method", [ + BaseNorthflankConnectionSchema.extend({ + method: z.literal(NorthflankConnectionMethod.ApiToken), + credentials: NorthflankConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateNorthflankConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(NorthflankConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Northflank).method), + credentials: NorthflankConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Northflank).credentials + ) + }) +]); + +export const CreateNorthflankConnectionSchema = ValidateNorthflankConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Northflank) +); + +export const UpdateNorthflankConnectionSchema = z + .object({ + credentials: NorthflankConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Northflank).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Northflank)); + +export const NorthflankConnectionListItemSchema = z.object({ + name: z.literal("Northflank"), + app: z.literal(AppConnection.Northflank), + methods: z.nativeEnum(NorthflankConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/northflank/northflank-connection-service.ts b/backend/src/services/app-connection/northflank/northflank-connection-service.ts new file mode 100644 index 000000000..faf248bbc --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-service.ts @@ -0,0 +1,50 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listProjects as getNorthflankProjects, + listSecretGroups as getNorthflankSecretGroups +} from "./northflank-connection-fns"; +import { TNorthflankConnection, TNorthflankSecretGroup } from "./northflank-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const northflankConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const projects = await getNorthflankProjects(appConnection); + + return projects; + } catch (error) { + logger.error({ error, connectionId, actor: actor.type }, "Failed to establish connection with Northflank"); + return []; + } + }; + + const listSecretGroups = async ( + connectionId: string, + projectId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.Northflank, connectionId, actor); + try { + const secretGroups = await getNorthflankSecretGroups(appConnection, projectId); + + return secretGroups; + } catch (error) { + logger.error({ error, connectionId, projectId, actor: actor.type }, "Failed to list Northflank secret groups"); + return []; + } + }; + + return { + listProjects, + listSecretGroups + }; +}; diff --git a/backend/src/services/app-connection/northflank/northflank-connection-types.ts b/backend/src/services/app-connection/northflank/northflank-connection-types.ts new file mode 100644 index 000000000..c007e27d3 --- /dev/null +++ b/backend/src/services/app-connection/northflank/northflank-connection-types.ts @@ -0,0 +1,35 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateNorthflankConnectionSchema, + NorthflankConnectionSchema, + ValidateNorthflankConnectionCredentialsSchema +} from "./northflank-connection-schemas"; + +export type TNorthflankConnection = z.infer; + +export type TNorthflankConnectionInput = z.infer & { + app: AppConnection.Northflank; +}; + +export type TValidateNorthflankConnectionCredentialsSchema = typeof ValidateNorthflankConnectionCredentialsSchema; + +export type TNorthflankConnectionConfig = DiscriminativePick< + TNorthflankConnection, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 28a986fe8..fb7213109 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -210,6 +210,7 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD if (!user || !user.isAccepted) throw new NotFoundError({ message: `User with ID '${session.userId}' not found` }); let orgId = ""; + let orgName = ""; let rootOrgId = ""; let parentOrgId = ""; if (token.organizationId) { @@ -235,9 +236,11 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD throw new ForbiddenRequestError({ message: "User organization membership is inactive" }); } orgId = subOrganization.id; + orgName = subOrganization.name; rootOrgId = token.organizationId; parentOrgId = subOrganization.parentOrgId as string; } else { + const organization = await orgDAL.findOne({ id: token.organizationId }); const orgMembership = await membershipUserDAL.findOne({ actorUserId: user.id, scopeOrgId: token.organizationId, @@ -253,12 +256,13 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, membershipUserDAL, orgD } orgId = token.organizationId; + orgName = organization.name; rootOrgId = token.organizationId; parentOrgId = token.organizationId; } } - return { user, tokenVersionId: token.tokenVersionId, orgId, rootOrgId, parentOrgId }; + return { user, tokenVersionId: token.tokenVersionId, orgId, orgName, rootOrgId, parentOrgId }; }; return { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 31a9cc5a8..b9c759703 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -16,6 +16,7 @@ import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError, DatabaseError, ForbiddenRequestError, UnauthorizedError } from "@app/lib/errors"; import { getMinExpiresIn, removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -385,63 +386,94 @@ export const authLoginServiceFactory = ({ providerAuthToken?: string; captchaToken?: string; }) => { - const usersByUsername = await userDAL.findUserEncKeyByUsername({ - username: email - }); - const userEnc = - usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; + const appCfg = getConfig(); - if (!userEnc) throw new BadRequestError({ message: "User not found" }); + try { + const usersByUsername = await userDAL.findUserEncKeyByUsername({ + username: email + }); + const userEnc = + usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; - if (userEnc.encryptionVersion !== UserEncryption.V2) { - throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); - } + if (!userEnc) throw new BadRequestError({ message: "User not found" }); - if (!userEnc.hashedPassword) { - if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + if (userEnc.encryptionVersion !== UserEncryption.V2) { throw new BadRequestError({ message: "Legacy encryption scheme not supported", name: "LegacyEncryptionScheme" }); } - throw new BadRequestError({ message: "No password found" }); - } - - const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); - await verifyCaptcha(userEnc, captchaToken); - - if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) { - await userDAL.update( - { id: userEnc.userId }, - { - $incr: { - consecutiveFailedPasswordAttempts: 1 - } + if (!userEnc.hashedPassword) { + if (userEnc.authMethods?.includes(AuthMethod.EMAIL)) { + throw new BadRequestError({ + message: "Legacy encryption scheme not supported", + name: "LegacyEncryptionScheme" + }); } - ); - throw new BadRequestError({ message: "Invalid username or email" }); + throw new BadRequestError({ message: "No password found" }); + } + + const { authMethod, organizationId } = getAuthMethodAndOrgId(email, providerAuthToken); + await verifyCaptcha(userEnc, captchaToken); + + if (!(await crypto.hashing().compareHash(password, userEnc.hashedPassword))) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new BadRequestError({ message: "Invalid username or email" }); + } + + const token = await generateUserTokens({ + user: { + ...userEnc, + id: userEnc.userId + }, + ip, + userAgent, + authMethod, + organizationId + }); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.organization.id": organizationId, + "infisical.user.email": email, + "infisical.user.id": userEnc.userId, + "infisical.auth.method": AuthAttemptAuthMethod.EMAIL, + "infisical.auth.result": AuthAttemptAuthResult.SUCCESS, + "client.address": ip, + "user_agent.original": userAgent + }); + } + + return { + tokens: { + accessToken: token.access, + refreshToken: token.refresh + }, + user: userEnc + } as const; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.user.email": email, + "infisical.auth.method": AuthAttemptAuthMethod.EMAIL, + "infisical.auth.result": AuthAttemptAuthResult.FAILURE, + "client.address": ip, + "user_agent.original": userAgent + }); + } + + throw error; } - - const token = await generateUserTokens({ - user: { - ...userEnc, - id: userEnc.userId - }, - ip, - userAgent, - authMethod, - organizationId - }); - - return { - tokens: { - accessToken: token.access, - refreshToken: token.refresh - }, - user: userEnc - } as const; }; const selectOrganization = async ({ @@ -965,7 +997,8 @@ export const authLoginServiceFactory = ({ expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME } ); - return { isUserCompleted, providerAuthToken }; + + return { isUserCompleted, providerAuthToken, user, orgId, orgName }; }; /** diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 5b9cd78ee..a7292e366 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -2,12 +2,14 @@ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -1180,7 +1182,9 @@ export const internalCertificateAuthorityServiceFactory = ({ extendedKeyUsages, signatureAlgorithm, keyAlgorithm, - isFromProfile + isFromProfile, + internal = false, + tx }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1210,19 +1214,28 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); } - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + if (isFromProfile) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } + } if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) @@ -1473,7 +1486,7 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChainPem) }); - await certificateDAL.transaction(async (tx) => { + const executeIssueCertOperations = async (transaction: Knex) => { const cert = await certificateDAL.create( { caId: (ca as TCertificateAuthorities).id, @@ -1488,9 +1501,11 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, keyUsages: selectedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages, - projectId: ca!.projectId + projectId: ca!.projectId, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, - tx + transaction ); await certificateBodyDAL.create( @@ -1499,7 +1514,7 @@ export const internalCertificateAuthorityServiceFactory = ({ encryptedCertificate, encryptedCertificateChain }, - tx + transaction ); await certificateSecretDAL.create( @@ -1507,7 +1522,7 @@ export const internalCertificateAuthorityServiceFactory = ({ certId: cert.id, encryptedPrivateKey }, - tx + transaction ); if (collectionId) { @@ -1516,12 +1531,18 @@ export const internalCertificateAuthorityServiceFactory = ({ pkiCollectionId: collectionId, certId: cert.id }, - tx + transaction ); } return cert; - }); + }; + + if (tx) { + await executeIssueCertOperations(tx); + } else { + await certificateDAL.transaction(executeIssueCertOperations); + } return { certificate: leafCert.toString("pem"), @@ -1593,10 +1614,17 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + if (dto.isFromProfile && dto.profileId) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } } if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); @@ -1700,7 +1728,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: alg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); @@ -1917,7 +1946,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, keyUsages: selectedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages, - projectId: ca!.projectId + projectId: ca!.projectId, + keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm, + signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, tx ); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index 22cb86d28..b4b037933 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -1,3 +1,4 @@ +import { Knex } from "knex"; import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; @@ -139,6 +140,9 @@ export type TIssueCertFromCaDTO = { signatureAlgorithm?: CertSignatureAlgorithm; keyAlgorithm?: CertKeyAlgorithm; isFromProfile?: boolean; + profileId?: string; + internal?: boolean; + tx?: Knex; } & Omit; export type TSignCertFromCaDTO = @@ -159,6 +163,7 @@ export type TSignCertFromCaDTO = signatureAlgorithm?: string; keyAlgorithm?: string; isFromProfile?: boolean; + profileId?: string; } | ({ isInternal: false; @@ -177,6 +182,7 @@ export type TSignCertFromCaDTO = signatureAlgorithm?: string; keyAlgorithm?: string; isFromProfile?: boolean; + profileId?: string; } & Omit); export type TGetCaCertificateTemplatesDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts index bbd589110..4cc3afb8b 100644 --- a/backend/src/services/certificate-common/certificate-constants.ts +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -175,6 +175,26 @@ export enum CertSignatureAlgorithm { ECDSA_SHA512 = "ECDSA-SHA512" } +export enum CertificateRenewalErrorType { + TEMPLATE_VALIDATION_FAILED = "TEMPLATE_VALIDATION_FAILED", + CA_NOT_FOUND = "CA_NOT_FOUND", + CA_INACTIVE = "CA_INACTIVE", + CERTIFICATE_OUTLIVES_CA = "CERTIFICATE_OUTLIVES_CA", + TTL_TOO_SHORT = "TTL_TOO_SHORT", + NOT_ELIGIBLE = "NOT_ELIGIBLE", + VALIDITY_EXCEEDS_MAXIMUM = "VALIDITY_EXCEEDS_MAXIMUM", + NOT_ALLOWED_BY_TEMPLATE = "NOT_ALLOWED_BY_TEMPLATE", + UNKNOWN_ERROR = "UNKNOWN_ERROR" +} + +export const CERTIFICATE_RENEWAL_CONFIG = { + MIN_RENEW_BEFORE_DAYS: 1, + MAX_RENEW_BEFORE_DAYS: 30, + QUEUE_BATCH_SIZE: 100, + DAILY_CRON_SCHEDULE: "0 0 * * *", + QUEUE_START_DELAY_MS: 5000 +} as const; + export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); diff --git a/backend/src/services/certificate-common/certificate-csr-utils.ts b/backend/src/services/certificate-common/certificate-csr-utils.ts new file mode 100644 index 000000000..7578950af --- /dev/null +++ b/backend/src/services/certificate-common/certificate-csr-utils.ts @@ -0,0 +1,183 @@ +import * as x509 from "@peculiar/x509"; + +import { BadRequestError } from "@app/lib/errors"; + +import { + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "../certificate/certificate-types"; +import { parseDistinguishedName } from "../certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "../certificate-authority/certificate-authority-validators"; +import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types"; +import { mapLegacyExtendedKeyUsageToStandard, mapLegacyKeyUsageToStandard } from "./certificate-constants"; + +/** + * Extracts certificate request data from a CSR string + * @param csr - The CSR in PEM format + * @returns TCertificateRequest object with parsed CSR data + */ +export const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; +}; + +/** + * Extracts the key algorithm and signature algorithm from a CSR + * @param csr - The CSR in PEM format + * @returns Object containing keyAlgorithm and signatureAlgorithm + */ +export const extractAlgorithmsFromCSR = (csr: string) => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // Extract key algorithm from public key + const { publicKey } = csrObj; + let keyAlgorithm: CertKeyAlgorithm; + + if (publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") { + const rsaPublicKey = publicKey as unknown as { algorithm: { modulusLength: number } }; + const keySize = rsaPublicKey.algorithm.modulusLength; + switch (keySize) { + case 2048: + keyAlgorithm = CertKeyAlgorithm.RSA_2048; + break; + case 3072: + keyAlgorithm = CertKeyAlgorithm.RSA_3072; + break; + case 4096: + keyAlgorithm = CertKeyAlgorithm.RSA_4096; + break; + default: + throw new BadRequestError({ + message: `Unsupported RSA key size in CSR: ${keySize}. Supported: 2048, 3072, 4096` + }); + } + } else if (publicKey.algorithm.name === "ECDSA") { + const ecPublicKey = publicKey as unknown as { algorithm: { namedCurve: string } }; + const { namedCurve } = ecPublicKey.algorithm; + switch (namedCurve) { + case "P-256": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P256; + break; + case "P-384": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P384; + break; + case "P-521": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P521; + break; + default: + throw new BadRequestError({ + message: `Unsupported ECDSA curve in CSR: ${namedCurve}. Supported: P-256, P-384, P-521` + }); + } + } else { + throw new BadRequestError({ + message: `Unsupported key algorithm in CSR: ${publicKey.algorithm.name}. Supported: RSASSA-PKCS1-v1_5, ECDSA` + }); + } + + const signatureAlgorithm = csrObj.signatureAlgorithm.name; + const hashName = (csrObj.signatureAlgorithm as unknown as { hash?: { name: string } }).hash?.name; + + let normalizedSignatureAlg: CertSignatureAlgorithm; + + if (signatureAlgorithm === "RSASSA-PKCS1-v1_5") { + switch (hashName) { + case "SHA-256": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA256; + break; + case "SHA-384": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA384; + break; + case "SHA-512": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA512; + break; + default: + throw new BadRequestError({ + message: `Unsupported RSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512` + }); + } + } else if (signatureAlgorithm === "ECDSA") { + switch (hashName) { + case "SHA-256": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA256; + break; + case "SHA-384": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA384; + break; + case "SHA-512": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA512; + break; + default: + throw new BadRequestError({ + message: `Unsupported ECDSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512` + }); + } + } else { + throw new BadRequestError({ + message: `Unsupported signature algorithm in CSR: ${signatureAlgorithm}. Supported: RSASSA-PKCS1-v1_5, ECDSA` + }); + } + + return { + keyAlgorithm, + signatureAlgorithm: normalizedSignatureAlg + }; +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index f6dbfba52..0d8ef30d0 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -3,31 +3,15 @@ import * as x509 from "@peculiar/x509"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { isCertChainValid } from "@app/services/certificate/certificate-fns"; -import { - CertExtendedKeyUsageOIDToName, - CertKeyUsage, - mapLegacyAltNameType, - TAltNameMapping, - TAltNameType -} from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; -import { - getCaCertChain, - getCaCertChains, - parseDistinguishedName -} from "@app/services/certificate-authority/certificate-authority-fns"; -import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; -import { - mapLegacyExtendedKeyUsageToStandard, - mapLegacyKeyUsageToStandard -} from "@app/services/certificate-common/certificate-constants"; +import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; -import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -61,63 +45,6 @@ export const certificateEstV3ServiceFactory = ({ certificateProfileDAL, estEnrollmentConfigDAL }: TCertificateEstV3ServiceFactoryDep) => { - const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { - const csrObj = new x509.Pkcs10CertificateRequest(csr); - const subject = parseDistinguishedName(csrObj.subject); - - const certificateRequest: TCertificateRequest = { - commonName: subject.commonName, - organization: subject.organization, - organizationUnit: subject.ou, - locality: subject.locality, - state: subject.province, - country: subject.country - }; - - const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; - if (csrKeyUsageExtension) { - const csrKeyUsages = Object.values(CertKeyUsage).filter( - // eslint-disable-next-line no-bitwise - (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 - ); - certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); - } - - const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; - if (csrExtendedKeyUsageExtension) { - const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( - (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] - ); - certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); - } - - const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); - if (sanExtension) { - const sanNames = new x509.GeneralNames(sanExtension.value); - const altNamesArray: TAltNameMapping[] = sanNames.items - .filter( - (value) => - value.type === TAltNameType.EMAIL || - value.type === TAltNameType.DNS || - value.type === TAltNameType.IP || - value.type === TAltNameType.URL - ) - .map((name): TAltNameMapping => { - const altNameType = validateAndMapAltNameType(name.value); - if (!altNameType) { - throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); - } - return altNameType; - }); - - certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ - type: mapLegacyAltNameType(altName.type), - value: altName.value - })); - } - - return certificateRequest; - }; const simpleEnrollByProfile = async ({ csr, profileId, diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 20cb9f3bc..1ffa3e295 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -109,7 +109,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -132,7 +132,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ? ({ id: result.apiConfigId, autoRenew: !!result.apiConfigAutoRenew, - autoRenewDays: result.apiConfigAutoRenewDays || undefined + renewBeforeDays: result.apiConfigRenewBeforeDays || undefined } as TCertificateProfileWithConfigs["apiConfig"]) : undefined; @@ -264,7 +264,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") ); if (includeMetrics) { @@ -290,7 +290,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), db.raw("COUNT(certificates.id) as total_certificates"), db.raw( 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', @@ -333,7 +333,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ? { id: result.apiId as string, autoRenew: !!result.apiAutoRenew, - autoRenewDays: (result.apiAutoRenewDays as number) || undefined + renewBeforeDays: (result.apiRenewBeforeDays as number) || undefined } : undefined; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index 7b3e2cc57..a2c391c2a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -25,7 +25,7 @@ export const createCertificateProfileSchema = z apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) @@ -75,7 +75,7 @@ export const updateCertificateProfileSchema = z apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index dd2d7d2d6..26b1e976a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -110,7 +110,7 @@ describe("CertificateProfileService", () => { apiConfig: { id: "api-config-123", autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -202,7 +202,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -323,7 +323,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -761,7 +761,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -786,7 +786,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 7 + renewBeforeDays: 7 } }; @@ -808,7 +808,7 @@ describe("CertificateProfileService", () => { expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( { autoRenew: true, - autoRenewDays: 7 + renewBeforeDays: 7 }, undefined ); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index c43dee889..7b48af8f1 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -225,7 +225,7 @@ export const certificateProfileServiceFactory = ({ const apiConfig = await apiEnrollmentConfigDAL.create( { autoRenew: data.apiConfig.autoRenew, - autoRenewDays: data.apiConfig.autoRenewDays + renewBeforeDays: data.apiConfig.renewBeforeDays }, tx ); @@ -343,7 +343,7 @@ export const certificateProfileServiceFactory = ({ existingProfile.apiConfigId, { autoRenew: apiConfig.autoRenew, - autoRenewDays: apiConfig.autoRenewDays + renewBeforeDays: apiConfig.renewBeforeDays }, tx ); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index a6d53a0f3..1c22a5e75 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -26,7 +26,7 @@ export type TCertificateProfileUpdate = Omit => { const template = await certificateTemplateV2DAL.findById(templateId); if (!template) { throw new NotFoundError({ message: "Certificate template not found" }); } - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: template.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Read, - ProjectPermissionSub.CertificateTemplates - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + } return template; }; diff --git a/backend/src/services/certificate-v3/certificate-v3-queue.ts b/backend/src/services/certificate-v3/certificate-v3-queue.ts new file mode 100644 index 000000000..db7349cd5 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-queue.ts @@ -0,0 +1,163 @@ +/* eslint-disable no-await-in-loop */ +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants"; +import { TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +type TCertificateV3QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + certificateDAL: Pick; + certificateV3Service: TCertificateV3ServiceFactory; + auditLogService: Pick; +}; + +export const certificateV3QueueServiceFactory = ({ + queueService, + certificateDAL, + certificateV3Service, + auditLogService +}: TCertificateV3QueueServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.CertificateV3AutoRenewal, + QueueJobs.CertificateV3DailyAutoRenewal, + { pattern: CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, utc: true }, + QueueName.CertificateV3AutoRenewal + ); + + await queueService.startPg( + QueueJobs.CertificateV3DailyAutoRenewal, + async () => { + try { + logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`); + + const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG; + let offset = 0; + let hasMore = true; + let totalCertificatesFound = 0; + let totalCertificatesRenewed = 0; + + while (hasMore) { + const certificates = await certificateDAL.findCertificatesEligibleForRenewal({ + limit: QUEUE_BATCH_SIZE, + offset + }); + + if (certificates.length === 0) { + hasMore = false; + break; + } + + totalCertificatesFound += certificates.length; + logger.info( + `${QueueJobs.CertificateV3DailyAutoRenewal}: found ${certificates.length} certificates eligible for renewal (batch ${Math.floor(offset / QUEUE_BATCH_SIZE) + 1}, total found so far: ${totalCertificatesFound})` + ); + + for (const certificate of certificates) { + try { + if (certificate.renewBeforeDays) { + const { MIN_RENEW_BEFORE_DAYS, MAX_RENEW_BEFORE_DAYS } = CERTIFICATE_RENEWAL_CONFIG; + if ( + certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS || + certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS + ) { + // eslint-disable-next-line no-continue + continue; + } + } + + await certificateV3Service.renewCertificate({ + actor: ActorType.PLATFORM, + actorId: "", + actorAuthMethod: null, + actorOrgId: "", + certificateId: certificate.id, + internal: true + }); + + totalCertificatesRenewed += 1; + + await auditLogService.createAuditLog({ + projectId: certificate.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.AUTOMATED_RENEW_CERTIFICATE, + metadata: { + certificateId: certificate.id, + commonName: certificate.commonName || "", + profileId: certificate.profileId!, + renewBeforeDays: certificate.renewBeforeDays?.toString() || "", + profileName: certificate.profileName || "" + } + } + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(error, `Failed to renew certificate ${certificate.id}: ${errorMessage}`); + await auditLogService.createAuditLog({ + projectId: certificate.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED, + metadata: { + certificateId: certificate.id, + commonName: certificate.commonName || "", + profileId: certificate.profileId || "", + renewBeforeDays: certificate.renewBeforeDays?.toString() || "", + profileName: certificate.profileName || "", + error: errorMessage + } + } + }); + } + } + + offset += QUEUE_BATCH_SIZE; + } + + logger.info( + `${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}` + ); + } catch (error) { + logger.error(error, `${QueueJobs.CertificateV3DailyAutoRenewal}: certificate renewal failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg( + QueueJobs.CertificateV3DailyAutoRenewal, + CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; + +export type TCertificateV3QueueServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 95f9a5077..0c70571dd 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -7,10 +7,12 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { CertExtendedKeyUsageType, @@ -23,14 +25,32 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + extractAlgorithmsFromCSR, + extractCertificateRequestFromCSR +} from "../certificate-common/certificate-csr-utils"; import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; +vi.mock("../certificate-common/certificate-csr-utils", () => ({ + extractCertificateRequestFromCSR: vi.fn(), + extractAlgorithmsFromCSR: vi.fn() +})); + describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; - const mockCertificateDAL: Pick = { + const mockCertificateDAL: Pick = { findOne: vi.fn(), - updateById: vi.fn() + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) + }; + + const mockCertificateSecretDAL: Pick = { + findOne: vi.fn() }; const mockCertificateAuthorityDAL: Pick = { @@ -76,7 +96,7 @@ describe("CertificateV3Service", () => { beforeEach(() => { // Reset all mocks before each test - vi.clearAllMocks(); + vi.resetAllMocks(); // Mock ForbiddenError.from static method vi.spyOn(ForbiddenError, "from").mockReturnValue({ @@ -95,8 +115,20 @@ describe("CertificateV3Service", () => { } }); + vi.mocked(extractCertificateRequestFromCSR).mockReturnValue({ + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }); + + vi.mocked(extractAlgorithmsFromCSR).mockReturnValue({ + keyAlgorithm: "RSA_2048" as any, + signatureAlgorithm: "RSA-SHA256" as any + }); + service = certificateV3ServiceFactory({ certificateDAL: mockCertificateDAL, + certificateSecretDAL: mockCertificateSecretDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, @@ -641,6 +673,11 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); @@ -1460,4 +1497,714 @@ describe("CertificateV3Service", () => { ).resolves.toBeDefined(); }); }); + + describe("renewCertificate", () => { + const mockOriginalCert = { + id: "cert-123", + status: CertStatus.ACTIVE, + serialNumber: "123456", + friendlyName: "Test Certificate", + commonName: "test.example.com", + notBefore: new Date("2024-01-01"), + notAfter: new Date("2024-02-01"), // 31 days + revokedAt: null, + renewedByCertificateId: null, + profileId: "profile-123", + renewBeforeDays: 7, + caId: "ca-123", + pkiSubscriberId: null, + keyUsages: ["digital_signature", "key_agreement"], + extendedKeyUsages: ["server_auth"], + altNames: "test.example.com,api.example.com", + projectId: "project-123", + createdAt: new Date(), + updatedAt: new Date(), + certificateTemplateId: "template-123", + revocationReason: null, + caCertId: null, + renewedFromCertificateId: null, + renewalError: null, + keyAlgorithm: "RSA_2048", + signatureAlgorithm: "RSA-SHA256" + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + id: "api-config-123", + autoRenew: true, + renewBeforeDays: 14 + }, + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + status: CaStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + name: "Test CA", + requireTemplateForIssuance: false, + externalCa: undefined, + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + keyAlgorithm: "RSA_2048", + notAfter: "2025-01-01T00:00:00Z", + notBefore: "2024-01-01T00:00:00Z", + maxPathLength: -1, + activeCaCertId: "cert-123", + dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US", + serialNumber: "123456789", + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + keyAlgorithm: "RSA_2048", + notAfter: "2025-01-01T00:00:00Z", + notBefore: "2024-01-01T00:00:00Z", + maxPathLength: -1, + activeCaCertId: "cert-123", + dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US", + serialNumber: "123456789" + } + }; + + const mockTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + algorithms: { + signature: ["SHA256-RSA", "SHA384-RSA"], + keyType: ["RSA_2048", "RSA_4096"] + } + }; + + beforeEach(() => { + // Mock current date to be within renewal window + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-26")); // 6 days before cert expires, within renewal window + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should successfully renew eligible certificate", async () => { + // Mock the initial findById call + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "renewed-cert", + certificateChain: "renewed-chain", + issuingCaCertificate: "issuing-ca", + privateKey: "private-key", + serialNumber: "789012", + ca: mockCA + }); + + const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }; + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + + // Mock the transaction to return the expected structure + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + const result = await callback(mockTx); + return result; + }); + + const result = await service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }); + + expect(result).toHaveProperty("certificate", "renewed-cert"); + expect(result).toHaveProperty("certificateId", "cert-456"); + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith( + "cert-456", + { + profileId: "profile-123", + renewBeforeDays: 14, + renewedFromCertificateId: "cert-123" + }, + {} + ); + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith( + "cert-123", + { + renewedByCertificateId: "cert-456", + renewalError: null + }, + {} + ); + }); + + it("should validate certificate against current template during renewal", async () => { + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: false, + errors: ["Subject alternative name not allowed"], + warnings: [] + }); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate renewal failed. Errors: Subject alternative name not allowed"); + + // Should store template validation error + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewalError: "Template validation failed: Subject alternative name not allowed" + }); + }); + + it("should reject renewal if certificate is not from a profile", async () => { + const certWithoutProfile = { ...mockOriginalCert, profileId: null }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Only certificates issued from a profile can be renewed"); + }); + + it("should reject renewal if certificate was issued from CSR (external private key)", async () => { + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue(null as any); + + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("certificates issued from CSR (external private key) cannot be renewed"); + }); + + it("should reject renewal if certificate is already renewed", async () => { + const alreadyRenewedCert = { ...mockOriginalCert, renewedByCertificateId: "cert-456" }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(alreadyRenewedCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate has already been renewed"); + }); + + it("should reject renewal if certificate is expired", async () => { + const expiredCert = { + ...mockOriginalCert, + notAfter: new Date("2024-01-20") // Expired 6 days ago + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(expiredCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(expiredCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is already expired"); + }); + + it("should reject renewal if certificate is revoked", async () => { + const revokedCert = { + ...mockOriginalCert, + revokedAt: new Date("2024-01-15") + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(revokedCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(revokedCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is revoked and cannot be renewed"); + }); + + it("should reject renewal if CA is inactive", async () => { + const inactiveCA = { ...mockCA, status: CaStatus.DISABLED }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is not eligible for renewal: Certificate Authority is disabled, must be active"); + }); + + it("should reject renewal if new certificate would outlive CA", async () => { + const shortLivedCA = { + ...mockCA, + internalCa: { + ...mockCA.internalCa, + notAfter: "2024-01-28T00:00:00Z" + } + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(/New certificate would expire \(.+\) after its issuing CA \(.+\)/); + }); + + it("should allow manual renewal outside window (manual renewal always bypasses window)", async () => { + vi.setSystemTime(new Date("2024-01-15")); // 17 days before expiry, outside 7-day window + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "renewed-cert", + certificateChain: "renewed-chain", + issuingCaCertificate: "issuing-ca", + privateKey: "private-key", + serialNumber: "789012", + ca: mockCA + }); + + const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }; + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + + // Set up transaction mock to properly handle the renewal process + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + const result = await service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }); + + expect(result).toHaveProperty("certificate", "renewed-cert"); + }); + }); + + describe("updateRenewalConfig", () => { + it("should update renewal configuration successfully", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedByCertificateId: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-02-01"), + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null, + commonName: "" + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any); + + const result = await service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }); + + expect(result).toEqual({ + projectId: "project-123", + renewBeforeDays: 7, + commonName: "" + }); + + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewBeforeDays: 7 + }); + }); + + it("should reject update if certificate is not from profile", async () => { + const mockCert = { + id: "cert-123", + profileId: null, + renewedByCertificateId: null, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile"); + }); + + it("should reject update if certificate is already renewed", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedByCertificateId: "cert-456", + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-02-01") + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate has already been renewed"); + }); + + it("should reject update if renewBeforeDays >= certificate TTL", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedByCertificateId: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-01-08"), + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 8 // Greater than 7-day TTL + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 8 + }) + ).rejects.toThrow("Invalid renewal configuration: renewal threshold exceeds certificate validity period"); + }); + }); + + describe("disableRenewalConfig", () => { + it("should disable renewal configuration successfully", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + projectId: "project-123", + commonName: "" + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any); + + const result = await service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }); + + expect(result).toEqual({ + projectId: "project-123", + commonName: "" + }); + + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewBeforeDays: null + }); + }); + + it("should reject disable if certificate is not from profile", async () => { + const mockCert = { + id: "cert-123", + profileId: null, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + + await expect( + service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile"); + }); + }); }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 1c11b0a00..0a721b2db 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,36 +1,49 @@ import { ForbiddenError } from "@casl/ability"; import { randomUUID } from "crypto"; +import RE2 from "re2"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { + ProjectPermissionCertificateActions, ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { + CertExtendedKeyUsage, CertificateOrderStatus, CertKeyAlgorithm, - CertSignatureAlgorithm + CertKeyType, + CertKeyUsage, + CertSignatureAlgorithm, + CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory, TCertificateAuthorityWithAssociatedCa } from "@app/services/certificate-authority/certificate-authority-dal"; -import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + extractAlgorithmsFromCSR, + extractCertificateRequestFromCSR +} from "../certificate-common/certificate-csr-utils"; import { bufferToString, buildCertificateSubjectFromTemplate, buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayFromLegacy, convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, normalizeDateForApi @@ -38,13 +51,19 @@ import { import { TCertificateFromProfileResponse, TCertificateOrderResponse, + TDisableRenewalConfigDTO, + TDisableRenewalResponse, TIssueCertificateFromProfileDTO, TOrderCertificateFromProfileDTO, - TSignCertificateFromProfileDTO + TRenewalConfigResponse, + TRenewCertificateDTO, + TSignCertificateFromProfileDTO, + TUpdateRenewalConfigDTO } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick; + certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateTemplateV2Service: Pick< @@ -95,6 +114,77 @@ const validateProfileAndPermissions = async ( return profile; }; +const validateRenewalEligibility = ( + certificate: { + id: string; + status: string; + notBefore: Date; + notAfter: Date; + revokedAt?: Date | null; + renewedByCertificateId?: string | null; + profileId?: string | null; + caId?: string | null; + pkiSubscriberId?: string | null; + }, + ca: TCertificateAuthorityWithAssociatedCa +) => { + const errors: string[] = []; + + if (certificate.status !== CertStatus.ACTIVE) { + errors.push(`Certificate status is ${certificate.status}, must be ${CertStatus.ACTIVE}`); + } + + const now = new Date(); + if (certificate.notAfter <= now) { + errors.push("Certificate is already expired"); + } + + if (certificate.revokedAt) { + errors.push("Certificate is revoked and cannot be renewed"); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + const isInternalCa = caType === CaType.INTERNAL; + const isConnectedExternalCa = caType === CaType.ACME || caType === CaType.AZURE_AD_CS; + const isImportedCertificate = certificate.pkiSubscriberId != null && !certificate.profileId; + + if (!isInternalCa && !isConnectedExternalCa) { + errors.push(`CA type ${String(caType)} does not support renewal`); + } + + if (isImportedCertificate) { + errors.push("Externally imported certificates cannot be renewed"); + } + + if (ca.status !== CaStatus.ACTIVE) { + errors.push(`Certificate Authority is ${ca.status}, must be ${CaStatus.ACTIVE}`); + } + + if (certificate.renewedByCertificateId) { + errors.push("Certificate has already been renewed"); + } + + const certificateTtlInDays = Math.ceil( + (certificate.notAfter.getTime() - certificate.notBefore.getTime()) / (24 * 60 * 60 * 1000) + ); + + if (ca.internalCa?.notAfter) { + const caExpiryDate = new Date(ca.internalCa.notAfter); + const proposedCertExpiryDate = new Date(now.getTime() + certificateTtlInDays * 24 * 60 * 60 * 1000); + + if (proposedCertExpiryDate > caExpiryDate) { + errors.push( + `New certificate would expire (${proposedCertExpiryDate.toISOString()}) after its issuing CA (${caExpiryDate.toISOString()})` + ); + } + } + + return { + isEligible: errors.length === 0, + errors + }; +}; + const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; if (caType !== CaType.INTERNAL) { @@ -129,11 +219,11 @@ const validateAlgorithmCompatibility = ( const keyType = parts[parts.length - 1]; if (caKeyAlgorithm.startsWith("RSA")) { - return keyType === "RSA"; + return keyType === CertKeyType.RSA; } if (caKeyAlgorithm.startsWith("EC")) { - return keyType === "ECDSA"; + return keyType === CertKeyType.ECDSA; } return false; @@ -155,8 +245,85 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s return bufferToString(certData as unknown as Buffer); }; +const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { + if (!keyUsages) return []; + if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[]; + return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage); +}; + +const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { + if (!extendedKeyUsages) return []; + if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[]; + return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage); +}; + +const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => { + const renewalDate = new Date(certificateExpiryDate.getTime() - renewBeforeDays * 24 * 60 * 60 * 1000); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + return renewalDate >= tomorrow; +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (!profileRenewBeforeDays) { + return undefined; + } + + if (certificateTtlInDays > profileRenewBeforeDays) { + return profileRenewBeforeDays; + } + + return Math.max(1, certificateTtlInDays - 1); +}; + +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const numValue = parseInt(value, 10); + + switch (unit) { + case "d": + return numValue; + case "h": + return Math.ceil(numValue / 24); + case "m": + return Math.ceil(numValue / (24 * 60)); + default: + throw new BadRequestError({ message: `Unsupported TTL unit: ${unit}` }); + } +}; + +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, + ttl: string, + certificateExpiryDate: Date +): number | undefined => { + if (!profile.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) { + return undefined; + } + + const certificateTtlInDays = parseTtlToDays(ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays); + + if (!renewBeforeDays) { + return undefined; + } + + return isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, @@ -198,7 +365,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - templateId: profile.certificateTemplateId + templateId: profile.certificateTemplateId, + internal: true }); if (!template) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); @@ -222,10 +390,6 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "direct certificate issuance"); - if (!actorAuthMethod) { - throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); - } - validateAlgorithmCompatibility(ca, template); const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; @@ -274,7 +438,16 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); } - await certificateDAL.updateById(cert.id, { profileId }); + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + new Date(cert.notAfter) + ); + + await certificateDAL.updateById(cert.id, { + profileId, + renewBeforeDays: finalRenewBeforeDays + }); return { certificate: bufferToString(certificate), @@ -284,7 +457,8 @@ export const certificateV3ServiceFactory = ({ serialNumber, certificateId: cert.id, projectId: profile.projectId, - profileName: profile.slug + profileName: profile.slug, + commonName: cert.commonName || "" }; }; @@ -294,8 +468,6 @@ export const certificateV3ServiceFactory = ({ validity, notBefore, notAfter, - signatureAlgorithm, - keyAlgorithm, actor, actorId, actorAuthMethod, @@ -319,38 +491,40 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "CSR signing"); - if (!actorAuthMethod) { - throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); - } - const template = await certificateTemplateV2Service.getTemplateV2ById({ actor, actorId, actorAuthMethod, actorOrgId, - templateId: profile.certificateTemplateId + templateId: profile.certificateTemplateId, + internal: true }); if (!template) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); } + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = signatureAlgorithm; - const effectiveKeyAlgorithm = keyAlgorithm; - - if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { - throw new BadRequestError({ - message: "Key algorithm is required by template policy but not provided in request" - }); - } - - if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { - throw new BadRequestError({ - message: "Signature algorithm is required by template policy but not provided in request" - }); - } + const effectiveSignatureAlgorithm = extractedSignatureAlgorithm; + const effectiveKeyAlgorithm = extractedKeyAlgorithm; const { certificate, certificateChain, issuingCaCertificate, serialNumber } = await internalCaService.signCertFromCa({ @@ -371,7 +545,12 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } - await certificateDAL.updateById(cert.id, { profileId }); + const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); + + await certificateDAL.updateById(cert.id, { + profileId, + renewBeforeDays: finalRenewBeforeDays + }); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); @@ -383,7 +562,8 @@ export const certificateV3ServiceFactory = ({ serialNumber, certificateId: cert.id, projectId: profile.projectId, - profileName: profile.slug + profileName: profile.slug, + commonName: cert.commonName || "" }; }; @@ -479,9 +659,405 @@ export const certificateV3ServiceFactory = ({ }); }; + const renewCertificate = async ({ + certificateId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + internal = false + }: TRenewCertificateDTO & { internal?: boolean }): Promise => { + const renewalResult = await certificateDAL.transaction(async (tx) => { + const originalCert = await certificateDAL.findById(certificateId, tx); + if (!originalCert) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + if (!originalCert.profileId) { + throw new ForbiddenRequestError({ + message: "Only certificates issued from a profile can be renewed" + }); + } + + const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; + const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; + + if (!originalSignatureAlgorithm || !originalKeyAlgorithm) { + throw new BadRequestError({ + message: + "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." + }); + } + + const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" + }); + } + + const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx); + if (!certificateSecret) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for renewal: certificates issued from CSR (external private key) cannot be renewed" + }); + } + + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const eligibilityCheck = validateRenewalEligibility(originalCert, ca); + if (!eligibilityCheck.isEligible) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + throw new BadRequestError({ + message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId, + internal + }); + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + const originalTtlInDays = Math.ceil( + (new Date(originalCert.notAfter).getTime() - new Date(originalCert.notBefore).getTime()) / (1000 * 60 * 60 * 24) + ); + const ttl = `${originalTtlInDays}d`; + + const certificateRequest = { + commonName: originalCert.commonName || undefined, + keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), + subjectAlternativeNames: originalCert.altNames + ? originalCert.altNames.split(",").map((san) => { + const trimmed = san.trim(); + + const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed); + const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed); + if (isIpv4 || isIpv6) { + return { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + value: trimmed + }; + } + + if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) { + return { + type: CertSubjectAlternativeNameType.EMAIL, + value: trimmed + }; + } + + if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) { + return { + type: CertSubjectAlternativeNameType.URI, + value: trimmed + }; + } + + return { + type: CertSubjectAlternativeNameType.DNS_NAME, + value: trimmed + }; + }) + : [], + validity: { + ttl + }, + signatureAlgorithm: originalCert.signatureAlgorithm || undefined, + keyAlgorithm: originalCert.keyAlgorithm || undefined + }; + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + certificateRequest + ); + + if (!validationResult.isValid) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Template validation failed: ${validationResult.errors.join(", ")}` + }); + + throw new BadRequestError({ + message: `Certificate renewal failed. Errors: ${validationResult.errors.join(", ")}` + }); + } + + validateAlgorithmCompatibility(ca, template); + const notBefore = new Date(); + const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter); + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", + commonName: originalCert.commonName || "", + altNames: originalCert.altNames || "", + ttl, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + isFromProfile: true, + actor, + actorId, + actorAuthMethod, + actorOrgId, + internal: true, + tx + }); + + const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + if (!newCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById( + newCert.id, + { + profileId: originalCert.profileId, + renewBeforeDays: finalRenewBeforeDays, + renewedFromCertificateId: originalCert.id + }, + tx + ); + + await certificateDAL.updateById( + originalCert.id, + { + renewedByCertificateId: newCert.id, + renewalError: null + }, + tx + ); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber, + newCert, + originalCert, + profile + }; + }); + + return { + certificate: renewalResult.certificate, + issuingCaCertificate: renewalResult.issuingCaCertificate, + certificateChain: renewalResult.certificateChain, + serialNumber: renewalResult.serialNumber, + certificateId: renewalResult.newCert.id, + projectId: renewalResult.profile.projectId, + profileName: renewalResult.profile.slug, + commonName: renewalResult.originalCert.commonName || "" + }; + }; + + const updateRenewalConfig = async ({ + certificateId, + renewBeforeDays, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateRenewalConfigDTO): Promise => { + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certificate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Edit, + ProjectPermissionSub.Certificates + ); + + if (!certificate.profileId) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile" + }); + } + + const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" + }); + } + + const certificateSecret = await certificateSecretDAL.findOne({ certId: certificate.id }); + if (!certificateSecret) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for auto-renewal: certificates issued from CSR (external private key) cannot be auto-renewed" + }); + } + + if (certificate.status !== CertStatus.ACTIVE) { + throw new BadRequestError({ + message: `Certificate is not eligible for auto-renewal: certificate status is ${certificate.status}, must be active` + }); + } + + const now = new Date(); + if (certificate.notAfter <= now) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has expired" + }); + } + + if (certificate.revokedAt) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has been revoked" + }); + } + + if (certificate.renewedByCertificateId) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has already been renewed" + }); + } + + const certificateTtlInDays = Math.ceil( + (new Date(certificate.notAfter).getTime() - new Date(certificate.notBefore).getTime()) / (24 * 60 * 60 * 1000) + ); + + if (renewBeforeDays >= certificateTtlInDays) { + throw new BadRequestError({ + message: "Invalid renewal configuration: renewal threshold exceeds certificate validity period" + }); + } + + if (!isValidRenewalTiming(renewBeforeDays, new Date(certificate.notAfter))) { + throw new BadRequestError({ + message: "Invalid renewal configuration: renewal would be triggered immediately or in the past" + }); + } + + await certificateDAL.updateById(certificateId, { + renewBeforeDays + }); + + return { + projectId: certificate.projectId, + renewBeforeDays, + commonName: certificate.commonName || "" + }; + }; + + const disableRenewalConfig = async ({ + certificateId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDisableRenewalConfigDTO): Promise => { + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certificate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Edit, + ProjectPermissionSub.Certificates + ); + + if (!certificate.profileId) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile" + }); + } + + const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" + }); + } + + await certificateDAL.updateById(certificateId, { + renewBeforeDays: null + }); + + return { + projectId: certificate.projectId, + commonName: certificate.commonName || "" + }; + }; + return { issueCertificateFromProfile, signCertificateFromProfile, - orderCertificateFromProfile + orderCertificateFromProfile, + renewCertificate, + updateRenewalConfig, + disableRenewalConfig }; }; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index b54042c5c..a62a25b73 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -35,8 +35,6 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; - signatureAlgorithm?: string; - keyAlgorithm?: string; } & Omit; export type TOrderCertificateFromProfileDTO = { @@ -68,6 +66,7 @@ export type TCertificateFromProfileResponse = { certificateId: string; projectId: string; profileName: string; + commonName: string; }; export type TCertificateOrderResponse = { @@ -97,3 +96,27 @@ export type TCertificateOrderResponse = { projectId: string; profileName: string; }; + +export type TRenewCertificateDTO = { + certificateId: string; +} & Omit; + +export type TUpdateRenewalConfigDTO = { + certificateId: string; + renewBeforeDays: number; +} & Omit; + +export type TDisableRenewalConfigDTO = { + certificateId: string; +} & Omit; + +export type TRenewalConfigResponse = { + projectId: string; + renewBeforeDays: number; + commonName: string; +}; + +export type TDisableRenewalResponse = { + projectId: string; + commonName: string; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 88808bd63..eb40b85a5 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; import { CertStatus } from "./certificate-types"; @@ -114,12 +114,94 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findCertificatesEligibleForRenewal = async ({ + limit, + offset + }: { + limit: number; + offset: number; + }): Promise<(TCertificates & { profileName?: string })[]> => { + try { + const now = new Date(); + const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); + + const certs = (await db + .replicaNode()(TableName.Certificate) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName")) + .leftJoin( + TableName.PkiCertificateProfile, + `${TableName.Certificate}.profileId`, + `${TableName.PkiCertificateProfile}.id` + ) + .innerJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) + .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) + .whereNull(`${TableName.Certificate}.renewedByCertificateId`) + .whereNull(`${TableName.Certificate}.renewalError`) + .whereNull(`${TableName.Certificate}.revokedAt`) + .whereNotNull(`${TableName.Certificate}.profileId`) + .whereNotNull(`${TableName.Certificate}.notAfter`) + .where(`${TableName.Certificate}.notAfter`, ">", now) + .whereNotNull(`${TableName.Certificate}.renewBeforeDays`) + .where(`${TableName.Certificate}.renewBeforeDays`, ">", 0) + .whereRaw( + `"${TableName.Certificate}"."notAfter" - INTERVAL '1 day' * "${TableName.Certificate}"."renewBeforeDays" <= ?`, + [endOfDay] + ) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.Certificate}.notAfter`, "asc")) as TCertificates[]; + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificates eligible for renewal" }); + } + }; + + const findWithPrivateKeyInfo = async ( + filter: Partial, + options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } + ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { + try { + let query = db + .replicaNode()(TableName.Certificate) + .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) + .where(filter); + + if (options?.offset) { + query = query.offset(options.offset); + } + if (options?.limit) { + query = query.limit(options.limit); + } + if (options?.sort) { + options.sort.forEach(([column, direction]) => { + query = query.orderBy(column, direction); + }); + } + + const results = await query; + return results.map((row) => { + return { + ...row, + hasPrivateKey: row.privateKeyRef !== null + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificates with private key info" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, findLatestActiveCertForSubscriber, findAllActiveCertsForSubscriber, - findExpiredSyncedCertificates + findExpiredSyncedCertificates, + findCertificatesEligibleForRenewal, + findWithPrivateKeyInfo }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 9da331be8..d654c96ba 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -21,6 +21,11 @@ export enum CertKeyAlgorithm { ECDSA_P521 = "EC_secp521r1" } +export enum CertKeyType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export enum CertSignatureAlgorithm { RSA_SHA256 = "RSA-SHA256", RSA_SHA384 = "RSA-SHA384", diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts index 1edfdae6c..ec4b8f76b 100644 --- a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -69,15 +69,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { const profiles = await query .where((qb) => { void qb - .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) - .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + .whereNull(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`, "<=", renewalThresholdDays); }) .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) - .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + .select((tx || db).ref("renewBeforeDays").withSchema(TableName.PkiCertificateProfile)); - return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + return profiles as Array<{ id: string; name: string; projectId: string; renewBeforeDays?: number }>; } catch (error) { throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); } diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 516f135fd..d2e03e4da 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -25,5 +25,5 @@ export interface TEstConfigData { export interface TApiConfigData { autoRenew: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; } diff --git a/backend/src/services/external-migration/external-migration-fns/import.ts b/backend/src/services/external-migration/external-migration-fns/import.ts index 62888c5bf..3ffd11088 100644 --- a/backend/src/services/external-migration/external-migration-fns/import.ts +++ b/backend/src/services/external-migration/external-migration-fns/import.ts @@ -82,7 +82,7 @@ export const importDataIntoInfisicalFn = async ({ if (existingEnv) { throw new BadRequestError({ - message: `Environment with slug '${slug}' already exist`, + message: `Environment with slug '${slug}' already exists`, name: "CreateEnvironment" }); } @@ -312,7 +312,7 @@ export const importDataIntoInfisicalFn = async ({ ); if (secretsByKeys.length) { throw new BadRequestError({ - message: `Secret already exist: ${secretsByKeys.map((el) => el.key).join(",")}` + message: `Secret already exists: ${secretsByKeys.map((el) => el.key).join(",")}` }); } await fnSecretBulkInsert({ diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 3cc128768..42fdc2c26 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -16,8 +16,10 @@ import { AppConnection } from "../app-connection/app-connection-enums"; import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; import { + convertVaultValueToString, getHCVaultAuthMounts, getHCVaultKubernetesAuthRoles, + getHCVaultKubernetesRoles, getHCVaultSecretsForPath, HCVaultAuthType, listHCVaultMounts, @@ -592,7 +594,7 @@ export const externalMigrationServiceFactory = ({ projectId, secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ secretKey, - secretValue + secretValue: convertVaultValueToString(secretValue) })) }); @@ -761,6 +763,56 @@ export const externalMigrationServiceFactory = ({ return roles; }; + const getVaultKubernetesRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission({ + scope: OrganizationActionScope.Any, + actor: actor.type, + actorId: actor.id, + orgId: actor.orgId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId + }); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can get Kubernetes roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + return getHCVaultKubernetesRoles(namespace, mountPath, connection, gatewayService); + }; + return { importEnvKeyData, importVaultData, @@ -775,6 +827,7 @@ export const externalMigrationServiceFactory = ({ getVaultAuthMounts, getVaultSecretPaths, importVaultSecrets, - getVaultKubernetesAuthRoles + getVaultKubernetesAuthRoles, + getVaultKubernetesRoles }; }; 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 02660a0ae..3479d929e 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 @@ -210,6 +210,7 @@ export const identityAccessTokenServiceFactory = ({ }); } let orgId = ""; + let orgName = ""; let parentOrgId = ""; const identityOrgDetails = await orgDAL.findOne({ id: identityAccessToken.identityScopeOrgId }); const rootOrgId = identityOrgDetails.rootOrgId || identityOrgDetails.id; @@ -229,8 +230,12 @@ export const identityAccessTokenServiceFactory = ({ throw new BadRequestError({ message: "Identity does not belong to any 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, @@ -242,6 +247,7 @@ export const identityAccessTokenServiceFactory = ({ } orgId = rootOrgId; + orgName = organization.name; parentOrgId = rootOrgId; } @@ -253,7 +259,7 @@ export const identityAccessTokenServiceFactory = ({ await validateAccessTokenExp({ ...identityAccessToken, accessTokenNumUses }); await accessTokenQueue.updateIdentityAccessTokenStatus(identityAccessToken.id, Number(accessTokenNumUses) + 1); - return { ...identityAccessToken, orgId, rootOrgId, parentOrgId }; + return { ...identityAccessToken, orgId, rootOrgId, parentOrgId, orgName }; }; return { renewAccessToken, revokeAccessToken, fnValidateIdentityAccessToken }; 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 c6f6f1376..525da1e10 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,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; @@ -22,6 +23,7 @@ import { } 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 { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -65,6 +67,7 @@ export const identityAliCloudAuthServiceFactory = ({ orgDAL }: TIdentityAliCloudAuthServiceFactoryDep) => { const login = async ({ identityId, ...params }: TLoginAliCloudAuthDTO) => { + const appCfg = getConfig(); const identityAliCloudAuth = await identityAliCloudAuthDAL.findOne({ identityId }); if (!identityAliCloudAuth) { throw new NotFoundError({ @@ -75,73 +78,103 @@ export const identityAliCloudAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAliCloudAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const requestUrl = new URL("https://sts.aliyuncs.com"); + const org = await orgDAL.findById(identity.orgId); - for (const key of Object.keys(params)) { - requestUrl.searchParams.set(key, (params as Record)[key]); - } + try { + const requestUrl = new URL("https://sts.aliyuncs.com"); - const { data } = await request.get(requestUrl.toString()).catch((err: AxiosError) => { - logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); - throw err; - }); + for (const key of Object.keys(params)) { + requestUrl.searchParams.set(key, (params as Record)[key]); + } - if (identityAliCloudAuth.allowedArns) { - // In the future we could do partial checks for role ARNs - const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); + const { data } = await request.get(requestUrl.toString()).catch((err: AxiosError) => { + logger.error(err.response, "AliCloudIdentityLogin: Failed to authenticate with Alibaba Cloud"); + throw err; + }); - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: Alibaba Cloud account ARN not allowed." - }); - } + if (identityAliCloudAuth.allowedArns) { + // In the future we could do partial checks for role ARNs + const isAccountAllowed = identityAliCloudAuth.allowedArns.split(",").some((arn) => arn.trim() === data.Arn); - // Generate the token - const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: Alibaba Cloud account ARN not allowed." + }); + } + + // Generate the token + const identityAccessToken = await identityAliCloudAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.ALICLOUD_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAliCloudAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAliCloudAuth.accessTokenTTL, + accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.ALICLOUD_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityAliCloudAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAliCloudAuth.accessTokenTTL, - accessTokenMaxTTL: identityAliCloudAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAliCloudAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.ALICLOUD_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityAliCloudAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAliCloudAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityAliCloudAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityAliCloudAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAliCloudAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.ALICLOUD_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAliCloudAuth = async ({ diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts index d0fb4d323..a028bc25f 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-fns.ts @@ -2,13 +2,13 @@ interface PrincipalArnEntity { Partition: string; Service: "iam" | "sts"; AccountNumber: string; - Type: "user" | "role" | "instance-profile"; + Type: "user" | "role" | "instance-profile" | "assumed-role"; Path: string; FriendlyName: string; SessionInfo: string; // Only populated for assumed-role } -export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { +export const extractPrincipalArnEntity = (arn: string, formatAsIamRole: boolean = false): PrincipalArnEntity => { // split the ARN into parts using ":" as the delimiter const fullParts = arn.split(":"); if (fullParts.length !== 6) { @@ -49,7 +49,7 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { } // assumed roles use a special format where the friendly name is the role name const [roleName, sessionId] = rest; - finalType = "role"; // treat assumed role case as role + finalType = formatAsIamRole ? "role" : "assumed-role"; friendlyName = roleName; sessionInfo = sessionId; break; @@ -83,9 +83,11 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => { * Extracts the identity ARN from the GetCallerIdentity response to one of the following formats: * - arn:aws:iam::123456789012:user/MyUserName * - arn:aws:iam::123456789012:role/MyRoleName + * - arn:aws-us-gov:iam::123456789012:user/MyUserName (GovCloud) + * - arn:aws-us-gov:iam::123456789012:role/MyRoleName (GovCloud) */ -export const extractPrincipalArn = (arn: string) => { - const entity = extractPrincipalArnEntity(arn); +export const extractPrincipalArn = (arn: string, formatAsIamRole: boolean = false) => { + const entity = extractPrincipalArnEntity(arn, formatAsIamRole); - return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; + return `arn:${entity.Partition}:${formatAsIamRole ? "iam" : entity.Service}::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`; }; 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 1814afb2e..81fab3fde 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,5 +1,6 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios from "axios"; import RE2 from "re2"; @@ -21,6 +22,8 @@ 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 { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -97,6 +100,7 @@ export const identityAwsAuthServiceFactory = ({ orgDAL }: TIdentityAwsAuthServiceFactoryDep) => { const login = async ({ identityId, iamHttpRequestMethod, iamRequestBody, iamRequestHeaders }: TLoginAwsAuthDTO) => { + const appCfg = getConfig(); const identityAwsAuth = await identityAwsAuthDAL.findOne({ identityId }); if (!identityAwsAuth) { throw new NotFoundError({ message: "AWS auth method not found for identity, did you configure AWS auth?" }); @@ -105,120 +109,156 @@ export const identityAwsAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAwsAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); - const body: string = Buffer.from(iamRequestBody, "base64").toString(); + const org = await orgDAL.findById(identity.orgId); + try { + const headers: TAwsGetCallerIdentityHeaders = JSON.parse(Buffer.from(iamRequestHeaders, "base64").toString()); + const body: string = Buffer.from(iamRequestBody, "base64").toString(); - const authHeader = headers.Authorization || headers.authorization; - const region = authHeader ? awsRegionFromHeader(authHeader) : null; + const authHeader = headers.Authorization || headers.authorization; + const region = authHeader ? awsRegionFromHeader(authHeader) : null; - if (!isValidAwsRegion(region)) { - throw new BadRequestError({ message: "Invalid AWS region" }); - } + if (!isValidAwsRegion(region)) { + throw new BadRequestError({ message: "Invalid AWS region" }); + } - const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; + const url = region ? `https://sts.${region}.amazonaws.com` : identityAwsAuth.stsEndpoint; - const { - data: { - GetCallerIdentityResponse: { - GetCallerIdentityResult: { Account, Arn, UserId } + const { + data: { + GetCallerIdentityResponse: { + GetCallerIdentityResult: { Account, Arn, UserId } + } + } + }: { data: TGetCallerIdentityResponse } = await axios({ + method: iamHttpRequestMethod, + url, + headers, + data: body + }); + + if (identityAwsAuth.allowedAccountIds) { + // validate if Account is in the list of allowed Account IDs + + const isAccountAllowed = identityAwsAuth.allowedAccountIds + .split(",") + .map((accountId) => accountId.trim()) + .some((accountId) => accountId === Account); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: AWS account ID not allowed." + }); + } + + if (identityAwsAuth.allowedPrincipalArns) { + // validate if Arn is in the list of allowed Principal ARNs + + const formattedArn = extractPrincipalArn(Arn); + + const isArnAllowed = identityAwsAuth.allowedPrincipalArns + .split(",") + .map((principalArn) => principalArn.trim()) + .some((principalArn) => { + // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" + // considers exact matches + wildcard matches + // heavily validated in router + const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); + return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true)); + }); + + if (!isArnAllowed) { + logger.error( + `AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]` + ); + + throw new UnauthorizedError({ + message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]` + }); } } - }: { data: TGetCallerIdentityResponse } = await axios({ - method: iamHttpRequestMethod, - url, - headers, - data: body - }); - if (identityAwsAuth.allowedAccountIds) { - // validate if Account is in the list of allowed Account IDs + const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAwsAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAwsAuth.accessTokenTTL, + accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.AWS_AUTH + }, + tx + ); + return newToken; + }); - const isAccountAllowed = identityAwsAuth.allowedAccountIds - .split(",") - .map((accountId) => accountId.trim()) - .some((accountId) => accountId === Account); - - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: AWS account ID not allowed." - }); - } - - if (identityAwsAuth.allowedPrincipalArns) { - // validate if Arn is in the list of allowed Principal ARNs - - const isArnAllowed = identityAwsAuth.allowedPrincipalArns - .split(",") - .map((principalArn) => principalArn.trim()) - .some((principalArn) => { - // convert wildcard ARN to a regular expression: "arn:aws:iam::123456789012:*" -> "^arn:aws:iam::123456789012:.*$" - // considers exact matches + wildcard matches - // heavily validated in router - const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`); - return regex.test(extractPrincipalArn(Arn)); - }); - - if (!isArnAllowed) - throw new UnauthorizedError({ - message: "Access denied: AWS principal ARN not allowed." - }); - } - - const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.AWS_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const splitArn = extractPrincipalArnEntity(Arn); + const accessToken = crypto.jwt().sign( { identityId: identityAwsAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAwsAuth.accessTokenTTL, - accessTokenMaxTTL: identityAwsAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAwsAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AWS_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + aws: { + accountId: Account, + arn: Arn, + userId: UserId, + + // Derived from ARN + partition: splitArn.Partition, + service: splitArn.Service, + resourceType: splitArn.Type, + resourceName: splitArn.FriendlyName + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const splitArn = extractPrincipalArnEntity(Arn); - const accessToken = crypto.jwt().sign( - { - identityId: identityAwsAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - aws: { - accountId: Account, - arn: Arn, - userId: UserId, + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAwsAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - // Derived from ARN - partition: splitArn.Partition, - service: splitArn.Service, - resourceType: splitArn.Type, - resourceName: splitArn.FriendlyName - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityAwsAuth, identityAccessToken, identity }; + return { accessToken, identityAwsAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAwsAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AWS_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAwsAuth = async ({ diff --git a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts index 098bdcf9a..ed7f6696a 100644 --- a/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts +++ b/backend/src/services/identity-aws-auth/identity-aws-auth-validators.ts @@ -4,7 +4,10 @@ import { z } from "zod"; const twelveDigitRegex = new RE2(/^\d{12}$/); // akhilmhdh: change this to a normal function later. Checked no redosable at the moment -const arnRegex = new RE2(/^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/); + +const arnRegex = new RE2( + /^arn:aws(?:-us-gov)?:(iam|sts)::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|assumed-role\/[a-zA-Z0-9_.@+*/-]+|\*)$/ +); export const validateAccountIds = z .string() @@ -52,7 +55,7 @@ export const validatePrincipalArns = z }, { message: - "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'." + "Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*', 'arn:aws:sts::123456789012:assumed-role/RoleName'. GovCloud ARNs (arn:aws-us-gov:...) are also supported." } ) // Transform to normalize the spaces around commas 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 f75aeba4f..17f274e7c 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,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +19,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -61,6 +63,7 @@ export const identityAzureAuthServiceFactory = ({ orgDAL }: TIdentityAzureAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const appCfg = getConfig(); const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); if (!identityAzureAuth) { throw new NotFoundError({ message: "Azure auth method not found for identity, did you configure Azure Auth?" }); @@ -69,69 +72,99 @@ export const identityAzureAuthServiceFactory = ({ const identity = await identityDAL.findById(identityAzureAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const azureIdentity = await validateAzureIdentity({ - tenantId: identityAzureAuth.tenantId, - resource: identityAzureAuth.resource, - jwt: azureJwt - }); + const org = await orgDAL.findById(identity.orgId); - if (azureIdentity.tid !== identityAzureAuth.tenantId) - throw new UnauthorizedError({ message: "Tenant ID mismatch" }); + try { + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); - if (identityAzureAuth.allowedServicePrincipalIds) { - // validate if the service principal id is in the list of allowed service principal ids + if (azureIdentity.tid !== identityAzureAuth.tenantId) + throw new UnauthorizedError({ message: "Tenant ID mismatch" }); - const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds - .split(",") - .map((servicePrincipalId) => servicePrincipalId.trim()) - .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids - if (!isServicePrincipalAllowed) { - throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + + if (!isServicePrincipalAllowed) { + throw new UnauthorizedError({ message: `Service principal '${azureIdentity.oid}' not allowed` }); + } } - } - const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.AZURE_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityAzureAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityAzureAuth.accessTokenTTL, - accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.AZURE_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityAzureAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAzureAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityAzureAuth, identityAccessToken, identity }; + return { accessToken, identityAzureAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityAzureAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.AZURE_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachAzureAuth = async ({ 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 67adb6c1e..3e0035e82 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,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -18,6 +19,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -59,6 +61,7 @@ export const identityGcpAuthServiceFactory = ({ orgDAL }: TIdentityGcpAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: gcpJwt }: TLoginGcpAuthDTO) => { + const appCfg = getConfig(); const identityGcpAuth = await identityGcpAuthDAL.findOne({ identityId }); if (!identityGcpAuth) { throw new NotFoundError({ message: "GCP auth method not found for identity, did you configure GCP auth?" }); @@ -67,108 +70,140 @@ export const identityGcpAuthServiceFactory = ({ const identity = await identityDAL.findById(identityGcpAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - let gcpIdentityDetails: TGcpIdentityDetails; - switch (identityGcpAuth.type) { - case "gce": { - gcpIdentityDetails = await validateIdTokenIdentity({ - identityId, - jwt: gcpJwt - }); - break; + const org = await orgDAL.findById(identity.orgId); + try { + let gcpIdentityDetails: TGcpIdentityDetails; + switch (identityGcpAuth.type) { + case "gce": { + gcpIdentityDetails = await validateIdTokenIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + case "iam": { + gcpIdentityDetails = await validateIamIdentity({ + identityId, + jwt: gcpJwt + }); + break; + } + default: { + throw new BadRequestError({ message: "Invalid GCP Auth type" }); + } } - case "iam": { - gcpIdentityDetails = await validateIamIdentity({ - identityId, - jwt: gcpJwt - }); - break; + + if (identityGcpAuth.allowedServiceAccounts) { + // validate if the service account is in the list of allowed service accounts + + const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts + .split(",") + .map((serviceAccount) => serviceAccount.trim()) + .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + + if (!isServiceAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP service account not allowed." + }); } - default: { - throw new BadRequestError({ message: "Invalid GCP Auth type" }); + + if ( + identityGcpAuth.type === "gce" && + identityGcpAuth.allowedProjects && + gcpIdentityDetails.computeEngineDetails + ) { + // validate if the project that the service account belongs to is in the list of allowed projects + + const isProjectAllowed = identityGcpAuth.allowedProjects + .split(",") + .map((project) => project.trim()) + .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); + + if (!isProjectAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP project not allowed." + }); } - } - if (identityGcpAuth.allowedServiceAccounts) { - // validate if the service account is in the list of allowed service accounts + if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { + const isZoneAllowed = identityGcpAuth.allowedZones + .split(",") + .map((zone) => zone.trim()) + .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); - const isServiceAccountAllowed = identityGcpAuth.allowedServiceAccounts - .split(",") - .map((serviceAccount) => serviceAccount.trim()) - .some((serviceAccount) => serviceAccount === gcpIdentityDetails.email); + if (!isZoneAllowed) + throw new UnauthorizedError({ + message: "Access denied: GCP zone not allowed." + }); + } - if (!isServiceAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP service account not allowed." - }); - } - - if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedProjects && gcpIdentityDetails.computeEngineDetails) { - // validate if the project that the service account belongs to is in the list of allowed projects - - const isProjectAllowed = identityGcpAuth.allowedProjects - .split(",") - .map((project) => project.trim()) - .some((project) => project === gcpIdentityDetails.computeEngineDetails?.project_id); - - if (!isProjectAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP project not allowed." - }); - } - - if (identityGcpAuth.type === "gce" && identityGcpAuth.allowedZones && gcpIdentityDetails.computeEngineDetails) { - const isZoneAllowed = identityGcpAuth.allowedZones - .split(",") - .map((zone) => zone.trim()) - .some((zone) => zone === gcpIdentityDetails.computeEngineDetails?.zone); - - if (!isZoneAllowed) - throw new UnauthorizedError({ - message: "Access denied: GCP zone not allowed." - }); - } - - const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { - await membershipIdentityDAL.update( - { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, - { - lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const identityAccessToken = await identityGcpAuthDAL.transaction(async (tx) => { + await membershipIdentityDAL.update( + { scope: AccessScope.Organization, scopeOrgId: identity.orgId, actorIdentityId: identity.id }, + { + lastLoginAuthMethod: IdentityAuthMethod.GCP_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityGcpAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityGcpAuth.accessTokenTTL, + accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.GCP_AUTH + }, + tx + ); + return newToken; + }); + const accessToken = crypto.jwt().sign( { identityId: identityGcpAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityGcpAuth.accessTokenTTL, - accessTokenMaxTTL: identityGcpAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityGcpAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.GCP_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityGcpAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityGcpAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityGcpAuth, identityAccessToken, identity }; + return { accessToken, identityGcpAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityGcpAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.GCP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachGcpAuth = async ({ 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 debd90933..3cf93fd16 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,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import https from "https"; import jwt from "jsonwebtoken"; import { JwksClient } from "jwks-rsa"; @@ -21,6 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -67,6 +69,7 @@ export const identityJwtAuthServiceFactory = ({ orgDAL }: TIdentityJwtAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: jwtValue }: TLoginJwtAuthDTO) => { + const appCfg = getConfig(); const identityJwtAuth = await identityJwtAuthDAL.findOne({ identityId }); if (!identityJwtAuth) { throw new NotFoundError({ message: "JWT auth method not found for identity, did you configure JWT auth?" }); @@ -75,176 +78,205 @@ export const identityJwtAuthServiceFactory = ({ const identity = await identityDAL.findById(identityJwtAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); - - const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); - if (!decodedToken) { - throw new UnauthorizedError({ - message: "Invalid JWT" + const org = await orgDAL.findById(identity.orgId); + try { + const { decryptor: orgDataKeyDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId }); - } - let tokenData: Record = {}; - - if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { - let client: JwksClient; - if (identityJwtAuth.jwksUrl.includes("https:")) { - const decryptedJwksCaCert = orgDataKeyDecryptor({ - cipherTextBlob: identityJwtAuth.encryptedJwksCaCert - }).toString(); - - const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); - client = new JwksClient({ - jwksUri: identityJwtAuth.jwksUrl, - requestAgent - }); - } else { - client = new JwksClient({ - jwksUri: identityJwtAuth.jwksUrl + const decodedToken = crypto.jwt().decode(jwtValue, { complete: true }); + if (!decodedToken) { + throw new UnauthorizedError({ + message: "Invalid JWT" }); } - const { kid } = decodedToken.header as { kid: string }; - const jwtSigningKey = await client.getSigningKey(kid); + let tokenData: Record = {}; - try { - tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { - throw new UnauthorizedError({ - message: `Access denied: ${error.message}` + if (identityJwtAuth.configurationType === JwtConfigurationType.JWKS) { + let client: JwksClient; + if (identityJwtAuth.jwksUrl.includes("https:")) { + const decryptedJwksCaCert = orgDataKeyDecryptor({ + cipherTextBlob: identityJwtAuth.encryptedJwksCaCert + }).toString(); + + const requestAgent = new https.Agent({ ca: decryptedJwksCaCert, rejectUnauthorized: !!decryptedJwksCaCert }); + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl, + requestAgent + }); + } else { + client = new JwksClient({ + jwksUri: identityJwtAuth.jwksUrl }); } - throw error; - } - } else { - const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) - .toString() - .split(","); + const { kid } = decodedToken.header as { kid: string }; + const jwtSigningKey = await client.getSigningKey(kid); - const errors: string[] = []; - let isMatchAnyKey = false; - for (const publicKey of decryptedPublicKeys) { try { - tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record; - isMatchAnyKey = true; + tokenData = crypto.jwt().verify(jwtValue, jwtSigningKey.getPublicKey()) as Record; } catch (error) { if (error instanceof jwt.JsonWebTokenError) { - errors.push(error.message); + throw new UnauthorizedError({ + message: `Access denied: ${error.message}` + }); + } + + throw error; + } + } else { + const decryptedPublicKeys = orgDataKeyDecryptor({ cipherTextBlob: identityJwtAuth.encryptedPublicKeys }) + .toString() + .split(","); + + const errors: string[] = []; + let isMatchAnyKey = false; + for (const publicKey of decryptedPublicKeys) { + try { + tokenData = crypto.jwt().verify(jwtValue, publicKey) as Record; + isMatchAnyKey = true; + } catch (error) { + if (error instanceof jwt.JsonWebTokenError) { + errors.push(error.message); + } } } - } - if (!isMatchAnyKey) { - throw new UnauthorizedError({ - message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` - }); - } - } - - if (identityJwtAuth.boundIssuer) { - if (tokenData.iss !== identityJwtAuth.boundIssuer) { - throw new ForbiddenRequestError({ - message: "Access denied: issuer mismatch" - }); - } - } - - if (identityJwtAuth.boundSubject) { - if (!tokenData.sub) { - throw new UnauthorizedError({ - message: "Access denied: token has no subject field" - }); - } - - if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { - throw new ForbiddenRequestError({ - message: "Access denied: subject not allowed" - }); - } - } - - if (identityJwtAuth.boundAudiences) { - if (!tokenData.aud) { - throw new UnauthorizedError({ - message: "Access denied: token has no audience field" - }); - } - - if ( - !identityJwtAuth.boundAudiences - .split(", ") - .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) - ) { - throw new UnauthorizedError({ - message: "Access denied: token audience not allowed" - }); - } - } - - if (identityJwtAuth.boundClaims) { - Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { - const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); - - if (!value) { + if (!isMatchAnyKey) { throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` + message: `Access denied: JWT verification failed with all keys. Errors - ${errors.join("; ")}` + }); + } + } + + if (identityJwtAuth.boundIssuer) { + if (tokenData.iss !== identityJwtAuth.boundIssuer) { + throw new ForbiddenRequestError({ + message: "Access denied: issuer mismatch" + }); + } + } + + if (identityJwtAuth.boundSubject) { + if (!tokenData.sub) { + throw new UnauthorizedError({ + message: "Access denied: token has no subject field" }); } - // handle both single and multi-valued claims - if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { - throw new UnauthorizedError({ - message: `Access denied: claim mismatch for field ${claimKey}` + if (!doesFieldValueMatchJwtPolicy(tokenData.sub, identityJwtAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: subject not allowed" }); } + } + + if (identityJwtAuth.boundAudiences) { + if (!tokenData.aud) { + throw new UnauthorizedError({ + message: "Access denied: token has no audience field" + }); + } + + if ( + !identityJwtAuth.boundAudiences + .split(", ") + .some((policyValue) => doesFieldValueMatchJwtPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: token audience not allowed" + }); + } + } + + if (identityJwtAuth.boundClaims) { + Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; + const value = getValueByDot(tokenData, claimKey); + + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { + throw new UnauthorizedError({ + message: `Access denied: claim mismatch for field ${claimKey}` + }); + } + }); + } + + 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityJwtAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityJwtAuth.accessTokenTTL, + accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.JWT_AUTH + }, + tx + ); + + return newToken; }); - } - 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityJwtAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityJwtAuth.accessTokenTTL, - accessTokenMaxTTL: identityJwtAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityJwtAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.JWT_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityJwtAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityJwtAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityJwtAuth, identityAccessToken, identity }; + return { accessToken, identityJwtAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityJwtAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.JWT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachJwtAuth = async ({ 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 49fb597f5..b633dc433 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,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios, { AxiosError } from "axios"; import https from "https"; import RE2 from "re2"; @@ -37,6 +38,7 @@ import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -182,6 +184,7 @@ export const identityKubernetesAuthServiceFactory = ({ }; const login = async ({ identityId, jwt: serviceAccountJwt }: TLoginKubernetesAuthDTO) => { + const appCfg = getConfig(); const identityKubernetesAuth = await identityKubernetesAuthDAL.findOne({ identityId }); if (!identityKubernetesAuth) { throw new NotFoundError({ @@ -192,294 +195,328 @@ export const identityKubernetesAuthServiceFactory = ({ const identity = await identityDAL.findById(identityKubernetesAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); + const org = await orgDAL.findById(identity.orgId); - let caCert = ""; - if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { - caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); - } + try { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId + }); - const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { - logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); - - if (!host || !identityKubernetesAuth.kubernetesHost) { - throw new BadRequestError({ - message: "Kubernetes host is required when token review mode is set to API" - }); + let caCert = ""; + if (identityKubernetesAuth.encryptedKubernetesCaCertificate) { + caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); } - let tokenReviewerJwt = ""; - if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { - tokenReviewerJwt = decryptor({ - cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt - }).toString(); - } else { - // if no token reviewer is provided means the incoming token has to act as reviewer - tokenReviewerJwt = serviceAccountJwt; - } + const tokenReviewCallbackRaw = async (host = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); - let servername = identityKubernetesAuth.kubernetesHost; - if (servername.startsWith("https://") || servername.startsWith("http://")) { - servername = new RE2("^https?:\\/\\/").replace(servername, ""); - } + if (!host || !identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to API" + }); + } - // get the last colon index, if it has a port, remove it, including the colon - const lastColonIndex = servername.lastIndexOf(":"); - if (lastColonIndex !== -1) { - servername = servername.substring(0, lastColonIndex); - } + let tokenReviewerJwt = ""; + if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { + tokenReviewerJwt = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt + }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; + } - const baseUrl = port ? `${host}:${port}` : host; + let servername = identityKubernetesAuth.kubernetesHost; + if (servername.startsWith("https://") || servername.startsWith("http://")) { + servername = new RE2("^https?:\\/\\/").replace(servername, ""); + } - const res = await axios - .post( - `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenReviewerJwt}` + // get the last colon index, if it has a port, remove it, including the colon + const lastColonIndex = servername.lastIndexOf(":"); + if (lastColonIndex !== -1) { + servername = servername.substring(0, lastColonIndex); + } + + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience + ? { audiences: [identityKubernetesAuth.allowedAudience] } + : {}) + } }, - signal: AbortSignal.timeout(10000), - timeout: 10000, - httpsAgent: new https.Agent({ - ca: caCert, - rejectUnauthorized: Boolean(caCert), - servername - }) - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - const { message } = err?.response?.data as unknown as { message?: string }; - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } - throw err; - }); - - return res.data; - }; - - const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => { - logger.info( - { - host, - port - }, - "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" - ); - - const res = await axios - .post( - `${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`, - { - apiVersion: "authentication.k8s.io/v1", - kind: "TokenReview", - spec: { - token: serviceAccountJwt, - ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) - } - }, - { - headers: { - "Content-Type": "application/json", - "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount - }, - signal: AbortSignal.timeout(10000), - timeout: 10000 - } - ) - .catch((err) => { - if (err instanceof AxiosError) { - if (err.response) { - let { message } = err?.response?.data as unknown as { message?: string }; - - if (!message && typeof err.response.data === "string") { - message = err.response.data; - } - - if (message) { - throw new UnauthorizedError({ - message, - name: "KubernetesTokenReviewRequestError" - }); - } - } - } - throw err; - }); - - return res.data; - }; - - let data: TCreateTokenReviewResponse | undefined; - - if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { - if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { - throw new BadRequestError({ - message: "Gateway ID is required when token review mode is set to Gateway" - }); - } - - data = await $gatewayProxyWrapper( - { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, - reviewTokenThroughGateway: true - }, - tokenReviewCallbackThroughGateway - ); - } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { - if (!identityKubernetesAuth.kubernetesHost) { - throw new BadRequestError({ - message: "Kubernetes host is required when token review mode is set to API" - }); - } - - let { kubernetesHost } = identityKubernetesAuth; - if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { - kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); - } - - const [k8sHost, k8sPort] = kubernetesHost.split(":"); - - data = - identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id - ? await $gatewayProxyWrapper( - { - gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443, - reviewTokenThroughGateway: false + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokenReviewerJwt}` }, - tokenReviewCallbackRaw - ) - : await tokenReviewCallbackRaw(); - } else { - throw new BadRequestError({ - message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` - }); - } + signal: AbortSignal.timeout(10000), + timeout: 10000, + httpsAgent: new https.Agent({ + ca: caCert, + rejectUnauthorized: Boolean(caCert), + servername + }) + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + const { message } = err?.response?.data as unknown as { message?: string }; - if (!data) { - throw new BadRequestError({ - message: "Failed to review token" - }); - } + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); - if ("error" in data.status) - throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); + return res.data; + }; - // check the response to determine if the token is valid - if (!(data.status && data.status.authenticated)) - throw new UnauthorizedError({ - message: "Kubernetes token not authenticated", - name: "KubernetesTokenReviewError" + const tokenReviewCallbackThroughGateway = async (host: string, port?: number) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); + + const res = await axios + .post( + `${host}:${port}/apis/authentication.k8s.io/v1/tokenreviews`, + { + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience + ? { audiences: [identityKubernetesAuth.allowedAudience] } + : {}) + } + }, + { + headers: { + "Content-Type": "application/json", + "x-infisical-action": GatewayHttpProxyActions.UseGatewayK8sServiceAccount + }, + signal: AbortSignal.timeout(10000), + timeout: 10000 + } + ) + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + let { message } = err?.response?.data as unknown as { message?: string }; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + return res.data; + }; + + let data: TCreateTokenReviewResponse | undefined; + + if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + if (!identityKubernetesAuth.gatewayId && !identityKubernetesAuth.gatewayV2Id) { + throw new BadRequestError({ + message: "Gateway ID is required when token review mode is set to Gateway" + }); + } + + data = await $gatewayProxyWrapper( + { + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + if (!identityKubernetesAuth.kubernetesHost) { + throw new BadRequestError({ + message: "Kubernetes host is required when token review mode is set to API" + }); + } + + let { kubernetesHost } = identityKubernetesAuth; + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); + + data = + identityKubernetesAuth.gatewayId || identityKubernetesAuth.gatewayV2Id + ? await $gatewayProxyWrapper( + { + gatewayId: (identityKubernetesAuth.gatewayV2Id ?? identityKubernetesAuth.gatewayId) as string, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); + } else { + throw new BadRequestError({ + message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` + }); + } + + if (!data) { + throw new BadRequestError({ + message: "Failed to review token" + }); + } + + if ("error" in data.status) + throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); + + // check the response to determine if the token is valid + if (!(data.status && data.status.authenticated)) + throw new UnauthorizedError({ + message: "Kubernetes token not authenticated", + name: "KubernetesTokenReviewError" + }); + + const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); + + if (identityKubernetesAuth.allowedNamespaces) { + // validate if [targetNamespace] is in the list of allowed namespaces + + const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces + .split(",") + .map((namespace) => namespace.trim()) + .some((namespace) => namespace === targetNamespace); + + if (!isNamespaceAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s namespace not allowed." + }); + } + + if (identityKubernetesAuth.allowedNames) { + // validate if [targetName] is in the list of allowed names + + const isNameAllowed = identityKubernetesAuth.allowedNames + .split(",") + .map((name) => name.trim()) + .some((name) => name === targetName); + + if (!isNameAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s name not allowed." + }); + } + + if (identityKubernetesAuth.allowedAudience) { + // validate if [audience] is in the list of allowed audiences + const isAudienceAllowed = data.status.audiences.some( + (audience) => audience === identityKubernetesAuth.allowedAudience + ); + + if (!isAudienceAllowed) + throw new UnauthorizedError({ + message: "Access denied: K8s audience not allowed." + }); + } + + 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityKubernetesAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityKubernetesAuth.accessTokenTTL, + accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.KUBERNETES_AUTH + }, + tx + ); + return newToken; }); - const { namespace: targetNamespace, name: targetName } = extractK8sUsername(data.status.user.username); - - if (identityKubernetesAuth.allowedNamespaces) { - // validate if [targetNamespace] is in the list of allowed namespaces - - const isNamespaceAllowed = identityKubernetesAuth.allowedNamespaces - .split(",") - .map((namespace) => namespace.trim()) - .some((namespace) => namespace === targetNamespace); - - if (!isNamespaceAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s namespace not allowed." - }); - } - - if (identityKubernetesAuth.allowedNames) { - // validate if [targetName] is in the list of allowed names - - const isNameAllowed = identityKubernetesAuth.allowedNames - .split(",") - .map((name) => name.trim()) - .some((name) => name === targetName); - - if (!isNameAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s name not allowed." - }); - } - - if (identityKubernetesAuth.allowedAudience) { - // validate if [audience] is in the list of allowed audiences - const isAudienceAllowed = data.status.audiences.some( - (audience) => audience === identityKubernetesAuth.allowedAudience - ); - - if (!isAudienceAllowed) - throw new UnauthorizedError({ - message: "Access denied: K8s audience not allowed." - }); - } - - 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityKubernetesAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityKubernetesAuth.accessTokenTTL, - accessTokenMaxTTL: identityKubernetesAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityKubernetesAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.KUBERNETES_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + kubernetes: { + namespace: targetNamespace, + name: targetName + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityKubernetesAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - kubernetes: { - namespace: targetNamespace, - name: targetName - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityKubernetesAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; + return { accessToken, identityKubernetesAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityKubernetesAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.KUBERNETES_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachKubernetesAuth = async ({ @@ -719,7 +756,8 @@ export const identityKubernetesAuthServiceFactory = ({ ); } - const shouldUpdateGatewayId = Boolean(gatewayId); + // Strict check to see if gateway ID is undefined. It should update the gateway ID to null if its strictly set to null. + const shouldUpdateGatewayId = Boolean(gatewayId !== undefined); const gatewayIdValue = isGatewayV1 ? gatewayId : null; const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; 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 272e45c4e..d327dabee 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,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import slugify from "@sindresorhus/slugify"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; @@ -29,6 +30,7 @@ import { } 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 { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -151,6 +153,7 @@ export const identityLdapAuthServiceFactory = ({ }; const login = async ({ identityId }: TLoginLdapAuthDTO) => { + const appCfg = getConfig(); const identityLdapAuth = await identityLdapAuthDAL.findOne({ identityId }); if (!identityLdapAuth) { @@ -162,6 +165,7 @@ export const identityLdapAuthServiceFactory = ({ const identity = await identityDAL.findById(identityLdapAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); + const org = await orgDAL.findById(identity.orgId); const plan = await licenseService.getPlan(identity.orgId); if (!plan.ldap) { throw new BadRequestError({ @@ -170,44 +174,72 @@ export const identityLdapAuthServiceFactory = ({ }); } - 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityLdapAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityLdapAuth.accessTokenTTL, + accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.LDAP_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityLdapAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityLdapAuth.accessTokenTTL, - accessTokenMaxTTL: identityLdapAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityLdapAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.LDAP_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityLdapAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityLdapAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { accessToken, identityLdapAuth, identityAccessToken, identity }; + return { accessToken, identityLdapAuth, identityAccessToken, identity }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityLdapAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.LDAP_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachLdapAuth = async ({ 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 6d7f0c4d3..c75abc76b 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,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AxiosError } from "axios"; import RE2 from "re2"; @@ -23,6 +24,7 @@ import { } 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 { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -63,6 +65,7 @@ export const identityOciAuthServiceFactory = ({ orgDAL }: TIdentityOciAuthServiceFactoryDep) => { const login = async ({ identityId, headers, userOcid }: TLoginOciAuthDTO) => { + const appCfg = getConfig(); const identityOciAuth = await identityOciAuthDAL.findOne({ identityId }); if (!identityOciAuth) { throw new NotFoundError({ message: "OCI auth method not found for identity, did you configure OCI auth?" }); @@ -71,80 +74,109 @@ export const identityOciAuthServiceFactory = ({ const identity = await identityDAL.findById(identityOciAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. - if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { - throw new BadRequestError({ - message: "Invalid OCI host format. Expected format: identity..oraclecloud.com" - }); - } - - const { data } = await request - .get(`https://${headers.host}/20160918/users/${userOcid}`, { - headers - }) - .catch((err: AxiosError) => { - logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud"); - throw err; - }); - - if (data.compartmentId !== identityOciAuth.tenancyOcid) { - throw new UnauthorizedError({ - message: "Access denied: OCI account isn't part of tenancy." - }); - } - - if (identityOciAuth.allowedUsernames) { - const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name); - - if (!isAccountAllowed) - throw new UnauthorizedError({ - message: "Access denied: OCI account username not allowed." + const org = await orgDAL.findById(identity.orgId); + try { + // Validate OCI host format. Ensures that the host is in "identity..oraclecloud.com" format. + if (!headers.host || !new RE2("^identity\\.([a-z]{2}-[a-z]+-[1-9])\\.oraclecloud\\.com$").test(headers.host)) { + throw new BadRequestError({ + message: "Invalid OCI host format. Expected format: identity..oraclecloud.com" }); - } + } - // 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const { data } = await request + .get(`https://${headers.host}/20160918/users/${userOcid}`, { + headers + }) + .catch((err: AxiosError) => { + logger.error(err.response, "OciIdentityLogin: Failed to authenticate with Oracle Cloud"); + throw err; + }); + + if (data.compartmentId !== identityOciAuth.tenancyOcid) { + throw new UnauthorizedError({ + message: "Access denied: OCI account isn't part of tenancy." + }); + } + + if (identityOciAuth.allowedUsernames) { + const isAccountAllowed = identityOciAuth.allowedUsernames.split(",").some((name) => name.trim() === data.name); + + if (!isAccountAllowed) + throw new UnauthorizedError({ + message: "Access denied: OCI account username not allowed." + }); + } + + // 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOciAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOciAuth.accessTokenTTL, + accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.OCI_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityOciAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityOciAuth.accessTokenTTL, - accessTokenMaxTTL: identityOciAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityOciAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OCI_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityOciAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOciAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityOciAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityOciAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOciAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OCI_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachOciAuth = async ({ 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 628b69f14..a03beeb3b 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,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import axios from "axios"; import https from "https"; import jwt from "jsonwebtoken"; @@ -22,6 +23,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; @@ -67,6 +69,7 @@ export const identityOidcAuthServiceFactory = ({ orgDAL }: TIdentityOidcAuthServiceFactoryDep) => { const login = async ({ identityId, jwt: oidcJwt }: TLoginOidcAuthDTO) => { + const appCfg = getConfig(); const identityOidcAuth = await identityOidcAuthDAL.findOne({ identityId }); if (!identityOidcAuth) { throw new NotFoundError({ message: "OIDC auth method not found for identity, did you configure OIDC auth?" }); @@ -75,151 +78,180 @@ export const identityOidcAuthServiceFactory = ({ const identity = await identityDAL.findById(identityOidcAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); - - let caCert = ""; - if (identityOidcAuth.encryptedCaCertificate) { - caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString(); - } - - const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); - const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( - `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, - { - httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined - } - ); - const jwksUri = discoveryDoc.jwks_uri; - - const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true }); - if (!decodedToken) { - throw new UnauthorizedError({ - message: "Invalid JWT" - }); - } - - const client = new JwksClient({ - jwksUri, - requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined - }); - - const { kid } = decodedToken.header as { kid: string }; - const oidcSigningKey = await client.getSigningKey(kid); - - let tokenData: Record; + const org = await orgDAL.findById(identity.orgId); try { - tokenData = crypto.jwt().verify(oidcJwt, oidcSigningKey.getPublicKey(), { - issuer: identityOidcAuth.boundIssuer - }) as Record; - } catch (error) { - if (error instanceof jwt.JsonWebTokenError) { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId + }); + + let caCert = ""; + if (identityOidcAuth.encryptedCaCertificate) { + caCert = decryptor({ cipherTextBlob: identityOidcAuth.encryptedCaCertificate }).toString(); + } + + const requestAgent = new https.Agent({ ca: caCert, rejectUnauthorized: !!caCert }); + const { data: discoveryDoc } = await axios.get<{ jwks_uri: string }>( + `${identityOidcAuth.oidcDiscoveryUrl}/.well-known/openid-configuration`, + { + httpsAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined + } + ); + const jwksUri = discoveryDoc.jwks_uri; + + const decodedToken = crypto.jwt().decode(oidcJwt, { complete: true }); + if (!decodedToken) { throw new UnauthorizedError({ - message: `Access denied: ${error.message}` + message: "Invalid JWT" + }); + } + + const client = new JwksClient({ + jwksUri, + requestAgent: identityOidcAuth.oidcDiscoveryUrl.includes("https") ? requestAgent : undefined + }); + + const { kid } = decodedToken.header as { kid: string }; + const oidcSigningKey = await client.getSigningKey(kid); + + 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; + } + + if (identityOidcAuth.boundSubject) { + if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { + throw new ForbiddenRequestError({ + message: "Access denied: OIDC subject not allowed." + }); + } + } + + if (identityOidcAuth.boundAudiences) { + if ( + !identityOidcAuth.boundAudiences + .split(", ") + .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) + ) { + throw new UnauthorizedError({ + message: "Access denied: OIDC audience not allowed." + }); + } + } + + if (identityOidcAuth.boundClaims) { + Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { + const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; + const value = getValueByDot(tokenData, claimKey); + + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + + // handle both single and multi-valued claims + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) { + throw new UnauthorizedError({ + message: "Access denied: OIDC claim not allowed." + }); + } + }); + } + + const filteredClaims: Record = {}; + if (identityOidcAuth.claimMetadataMapping) { + Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { + const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; + const value = getValueByDot(tokenData, claimKey); + if (!value) { + throw new UnauthorizedError({ + message: `Access denied: token has no ${claimKey} field` + }); + } + filteredClaims[permissionKey] = value.toString(); + }); + } + + 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityOidcAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityOidcAuth.accessTokenTTL, + accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.OIDC_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( + { + identityId: identityOidcAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, + identityAuth: { + oidc: { + claims: filteredClaims + } + } + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOidcAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + + return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityOidcAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.OIDC_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") }); } throw error; } - - if (identityOidcAuth.boundSubject) { - if (!doesFieldValueMatchOidcPolicy(tokenData.sub, identityOidcAuth.boundSubject)) { - throw new ForbiddenRequestError({ - message: "Access denied: OIDC subject not allowed." - }); - } - } - - if (identityOidcAuth.boundAudiences) { - if ( - !identityOidcAuth.boundAudiences - .split(", ") - .some((policyValue) => doesAudValueMatchOidcPolicy(tokenData.aud, policyValue)) - ) { - throw new UnauthorizedError({ - message: "Access denied: OIDC audience not allowed." - }); - } - } - - if (identityOidcAuth.boundClaims) { - Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { - const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getValueByDot(tokenData, claimKey); - - if (!value) { - throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` - }); - } - - // handle both single and multi-valued claims - if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchOidcPolicy(value, claimEntry))) { - throw new UnauthorizedError({ - message: "Access denied: OIDC claim not allowed." - }); - } - }); - } - - const filteredClaims: Record = {}; - if (identityOidcAuth.claimMetadataMapping) { - Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { - const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getValueByDot(tokenData, claimKey); - if (!value) { - throw new UnauthorizedError({ - message: `Access denied: token has no ${claimKey} field` - }); - } - filteredClaims[permissionKey] = value.toString(); - }); - } - - 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( - { - identityId: identityOidcAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityOidcAuth.accessTokenTTL, - accessTokenMaxTTL: identityOidcAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityOidcAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.OIDC_AUTH - }, - tx - ); - return newToken; - }); - - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityOidcAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN, - identityAuth: { - oidc: { - claims: filteredClaims - } - } - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { accessToken, identityOidcAuth, identityAccessToken, identity, oidcTokenData: tokenData }; }; const attachOidcAuth = 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 24c82ccac..60670d035 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,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -19,6 +20,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -27,6 +29,7 @@ import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identit import { TKmsServiceFactory } from "../kms/kms-service"; import { KmsDataKey } from "../kms/kms-types"; import { TMembershipIdentityDALFactory } from "../membership-identity/membership-identity-dal"; +import { TOrgDALFactory } from "../org/org-dal"; import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/super-admin-fns"; import { TIdentityTlsCertAuthDALFactory } from "./identity-tls-cert-auth-dal"; import { TIdentityTlsCertAuthServiceFactory } from "./identity-tls-cert-auth-types"; @@ -42,6 +45,7 @@ type TIdentityTlsCertAuthServiceFactoryDep = { licenseService: Pick; permissionService: Pick; kmsService: Pick; + orgDAL: Pick; }; const parseSubjectDetails = (data: string) => { @@ -60,9 +64,11 @@ export const identityTlsCertAuthServiceFactory = ({ membershipIdentityDAL, licenseService, permissionService, - kmsService + kmsService, + orgDAL }: TIdentityTlsCertAuthServiceFactoryDep): TIdentityTlsCertAuthServiceFactory => { const login: TIdentityTlsCertAuthServiceFactory["login"] = async ({ identityId, clientCertificate }) => { + const appCfg = getConfig(); const identityTlsCertAuth = await identityTlsCertAuthDAL.findOne({ identityId }); if (!identityTlsCertAuth) { throw new NotFoundError({ @@ -73,94 +79,124 @@ export const identityTlsCertAuthServiceFactory = ({ const identity = await identityDAL.findById(identityTlsCertAuth.identityId); if (!identity) throw new UnauthorizedError({ message: "Identity not found" }); - const { decryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.Organization, - orgId: identity.orgId - }); + const org = await orgDAL.findById(identity.orgId); - const caCertificate = decryptor({ - cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate - }).toString(); - - const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; - if (!leafCertificate) { - throw new BadRequestError({ message: "Missing client certificate" }); - } - - const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate); - const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate); - - const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); - if (!isValidCertificate) - throw new UnauthorizedError({ - message: "Access denied: Certificate not issued by the provided CA." + try { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: identity.orgId }); - if (new Date(clientCertificateX509.validTo) < new Date()) { - throw new UnauthorizedError({ - message: "Access denied: Certificate has expired." - }); - } + const caCertificate = decryptor({ + cipherTextBlob: identityTlsCertAuth.encryptedCaCertificate + }).toString(); - if (new Date(clientCertificateX509.validFrom) > new Date()) { - throw new UnauthorizedError({ - message: "Access denied: Certificate not yet valid." - }); - } + const leafCertificate = extractX509CertFromChain(decodeURIComponent(clientCertificate))?.[0]; + if (!leafCertificate) { + throw new BadRequestError({ message: "Missing client certificate" }); + } - const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); - if (identityTlsCertAuth.allowedCommonNames) { - const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN); - if (!isValidCommonName) { + const clientCertificateX509 = new crypto.nativeCrypto.X509Certificate(leafCertificate); + const caCertificateX509 = new crypto.nativeCrypto.X509Certificate(caCertificate); + + const isValidCertificate = clientCertificateX509.verify(caCertificateX509.publicKey); + if (!isValidCertificate) throw new UnauthorizedError({ - message: "Access denied: TLS Certificate Auth common name not allowed." + message: "Access denied: Certificate not issued by the provided CA." + }); + + if (new Date(clientCertificateX509.validTo) < new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate has expired." }); } - } - // 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() }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + if (new Date(clientCertificateX509.validFrom) > new Date()) { + throw new UnauthorizedError({ + message: "Access denied: Certificate not yet valid." + }); + } + + const subjectDetails = parseSubjectDetails(clientCertificateX509.subject); + if (identityTlsCertAuth.allowedCommonNames) { + const isValidCommonName = identityTlsCertAuth.allowedCommonNames.split(",").includes(subjectDetails.CN); + if (!isValidCommonName) { + throw new UnauthorizedError({ + message: "Access denied: TLS Certificate Auth common name not allowed." + }); + } + } + + // 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() }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityTlsCertAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityTlsCertAuth.accessTokenTTL, + accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, + authMethod: IdentityAuthMethod.TLS_CERT_AUTH + }, + tx + ); + return newToken; + }); + + const accessToken = crypto.jwt().sign( { identityId: identityTlsCertAuth.identityId, - isAccessTokenRevoked: false, - accessTokenTTL: identityTlsCertAuth.accessTokenTTL, - accessTokenMaxTTL: identityTlsCertAuth.accessTokenMaxTTL, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityTlsCertAuth.accessTokenNumUsesLimit, - authMethod: IdentityAuthMethod.TLS_CERT_AUTH - }, - tx + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityTlsCertAuth.identityId, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityTlsCertAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - return { - identityTlsCertAuth, - accessToken, - identityAccessToken, - identity - }; + return { + identityTlsCertAuth, + accessToken, + identityAccessToken, + identity + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityTlsCertAuth.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.TLS_CERT_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachTlsCertAuth: TIdentityTlsCertAuthServiceFactory["attachTlsCertAuth"] = async ({ diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index 00ab1610d..26bd01627 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -1,4 +1,5 @@ import { ForbiddenError } from "@casl/ability"; +import { requestContext } from "@fastify/request-context"; import { AccessScope, IdentityAuthMethod, OrganizationActionScope } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; @@ -21,6 +22,7 @@ import { } from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; import { logger } from "@app/lib/logger"; +import { AuthAttemptAuthMethod, AuthAttemptAuthResult, authAttemptCounter } from "@app/lib/telemetry/metrics"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityDALFactory } from "../identity/identity-dal"; @@ -77,6 +79,7 @@ export const identityUaServiceFactory = ({ identityDAL }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { + const appCfg = getConfig(); const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { throw new UnauthorizedError({ @@ -84,196 +87,226 @@ export const identityUaServiceFactory = ({ }); } - checkIPAgainstBlocklist({ - ipAddress: ip, - trustedIps: identityUa.clientSecretTrustedIps as TIp[] - }); + const identity = await identityDAL.findById(identityUa.identityId); + const org = await orgDAL.findById(identity.orgId); - const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - - const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - - let lockout: LockoutObject | undefined; - if (lockoutRaw) { - lockout = JSON.parse(lockoutRaw) as LockoutObject; - } - - if (lockout && lockout.lockedOut) { - throw new UnauthorizedError({ - message: "This identity auth method is temporarily locked, please try again later" + try { + checkIPAgainstBlocklist({ + ipAddress: ip, + trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); - } - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecretInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); + const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; - for await (const info of clientSecretInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - if (isMatch) { - validClientSecretInfo = info; - break; + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; } - } - if (!validClientSecretInfo) { - if (identityUa.lockoutEnabled) { - let lock: Awaited> | undefined; - try { - lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { - retryCount: 3, - retryDelay: 300, - retryJitter: 100 - }); + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } - // Re-fetch the latest lockout data while holding the lock - const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); - if (lockoutRawNew) { - lockout = JSON.parse(lockoutRawNew) as LockoutObject; - } else { - lockout = { - lockedOut: false, - failedAttempts: 0 - }; - } + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); - if (lockout.lockedOut) { - throw new UnauthorizedError({ - message: "This identity auth method is temporarily locked, please try again later" - }); - } + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); - lockout.failedAttempts += 1; - if (lockout.failedAttempts >= identityUa.lockoutThreshold) { - lockout.lockedOut = true; - } - - await keyStore.setItemWithExpiry( - LOCKOUT_KEY, - lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, - JSON.stringify(lockout) - ); - } catch (e) { - if (lock === undefined) { - logger.info( - `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` - ); - throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); - } - throw e; - } finally { - if (lock) { - await lock.release(); - } + if (isMatch) { + validClientSecretInfo = info; + break; } } - throw new UnauthorizedError({ message: "Invalid credentials" }); - } else if (lockout) { - // If credentials are valid, clear any existing lockout record - await keyStore.deleteItem(LOCKOUT_KEY); - } + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + let lock: Awaited> | undefined; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 300, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + // Re-fetch the latest lockout data while holding the lock + const lockoutRawNew = await keyStore.getItem(LOCKOUT_KEY); + if (lockoutRawNew) { + lockout = JSON.parse(lockoutRawNew) as LockoutObject; + } else { + lockout = { + lockedOut: false, + failedAttempts: 0 + }; + } - if (currentDate > expirationTime) { + if (lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityUa.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, + JSON.stringify(lockout) + ); + } catch (e) { + if (lock === undefined) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Failed to acquire lock: rate limit exceeded" }); + } + throw e; + } finally { + if (lock) { + await lock.release(); + } + } + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + // If credentials are valid, clear any existing lockout record + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Access denied due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" + message: "Access denied due to client secret usage limit reached" }); } - } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; + + 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 }, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityUa.identityId, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx + ); + + return newToken; }); - throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" - }); - } - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identity = await identityDAL.findById(identityUa.identityId); - 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 }, - { - lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityUa.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.SUCCESS, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { - accessToken, - identityUa, - validClientSecretInfo, - identityAccessToken, - identity, - ...accessTokenTTLParams - }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identity, + ...accessTokenTTLParams + }; + } catch (error) { + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + authAttemptCounter.add(1, { + "infisical.identity.id": identityUa.identityId, + "infisical.identity.name": identity.name, + "infisical.organization.id": org.id, + "infisical.organization.name": org.name, + "infisical.identity.auth_method": AuthAttemptAuthMethod.UNIVERSAL_AUTH, + "infisical.identity.auth_result": AuthAttemptAuthResult.FAILURE, + "client.address": requestContext.get("ip"), + "user_agent.original": requestContext.get("userAgent") + }); + } + throw error; + } }; const attachUniversalAuth = async ({ diff --git a/backend/src/services/kms/kms-key-dal.ts b/backend/src/services/kms/kms-key-dal.ts index a0dd12191..36ffa3366 100644 --- a/backend/src/services/kms/kms-key-dal.ts +++ b/backend/src/services/kms/kms-key-dal.ts @@ -112,7 +112,8 @@ export const kmskeyDALFactory = (db: TDbClient) => { ...KmsKeysSchema.parse(entry), isActive: !entry.isDisabled, algorithm: entry.internalKmsEncryptionAlgorithm, - version: entry.internalKmsVersion + version: entry.internalKmsVersion, + kmipMetadata: entry.kmipMetadata as Record })); } catch (error) { throw new DatabaseError({ error, name: "Find project cmeks" }); diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 4e5b48006..8f868978d 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -12,6 +12,7 @@ import { TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service"; +import { THsmStatus } from "@app/ee/services/hsm/hsm-types"; import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { TEnvConfig } from "@app/lib/config/env"; import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; @@ -400,13 +401,6 @@ export const kmsServiceFactory = ({ const cipher = symmetricCipherService(SymmetricKeyAlgorithm.AES_GCM_256); - const expectedByteLength = getByteLengthForSymmetricEncryptionAlgorithm(algorithm as SymmetricKeyAlgorithm); - if (key.byteLength !== expectedByteLength) { - throw new BadRequestError({ - message: `Invalid key length for ${algorithm}. Expected ${expectedByteLength} bytes but got ${key.byteLength} bytes` - }); - } - const encryptedKeyMaterial = cipher.encrypt(key, ROOT_ENCRYPTION_KEY); const sanitizedName = name ? slugify(name) : slugify(alphaNumericNanoId(8).toLowerCase()); const dbQuery = async (db: Knex) => { @@ -1077,17 +1071,22 @@ export const kmsServiceFactory = ({ return { id, name, orgId, isExternal }; }; - const startService = async () => { + const startService = async (hsmStatus: THsmStatus) => { const kmsRootConfig = await kmsRootConfigDAL.transaction(async (tx) => { await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.KmsRootKeyInit]); // check if KMS root key was already generated and saved in DB const existingRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); if (existingRootConfig) return existingRootConfig; - logger.info("KMS: Generating new ROOT Key"); - const newRootKey = crypto.randomBytes(32); - const encryptedRootKey = await $encryptRootKey(newRootKey, RootKeyEncryptionStrategy.Software).catch((err) => { - logger.error({ hsmEnabled: hsmService.isActive() }, "KMS: Failed to encrypt ROOT Key"); + const isHsmActive = hsmStatus.isHsmConfigured; + + logger.info(`KMS: Generating new ROOT Key with ${isHsmActive ? "HSM" : "software"} encryption`); + const newRootKey = isHsmActive ? await hsmService.randomBytes(32) : crypto.randomBytes(32); + + const encryptionStrategy = isHsmActive ? RootKeyEncryptionStrategy.HSM : RootKeyEncryptionStrategy.Software; + + const encryptedRootKey = await $encryptRootKey(newRootKey, encryptionStrategy).catch((err) => { + logger.error({ hsmEnabled: isHsmActive, encryptionStrategy }, "KMS: Failed to encrypt ROOT Key"); throw err; }); @@ -1095,7 +1094,7 @@ export const kmsServiceFactory = ({ // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition id: KMS_ROOT_CONFIG_UUID, encryptedRootKey, - encryptionStrategy: RootKeyEncryptionStrategy.Software + encryptionStrategy }); return newRootConfig; }); @@ -1117,6 +1116,15 @@ export const kmsServiceFactory = ({ return; } + if (strategy === RootKeyEncryptionStrategy.Software) { + if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) { + throw new BadRequestError({ + message: + "Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment before trying to update the encryption strategy to software mode." + }); + } + } + const decryptedRootKey = await $decryptRootKey(kmsRootConfig); const encryptedRootKey = await $encryptRootKey(decryptedRootKey, strategy); diff --git a/backend/src/services/membership-group/membership-group-service.ts b/backend/src/services/membership-group/membership-group-service.ts index 18f6b3ad1..0aedccd15 100644 --- a/backend/src/services/membership-group/membership-group-service.ts +++ b/backend/src/services/membership-group/membership-group-service.ts @@ -1,5 +1,15 @@ -import { AccessScope, ProjectMembershipRole, TemporaryPermissionMode, TMembershipRolesInsert } from "@app/db/schemas"; +import { + AccessScope, + ProjectMembershipRole, + TableName, + TemporaryPermissionMode, + TMembershipRolesInsert +} from "@app/db/schemas"; +import { TAccessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { TAccessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TSecretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; +import { TSecretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { ms } from "@app/lib/ms"; @@ -23,6 +33,10 @@ import { newProjectMembershipGroupFactory } from "./project/project-membership-g type TMembershipGroupServiceFactoryDep = { membershipGroupDAL: TMembershipGroupDALFactory; membershipRoleDAL: Pick; + accessApprovalPolicyDAL: Pick; + accessApprovalPolicyApproverDAL: Pick; + secretApprovalPolicyDAL: Pick; + secretApprovalPolicyApproverDAL: Pick; roleDAL: Pick; permissionService: TPermissionServiceFactory; orgDAL: TOrgDALFactory; @@ -33,6 +47,10 @@ export type TMembershipGroupServiceFactory = ReturnType policyId); + if (accessApprovalPolicyApprovers.length > 0) { + const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ + $in: { + [`${TableName.AccessApprovalPolicy}.id` as "id"]: [...new Set(accessApprovalPolicyApproverGroupIds)] + }, + projectId: existingMembership.scopeProjectId ?? undefined, + deletedAt: null + }); + + if (accessApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "This group is assigned to an approval policy and cannot be deleted" + }); + } + } + + // check if group is assigned to any secret approval policy + const secretApprovalPolicyApprovers = await secretApprovalPolicyApproverDAL.find({ + approverGroupId: dto.selector.groupId + }); + const secretApprovalPolicyApproverGroupIds = secretApprovalPolicyApprovers.map(({ policyId }) => policyId); + if (secretApprovalPolicyApprovers.length > 0) { + const secretApprovalPolicies = await secretApprovalPolicyDAL.find({ + $in: { + [`${TableName.SecretApprovalPolicy}.id` as "id"]: [...new Set(secretApprovalPolicyApproverGroupIds)] + }, + projectId: existingMembership.scopeProjectId ?? undefined, + deletedAt: null + }); + if (secretApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "This group is assigned to a secret approval policy and cannot be deleted" + }); + } + } + const membershipDoc = await membershipGroupDAL.transaction(async (tx) => { await membershipRoleDAL.delete({ membershipId: existingMembership.id }, tx); const doc = await membershipGroupDAL.deleteById(existingMembership.id, tx); diff --git a/backend/src/services/org/org-bot-dal.ts b/backend/src/services/org/org-bot-dal.ts deleted file mode 100644 index b2ee54758..000000000 --- a/backend/src/services/org/org-bot-dal.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; -import { ormify } from "@app/lib/knex"; - -export type TOrgBotDALFactory = ReturnType; - -export const orgBotDALFactory = (db: TDbClient) => { - const orgBotOrm = ormify(db, TableName.OrgBot); - return orgBotOrm; -}; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 4a3bdb06e..be5c300b5 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ scannerProductEnabled: true, shareSecretsProductEnabled: true, maxSharedSecretLifetime: true, - maxSharedSecretViewLimit: true + maxSharedSecretViewLimit: true, + blockDuplicateSecretSyncDestinations: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 20ed37a06..b0bbd9a2a 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -58,7 +58,6 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge- import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TIncidentContactsDALFactory } from "./incident-contacts-dal"; -import { TOrgBotDALFactory } from "./org-bot-dal"; import { TOrgDALFactory } from "./org-dal"; import { deleteOrgMembershipsFn } from "./org-fns"; import { @@ -82,7 +81,6 @@ type TOrgServiceFactoryDep = { secretV2BridgeDAL: Pick; folderDAL: Pick; orgDAL: TOrgDALFactory; - orgBotDAL: TOrgBotDALFactory; roleDAL: TRoleDALFactory; userDAL: TUserDALFactory; groupDAL: TGroupDALFactory; @@ -136,7 +134,6 @@ export const orgServiceFactory = ({ projectKeyDAL, orgMembershipDAL, tokenService, - orgBotDAL, licenseService, samlConfigDAL, oidcConfigDAL, @@ -408,7 +405,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -519,7 +517,7 @@ export const orgServiceFactory = ({ if (slug) { const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null }); if (existingOrg && existingOrg?.id !== orgId) - throw new BadRequestError({ message: `Organization with slug ${slug} already exist` }); + throw new BadRequestError({ message: `Organization with slug ${slug} already exists` }); } if (googleSsoAuthEnforced) { @@ -592,7 +590,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; @@ -612,23 +611,6 @@ export const orgServiceFactory = ({ }, trx?: Knex ) => { - const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair(); - const key = crypto.randomBytes(32).toString("base64"); - const { - ciphertext: encryptedPrivateKey, - iv: privateKeyIV, - tag: privateKeyTag, - encoding: privateKeyKeyEncoding, - algorithm: privateKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey); - const { - ciphertext: encryptedSymmetricKey, - iv: symmetricKeyIV, - tag: symmetricKeyTag, - encoding: symmetricKeyKeyEncoding, - algorithm: symmetricKeyAlgorithm - } = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key); - const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail); const createOrg = async (tx: Knex) => { @@ -656,24 +638,7 @@ export const orgServiceFactory = ({ tx ); } - await orgBotDAL.create( - { - name: org.name, - publicKey, - privateKeyIV, - encryptedPrivateKey, - symmetricKeyIV, - symmetricKeyTag, - encryptedSymmetricKey, - symmetricKeyAlgorithm, - orgId: org.id, - privateKeyTag, - privateKeyAlgorithm, - privateKeyKeyEncoding, - symmetricKeyKeyEncoding - }, - tx - ); + return org; }; @@ -1184,7 +1149,7 @@ export const orgServiceFactory = ({ const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); if (doesIncidentContactExist) { throw new BadRequestError({ - message: "Incident contact already exist", + message: "Incident contact already exists", name: "Incident contact exist" }); } diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 48680456c..2587045bb 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -90,6 +90,7 @@ export type TUpdateOrgDTO = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }>; } & TOrgPermission; diff --git a/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts new file mode 100644 index 000000000..6ed78f665 --- /dev/null +++ b/backend/src/services/pam-account-rotation/pam-account-rotation-queue.ts @@ -0,0 +1,61 @@ +import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +type TPamAccountRotationServiceFactoryDep = { + queueService: TQueueServiceFactory; + pamAccountService: Pick; +}; + +export type TPamAccountRotationServiceFactory = ReturnType; + +export const pamAccountRotationServiceFactory = ({ + queueService, + pamAccountService +}: TPamAccountRotationServiceFactoryDep) => { + const appCfg = getConfig(); + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.stopRepeatableJob( + QueueName.PamAccountRotation, + QueueJobs.PamAccountRotation, + { pattern: "0 * * * *", utc: true }, + QueueName.PamAccountRotation // job id + ); + + await queueService.startPg( + QueueJobs.PamAccountRotation, + async () => { + try { + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`); + await pamAccountService.rotateAllDueAccounts(); + logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`); + } catch (error) { + logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 5 * 60 + } + ); + + await queueService.schedulePg( + QueueJobs.PamAccountRotation, + "0 * * * *", // Schedule to run every hour + undefined, + { tz: "UTC" } + ); + }; + + return { + init + }; +}; diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index bf0bb18af..c402b6030 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -76,7 +76,7 @@ export const projectEnvServiceFactory = ({ const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug); if (existingEnv) throw new BadRequestError({ - message: "Environment with slug already exist", + message: "Environment with slug already exists", name: "CreateEnvironment" }); @@ -171,7 +171,7 @@ export const projectEnvServiceFactory = ({ const existingEnv = await projectEnvDAL.findOne({ slug, projectId }); if (existingEnv && existingEnv.id !== id) { throw new BadRequestError({ - message: "Environment with slug already exist", + message: "Environment with slug already exists", name: "UpdateEnvironment" }); } diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e29f18404..fb2eb70af 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -10,7 +10,8 @@ import { ProjectType, ProjectVersion, TableName, - TProjectEnvironments + TProjectEnvironments, + TProjects } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; @@ -40,7 +41,8 @@ import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-hos import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; @@ -154,7 +156,7 @@ type TProjectServiceFactoryDep = { >; pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; @@ -303,19 +305,34 @@ export const projectServiceFactory = ({ }); } - const project = await projectDAL.create( - { - name: workspaceName, - type, - description: workspaceDescription, - orgId: organization.id, - slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), - kmsSecretManagerKeyId: kmsKeyId, - version: ProjectVersion.V3, - pitVersionLimit: 10 - }, - tx - ); + const slug = projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`); + + let project: TProjects; + try { + project = await projectDAL.create( + { + name: workspaceName, + type, + description: workspaceDescription, + orgId: organization.id, + slug, + kmsSecretManagerKeyId: kmsKeyId, + version: ProjectVersion.V3, + pitVersionLimit: 10 + }, + tx + ); + } catch (err) { + if ( + err instanceof DatabaseError && + (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ + message: `A project with the slug "${slug}" already exists in your organization. Please choose a different name or slug.` + }); + } + throw err; + } if (type === ProjectType.SSH) { await bootstrapSshProject({ @@ -579,39 +596,36 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); - if (update.slug) { - const existingProject = await projectDAL.findOne({ - slug: update.slug, - orgId: actorOrgId - }); - if (existingProject && existingProject.id !== project.id) { - throw new BadRequestError({ - message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.` - }); - } - } - if (update.secretDetectionIgnoreValues && !hasRole(ProjectMembershipRole.Admin)) { throw new ForbiddenRequestError({ message: "Only admins can update secret detection ignore values" }); } - const updatedProject = await projectDAL.updateById(project.id, { - name: update.name, - description: update.description, - autoCapitalization: update.autoCapitalization, - enforceCapitalization: update.autoCapitalization, - hasDeleteProtection: update.hasDeleteProtection, - slug: update.slug, - secretSharing: update.secretSharing, - defaultProduct: update.defaultProduct, - showSnapshotsLegacy: update.showSnapshotsLegacy, - secretDetectionIgnoreValues: update.secretDetectionIgnoreValues, - pitVersionLimit: update.pitVersionLimit - }); + try { + const updatedProject = await projectDAL.updateById(project.id, { + name: update.name, + description: update.description, + autoCapitalization: update.autoCapitalization, + enforceCapitalization: update.autoCapitalization, + hasDeleteProtection: update.hasDeleteProtection, + slug: update.slug, + secretSharing: update.secretSharing, + defaultProduct: update.defaultProduct, + showSnapshotsLegacy: update.showSnapshotsLegacy, + secretDetectionIgnoreValues: update.secretDetectionIgnoreValues, + pitVersionLimit: update.pitVersionLimit + }); - return updatedProject; + return updatedProject; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation) { + throw new BadRequestError({ + message: `Failed to update project. A project with the slug "${update.slug}" already exists in your organization. Please choose a different slug.` + }); + } + throw err; + } }; const toggleAutoCapitalization = async ({ @@ -938,13 +952,13 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const certificates = await certificateDAL.find( + const certificates = await certificateDAL.findWithPrivateKeyInfo( { projectId, ...(friendlyName && { friendlyName }), ...(commonName && { commonName }) }, - { offset, limit, sort: [["updatedAt", "desc"]] } + { offset, limit, sort: [["notAfter", "desc"]] } ); const count = await certificateDAL.countCertificatesInProject({ diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 4b174ca2a..e7116d604 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -211,10 +211,18 @@ export const GithubSyncFns = { } const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token); @@ -269,10 +277,18 @@ export const GithubSyncFns = { const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v])); const { connection } = secretSync; - const token = - connection.method === GitHubConnectionMethod.OAuth - ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + let token: string; + + switch (connection.method) { + case GitHubConnectionMethod.OAuth: + token = connection.credentials.accessToken; + break; + case GitHubConnectionMethod.Pat: + token = connection.credentials.personalAccessToken; + break; + default: + token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service); + } const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service); diff --git a/backend/src/services/secret-sync/northflank/index.ts b/backend/src/services/secret-sync/northflank/index.ts new file mode 100644 index 000000000..7fab276cf --- /dev/null +++ b/backend/src/services/secret-sync/northflank/index.ts @@ -0,0 +1,4 @@ +export * from "./northflank-sync-constants"; +export * from "./northflank-sync-fns"; +export * from "./northflank-sync-schemas"; +export * from "./northflank-sync-types"; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts new file mode 100644 index 000000000..d4b851217 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const NORTHFLANK_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Northflank", + destination: SecretSync.Northflank, + connection: AppConnection.Northflank, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts new file mode 100644 index 000000000..396aa9ac8 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-fns.ts @@ -0,0 +1,165 @@ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { SecretSyncError } from "../secret-sync-errors"; +import { TNorthflankSyncWithCredentials } from "./northflank-sync-types"; + +const NORTHFLANK_API_URL = "https://api.northflank.com"; + +const buildNorthflankAPIErrorMessage = (error: unknown): string => { + let errorMessage = "Northflank API returned an error."; + + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as AxiosError; + + if (axiosError.response?.data) { + // This is the shape of the error response from the Northflank API + const responseData = axiosError.response.data as { + error?: { message?: string; details?: Record }; + message?: string; + }; + const errorParts = []; + + if (responseData.error?.message) { + errorParts.push(responseData.error.message); + } else if (responseData.message) { + errorParts.push(responseData.message); + } + + if (responseData.error?.details) { + const { details } = responseData.error; + + // Flatten the details object into a string + Object.entries(details).forEach(([field, fieldErrors]) => { + if (Array.isArray(fieldErrors)) { + fieldErrors.forEach((fieldError) => errorParts.push(`${field}: ${fieldError}`)); + } else { + errorParts.push(`${field}: ${String(fieldErrors)}`); + } + }); + } + + errorMessage += ` ${errorParts.join(". ")}`; + } + } + + return errorMessage; +}; + +const getNorthflankSecrets = async (secretSync: TNorthflankSyncWithCredentials): Promise> => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + const { + data: { + data: { + secrets: { variables } + } + } + } = await request.get<{ + data: { + secrets: { + variables: Record; + }; + }; + }>(`${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}/details`, { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + }); + + return variables; + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to fetch Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +const updateNorthflankSecrets = async ( + secretSync: TNorthflankSyncWithCredentials, + variables: Record +): Promise => { + const { + destinationConfig: { projectId, secretGroupId }, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.patch( + `${NORTHFLANK_API_URL}/v1/projects/${projectId}/secrets/${secretGroupId}`, + { + secrets: { + variables + } + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json" + } + } + ); + } catch (error: unknown) { + throw new SecretSyncError({ + error, + message: `Failed to update Northflank secrets. ${buildNorthflankAPIErrorMessage(error)}` + }); + } +}; + +export const NorthflankSyncFns = { + syncSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + const shouldKeep = + !secretMap[key] && // this prevents duplicates from infisical secrets, because we add all of them to the updateVariables in the next loop + (secretSync.syncOptions.disableSecretDeletion || + !matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)); + + if (shouldKeep) { + updatedVariables[key] = value; + } + } + + for (const [key, { value }] of Object.entries(secretMap)) { + updatedVariables[key] = value; + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + }, + + getSecrets: async (secretSync: TNorthflankSyncWithCredentials): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + return Object.fromEntries(Object.entries(northflankSecrets).map(([key, value]) => [key, { value }])); + }, + + removeSecrets: async (secretSync: TNorthflankSyncWithCredentials, secretMap: TSecretMap): Promise => { + const northflankSecrets = await getNorthflankSecrets(secretSync); + + const updatedVariables: Record = {}; + + for (const [key, value] of Object.entries(northflankSecrets)) { + if (!(key in secretMap)) { + updatedVariables[key] = value; + } + } + + await updateNorthflankSecrets(secretSync, updatedVariables); + } +}; diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts new file mode 100644 index 000000000..55cdeae23 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-schemas.ts @@ -0,0 +1,54 @@ +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 { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const NorthflankSyncDestinationConfigSchema = z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectId), + projectName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.projectName), + secretGroupId: z + .string() + .trim() + .min(1, "Secret Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupId), + secretGroupName: z.string().trim().optional().describe(SecretSyncs.DESTINATION_CONFIG.NORTHFLANK.secretGroupName) +}); + +const NorthflankSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const NorthflankSyncSchema = BaseSecretSyncSchema(SecretSync.Northflank, NorthflankSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const CreateNorthflankSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + destinationConfig: NorthflankSyncDestinationConfigSchema +}); + +export const UpdateNorthflankSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Northflank, + NorthflankSyncOptionsConfig +).extend({ + 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) +}); diff --git a/backend/src/services/secret-sync/northflank/northflank-sync-types.ts b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts new file mode 100644 index 000000000..019ae2843 --- /dev/null +++ b/backend/src/services/secret-sync/northflank/northflank-sync-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TNorthflankConnection } from "@app/services/app-connection/northflank"; + +import { + CreateNorthflankSyncSchema, + NorthflankSyncListItemSchema, + NorthflankSyncSchema +} from "./northflank-sync-schemas"; + +export type TNorthflankSyncListItem = z.infer; + +export type TNorthflankSync = z.infer; + +export type TNorthflankSyncInput = z.infer; + +export type TNorthflankSyncWithCredentials = TNorthflankSync & { + connection: TNorthflankConnection; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 235b3db3a..f04247684 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge" } diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 85fc27250..3068e803b 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -52,6 +52,7 @@ import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; +import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; import { RENDER_SYNC_LIST_OPTION, RenderSyncFns } from "./render"; @@ -93,6 +94,7 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, + [SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; @@ -278,6 +280,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: @@ -395,6 +399,9 @@ export const SecretSyncFns = { case SecretSync.Netlify: secretMap = await NetlifySyncFns.getSecrets(secretSync); break; + case SecretSync.Northflank: + secretMap = await NorthflankSyncFns.getSecrets(secretSync); + break; case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; @@ -492,6 +499,8 @@ export const SecretSyncFns = { return DigitalOceanAppPlatformSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Netlify: return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Northflank: + return NorthflankSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 0ec8aede0..8110cced9 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,6 +32,7 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", + [SecretSync.Northflank]: "Northflank", [SecretSync.Bitbucket]: "Bitbucket", [SecretSync.LaravelForge]: "Laravel Forge" }; @@ -66,6 +67,7 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge }; @@ -100,6 +102,7 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, + [SecretSync.Northflank]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, [SecretSync.LaravelForge]: SecretSyncPlanType.Regular }; @@ -143,6 +146,7 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Northflank]: [], [SecretSync.Bitbucket]: [], [SecretSync.LaravelForge]: [] }; @@ -203,6 +207,7 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record 0) { + throw new SecretSyncError({ + message: `Found ${invalidNameCount} invalid secret name${invalidNameCount === 1 ? "" : "s"}. ${errorMessage}`, + shouldRetry: false + }); + } + const importedSecretMap: TSecretMap = {}; const secretMap = await $getInfisicalSecrets(secretSync, false); diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 6a2f49386..a4e2fc467 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -15,6 +15,8 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { deepEqualSkipFields } from "@app/lib/fn/object"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; appConnectionService: Pick; permissionService: Pick; + projectDAL: Pick; + orgDAL: Pick; projectBotService: Pick; folderDAL: Pick; keyStore: Pick; @@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({ secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, projectBotService, secretSyncQueue, keyStore, @@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({ return secretSync as TSecretSync; }; + const checkDuplicateDestination = async ( + { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, + actor: OrgServiceActor + ) => { + const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (!destinationConfig || Object.keys(destinationConfig).length === 0) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + + try { + const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); + + const duplicates = existingSyncs.filter((sync) => { + if (sync.id === excludeSyncId) { + return false; + } + + try { + const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + if (baseFieldsMatch) { + return DESTINATION_DUPLICATE_CHECK_MAP[destination]( + sync.destinationConfig as Record, + destinationConfig + ); + } + return false; + } catch { + return false; + } + }); + + const hasDuplicate = duplicates.length > 0; + return { + hasDuplicate, + duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined + }; + } catch (error) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + }; + const createSecretSync = async ( { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, actor: OrgServiceActor @@ -271,6 +332,30 @@ export const secretSyncServiceFactory = ({ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` }); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const organization = await orgDAL.findById(project.orgId); + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination: params.destination, + destinationConfig: params.destinationConfig, + projectId + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination]; // validates permission to connect and app is valid for sync destination @@ -369,6 +454,33 @@ export const secretSyncServiceFactory = ({ let { folderId } = secretSync; + if (params.destinationConfig) { + const project = await projectDAL.findById(secretSync.projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const organization = await orgDAL.findById(project.orgId); + + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination, + destinationConfig: params.destinationConfig, + projectId: secretSync.projectId, + excludeSyncId: secretSync.id + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + } + if (params.connectionId) { const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync]; @@ -703,61 +815,6 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; - const checkDuplicateDestination = async ( - { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, - actor: OrgServiceActor - ) => { - const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; - const { permission } = await permissionService.getProjectPermission({ - actor: actor.type, - actorId: actor.id, - actorAuthMethod: actor.authMethod, - actorOrgId: actor.orgId, - actionProjectType: ActionProjectType.SecretManager, - projectId - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSecretSyncActions.Read, - ProjectPermissionSub.SecretSyncs - ); - - if (!destinationConfig || Object.keys(destinationConfig).length === 0) { - return { hasDuplicate: false, duplicateProjectId: undefined }; - } - - try { - const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); - - const duplicates = existingSyncs.filter((sync) => { - if (sync.id === excludeSyncId) { - return false; - } - - try { - const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); - if (baseFieldsMatch) { - return DESTINATION_DUPLICATE_CHECK_MAP[destination]( - sync.destinationConfig as Record, - destinationConfig - ); - } - return false; - } catch { - return false; - } - }); - - const hasDuplicate = duplicates.length > 0; - return { - hasDuplicate, - duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined - }; - } catch (error) { - return { hasDuplicate: false, duplicateProjectId: undefined }; - } - }; - return { listSecretSyncOptions, listSecretSyncsByProjectId, diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 9719f0a9e..649f91d44 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -124,6 +124,12 @@ import { TLaravelForgeSyncWithCredentials } from "./laravel-forge"; import { TNetlifySync, TNetlifySyncInput, TNetlifySyncListItem, TNetlifySyncWithCredentials } from "./netlify"; +import { + TNorthflankSync, + TNorthflankSyncInput, + TNorthflankSyncListItem, + TNorthflankSyncWithCredentials +} from "./northflank"; import { TRailwaySync, TRailwaySyncInput, @@ -187,6 +193,7 @@ export type TSecretSync = | TChecklySync | TSupabaseSync | TNetlifySync + | TNorthflankSync | TBitbucketSync; export type TSecretSyncWithCredentials = @@ -219,6 +226,7 @@ export type TSecretSyncWithCredentials = | TSupabaseSyncWithCredentials | TDigitalOceanAppPlatformSyncWithCredentials | TNetlifySyncWithCredentials + | TNorthflankSyncWithCredentials | TBitbucketSyncWithCredentials | TLaravelForgeSyncWithCredentials; @@ -252,6 +260,7 @@ export type TSecretSyncInput = | TSupabaseSyncInput | TDigitalOceanAppPlatformSyncInput | TNetlifySyncInput + | TNorthflankSyncInput | TBitbucketSyncInput | TLaravelForgeSyncInput; @@ -286,6 +295,7 @@ export type TSecretSyncListItem = | TSupabaseSyncListItem | TDigitalOceanAppPlatformSyncListItem | TNetlifySyncListItem + | TNorthflankSyncListItem | TBitbucketSyncListItem; export type TSyncOptionsConfig = { diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index 8a08c44dd..58913e08c 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -35,7 +35,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); const existingTag = await secretTagDAL.findOne({ slug, projectId }); - if (existingTag) throw new BadRequestError({ message: "Tag already exist" }); + if (existingTag) throw new BadRequestError({ message: "Tag already exists" }); const newTag = await secretTagDAL.create({ projectId, @@ -53,7 +53,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe if (slug) { const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId }); - if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" }); + if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exists" }); } const { permission } = await permissionService.getProjectPermission({ diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 579b19b19..559c86843 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -34,6 +34,7 @@ import { diff, groupBy } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { recordSecretReadMetric } from "@app/lib/telemetry/metrics"; import { ActorType } from "../auth/auth-type"; import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; @@ -282,7 +283,7 @@ export const secretV2BridgeServiceFactory = ({ folderId }); if (inputSecret.type === SecretType.Shared && doesSecretExist) - throw new BadRequestError({ message: "Secret already exist" }); + throw new BadRequestError({ message: "Secret already exists" }); // if user creating personal check its shared also exist if (inputSecret.type === SecretType.Personal && !doesSecretExist) { @@ -527,7 +528,7 @@ export const secretV2BridgeServiceFactory = ({ type: SecretType.Shared, folderId }); - if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exist" }); + if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exists" }); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionSecretActions.Edit, subject(ProjectPermissionSub.Secrets, { @@ -1052,6 +1053,11 @@ export const secretV2BridgeServiceFactory = ({ }); throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret); + recordSecretReadMetric({ + environment, + secretPath: path + }); + const cachedSecretDalVersion = await keyStore.pgGetIntItem(SecretServiceCacheKeys.getSecretDalVersion(projectId)); const secretDalVersion = Number(cachedSecretDalVersion || 0); const cacheKey = SecretServiceCacheKeys.getSecretsOfServiceLayer(projectId, secretDalVersion, { @@ -1482,6 +1488,12 @@ export const secretV2BridgeServiceFactory = ({ secretTags: (secret?.tags || []).map((el) => el.slug) }); + recordSecretReadMetric({ + environment, + secretPath: path, + name: secretName + }); + // this will throw if the user doesn't have read value permission no matter what // because if its an expansion, it will fully depend on the value. const { expandSecretReferences } = expandSecretReferencesFactory({ @@ -1674,7 +1686,7 @@ export const secretV2BridgeServiceFactory = ({ } }); if (secrets.length) - throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` }); + throw new BadRequestError({ message: `Secret already exists: ${secrets.map((el) => el.key).join(",")}` }); const project = await projectDAL.findById(projectId); await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []); diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 3b97f5891..820260d29 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -525,7 +525,7 @@ export const fnSecretBlindIndexCheck = async ({ ); if (isNew) { - if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); + if (secrets.length) throw new BadRequestError({ message: "Secret already exists" }); } else { const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map( (el) => blindIndex2KeyName[el.secretBlindIndex as string] @@ -819,7 +819,7 @@ export const createManySecretsRawFnFactory = ({ ); if (secretsStoredInDB.length) throw new BadRequestError({ - message: `Secret already exist: ${secretsStoredInDB.map((el) => el.key).join(",")}` + message: `Secret already exists: ${secretsStoredInDB.map((el) => el.key).join(",")}` }); const inputSecrets = secrets.map((secret) => { diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 723cd368c..9ba1df47d 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -2751,7 +2751,7 @@ export const secretServiceFactory = ({ const existingSecretTags = await secretDAL.getSecretTags(secret.id); if (existingSecretTags.some((tag) => tagSlugs.includes(tag.slug))) { - throw new BadRequestError({ message: "One or more tags already exist on the secret" }); + throw new BadRequestError({ message: "One or more tags already exists on the secret" }); } const combinedTags = new Set([...existingSecretTags.map((tag) => tag.id), ...tags.map((el) => el.id)]); diff --git a/backend/vitest.e2e.config.ts b/backend/vitest.e2e.config.mts similarity index 86% rename from backend/vitest.e2e.config.ts rename to backend/vitest.e2e.config.mts index bb7ae8087..83554b818 100644 --- a/backend/vitest.e2e.config.ts +++ b/backend/vitest.e2e.config.mts @@ -12,13 +12,16 @@ export default defineConfig({ }, environment: "./e2e-test/vitest-environment-knex.ts", include: ["./e2e-test/**/*.spec.ts"], + pool: "threads", poolOptions: { threads: { - singleThread: true, - useAtomics: true, - isolate: false + minThreads: 1, + maxThreads: 1, + singleThread: true } }, + fileParallelism: false, + alias: { "./license-fns": path.resolve(__dirname, "./src/ee/services/license/__mocks__/license-fns") } diff --git a/backend/vitest.unit.config.ts b/backend/vitest.unit.config.mts similarity index 100% rename from backend/vitest.unit.config.ts rename to backend/vitest.unit.config.mts diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00dc19a46..e60ef1ba5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -77,6 +77,7 @@ services: - TELEMETRY_ENABLED=false volumes: - ./backend/src:/app/src + - softhsm_tokens:/etc/softhsm2/tokens # SoftHSM tokens are stored in a volume to persist across container restarts extra_hosts: - "host.docker.internal:host-gateway" @@ -198,3 +199,5 @@ volumes: ldap_data: ldap_config: grafana_storage: + softhsm_tokens: + driver: local \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/northflank/available.mdx b/docs/api-reference/endpoints/app-connections/northflank/available.mdx new file mode 100644 index 000000000..99626a947 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/northflank/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/create.mdx b/docs/api-reference/endpoints/app-connections/northflank/create.mdx new file mode 100644 index 000000000..0ec652776 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/create.mdx @@ -0,0 +1,8 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/northflank" +--- + + + Check out the configuration docs for [Northflank Connections](/integrations/app-connections/northflank) to learn how to obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/northflank/delete.mdx b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx new file mode 100644 index 000000000..1c3518ea5 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx new file mode 100644 index 000000000..e6e24e39e --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx new file mode 100644 index 000000000..e3ca69b31 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/northflank/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/list.mdx b/docs/api-reference/endpoints/app-connections/northflank/list.mdx new file mode 100644 index 000000000..fbaf08ea6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/northflank" +--- diff --git a/docs/api-reference/endpoints/app-connections/northflank/update.mdx b/docs/api-reference/endpoints/app-connections/northflank/update.mdx new file mode 100644 index 000000000..4554bd714 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/northflank/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx new file mode 100644 index 000000000..47ae2f4b4 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx new file mode 100644 index 000000000..12e5c6e44 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx new file mode 100644 index 000000000..7cad153e8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx new file mode 100644 index 000000000..487462dde --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/northflank/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx new file mode 100644 index 000000000..0294f4dad --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx new file mode 100644 index 000000000..a1926710f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/northflank" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx new file mode 100644 index 000000000..161ddac54 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx new file mode 100644 index 000000000..82ce1a96a --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/northflank/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx new file mode 100644 index 000000000..2f743e82f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/northflank/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/northflank/{syncId}" +--- diff --git a/docs/api-reference/overview/introduction.mdx b/docs/api-reference/overview/introduction.mdx index 6d577e15b..2f75bf0f5 100644 --- a/docs/api-reference/overview/introduction.mdx +++ b/docs/api-reference/overview/introduction.mdx @@ -7,4 +7,10 @@ Infisical's Public (REST) API provides users an alternative way to programmatica secrets via HTTPS requests. This can be useful for automating tasks, such as rotating credentials, or for integrating secret management into a larger system. -With the Public API, you can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more. \ No newline at end of file +With the Public API, you can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more. + +## API Versioning + +The API is versioned on a per-resource basis. A resource's version is only incremented for breaking changes, so different endpoints may have different version numbers (e.g., `/api/v4/secrets` vs. `/api/v1/secret-syncs`). + +As a best practice, always use the latest available version for each endpoint to ensure access to the most recent features and improvements. diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index f93e3b4b2..c58c13713 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -9,22 +9,97 @@ infisical login ### Description -The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. +The CLI uses authentication to verify your identity. You can authenticate using: +- **Browser Login** (default): Opens a browser for authentication +- **Direct Login**: Provide email and password via flags or environment variables for non-interactive workflows +- **Interactive CLI Login**: Use the `--interactive` flag to enter credentials via CLI prompts + +When authenticated, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. To change where the login credentials are stored, visit the [vaults command](./vault). If you have added multiple users, you can switch between the users by using the [user command](./user). - When you authenticate with **any other method than `user`**, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. - - Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method. - + **JWT Token Output:** + - For **user authentication** with the `--plain --silent` flags: outputs only the JWT access token (useful for scripting) + - For **machine identity authentication**: an access token is always printed to the console + + Use the `--plain` flag to print only the token in plain text and the `--silent` flag to disable update alerts. + + Both flags are ideal for capturing the token in environment variables or CI/CD pipelines. ### Authentication Methods -The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. +The Infisical CLI supports two main categories of authentication: User Authentication and Machine Identity Authentication. + +#### User Authentication + +User authentication is designed for individual developers and supports multiple login flows. + + + + The User authentication method allows you to log in with your email and password. This method supports three different login flows: + + - **Browser Login** (default): Opens a browser for authentication + - **Direct Login**: Provide credentials via flags or environment variables for CI/CD + - **Interactive CLI Login**: Enter credentials via CLI prompts using `--interactive` + + + + + Your email address. Required for direct login along with `--password` and `--organization-id`. + + + Your password. Required for direct login along with `--email` and `--organization-id`. + + + Your organization id. Required for direct login along with `--password` and `--email`. + + + Force interactive CLI login instead of browser-based authentication. + + + Output only the JWT token (useful for scripting and CI/CD). + + + + + + + ```bash + infisical login + ``` + + + ```bash + infisical login --email=user@example.com --password=your-password --organization-id=your-organization-id + + # Or using environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + export INFISICAL_ORGANIZATION_ID="your-organization-id" + infisical login + ``` + + + ```bash + infisical login --interactive + ``` + + + ```bash + export INFISICAL_TOKEN=$(infisical login --email=user@example.com --password=your-password --organization-id=your-organization-id --plain --silent) + ``` + + + + + +#### Machine Identity Authentication + +Machine identity authentication methods are designed for automated systems, services, and CI/CD pipelines. @@ -237,7 +312,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa Run the `login` command with the following flags to obtain an access token: ```bash - infisical login --method=jwt-auth --jwt= --machine-identity-id= + infisical login --method=jwt-auth --jwt= --machine-identity-id= ``` @@ -262,7 +337,8 @@ The login command supports a number of flags that you can use for different auth - `gcp-id-token`: Login using a GCP ID token native auth. - `gcp-iam`: Login using a GCP IAM. - `aws-iam`: Login using an AWS IAM native auth. - - `oidc-auth`: Login using oidc auth. + - `oidc-auth`: Login using OIDC auth. + - `jwt-auth`: Login using a plain JWT token. @@ -330,22 +406,173 @@ The login command supports a number of flags that you can use for different auth - - - + ```bash - infisical login --oidc-jwt= + infisical login --email= --password= --organization-id= ``` #### Description - The JWT provided by an identity provider for OIDC authentication. + User email address. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--password` and `--organization-id` flag. - The `oidc-jwt` flag can be substituted with the `INFISICAL_OIDC_AUTH_JWT` environment variable. + You can omit the **--method=user** if you want as it's the default method. + + + + The `email` flag can be substituted with the `INFISICAL_EMAIL` environment variable. + + ```bash + infisical login --email= --password= --organization-id= + ``` + #### Description + User password. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--email` and `--organization-id` flag. + + + For security in CI/CD environments, prefer using the `INFISICAL_PASSWORD` environment variable instead of passing the password as a command-line flag. + + + + You can omit the **--method=user** if you want as it's the default method. + + + + The `password` flag can be substituted with the `INFISICAL_PASSWORD` environment variable. + + + + + ```bash + infisical login --email= --password= --organization-id= + ``` + + #### Description + User organization id. Required if you want to do a non-interactive login when the **--method** flag is set to **user**. Must be used together with the `--email` and `--password` flag. + + + You can omit the **--method=user** if you want as it's the default method. + + + + The `organization-id` flag can be substituted with the `INFISICAL_ORGANIZATION_ID` environment variable. + + + + + ```bash + infisical login --interactive + ``` + + #### Description + Forces interactive CLI login where you'll be prompted to enter your email, password, and select your organization in the terminal, instead of opening a browser. + + + + ```bash + infisical login --email= --password= --organization-id= --plain + ``` + + #### Description + When used with direct user login or machine identity authentication, outputs only the JWT access token without any additional formatting. This is useful for scripting and CI/CD pipelines where you need to capture the token. + + ```bash + # Example: Capture token in a variable + export INFISICAL_TOKEN=$(infisical login --email= --password= --organization-id= --plain --silent) + ``` + + + Use it alongside the `silent` flag to disable all messages in the console except from the access token. + + + + + ```bash + infisical login --jwt= --machine-identity-id= + ``` + + #### Description + The JWT provided by an identity provider for OIDC or plain JWT authentication. This is required if the `--method` flag is set to `oidc-auth` or `jwt-auth`. + + + The `jwt` flag can be substituted with the `INFISICAL_JWT` environment variable. + + + + + +### User Authentication Examples + +The following examples demonstrate different ways to authenticate as a user with the Infisical CLI. + + + + By default, running `infisical login` without any flags opens your browser for authentication. + + ```bash + # Opens browser for authentication + infisical login + ``` + + The browser will open to the Infisical login page, and upon successful authentication, the CLI will be automatically authenticated. + + + + + Direct login is ideal for CI/CD pipelines and automation scripts where browser-based authentication is not possible. + + #### Using Command-Line Flags + + ```bash + # Basic direct login (defaults to US Cloud) + infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" + + # EU Cloud (Custom domain) + infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --domain https://eu.infisical.com + + # Output only JWT token for scripting + export INFISICAL_TOKEN=$(infisical login --email user@example.com --password "your-password" --organization-id "your-organization-id" --plain --silent) + ``` + + #### Using Environment Variables (Recommended for CI/CD) + + ```bash + # Set credentials as environment variables + export INFISICAL_EMAIL="user@example.com" + export INFISICAL_PASSWORD="your-password" + export INFISICAL_ORGANIZATION_ID="your-organization-id" + + # Login without additional flags + infisical login + + # Or with plain output for token capture + export INFISICAL_TOKEN=$(infisical login --plain --silent) + ``` + + + + Interactive login prompts you to enter credentials in the terminal instead of opening a browser. + + ```bash + # Force interactive CLI login + infisical login --interactive + ``` + + You'll be prompted to enter: + - Email address + - Password + + After the prompt, you will be shown a list of organizations to choose from. + + + + + + +If you have SSO enabled, we recommend using the default browser login. + ### Machine Identity Authentication Quick Start @@ -367,9 +594,9 @@ In this example we'll be using the `universal-auth` method to login to obtain an ``` - + ```bash - infisical secrets --projectId= --env=dev --recursive ``` This command will fetch all secrets from the `dev` environment in your project, including all secrets in subfolders. diff --git a/docs/cli/usage.mdx b/docs/cli/usage.mdx index 8d0236ed5..bedfda22c 100644 --- a/docs/cli/usage.mdx +++ b/docs/cli/usage.mdx @@ -9,11 +9,15 @@ The CLI is designed for a variety of secret management applications ranging from In the following steps, we explore how to use the Infisical CLI to fetch back environment variables from Infisical and inject them into your local development process. - + + + If you prefer learning by watching, you can follow along our step-by-step video tutorial [here](https://www.youtube.com/watch?v=EzDQC7nY3YY). + + Start by running the `infisical login` command to authenticate with Infisical. - + ```bash infisical login ``` @@ -23,7 +27,7 @@ The CLI is designed for a variety of secret management applications ranging from Next, navigate to your project and initialize Infisical. - + ```bash # navigate to your project cd /path/to/project @@ -123,23 +127,25 @@ The CLI is designed for a variety of secret management applications ranging from Starting with CLI version v0.4.0, you can now choose to log in via Infisical Cloud (US/EU) or your own self-hosted instance by simply running `infisical login` and following the on-screen instructions — no need to manually set the `INFISICAL_API_URL` environment variable. - For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL. +For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL. + ## Custom Request Headers - The Infisical CLI supports custom HTTP headers for requests to servers protected by authentication services such as Cloudflare Access. Configure these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable: +The Infisical CLI supports custom HTTP headers for requests to servers protected by authentication services such as Cloudflare Access. Configure these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable: - ```bash - # Syntax: headername1=headervalue1 headername2=headervalue2 - export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" +```bash +# Syntax: headername1=headervalue1 headername2=headervalue2 +export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" - # Execute Infisical commands after setting the environment variable - infisical secrets - ``` +# Execute Infisical commands after setting the environment variable +infisical secrets +``` + +This functionality enables secure interaction with Infisical instances that require specific authentication headers. - This functionality enables secure interaction with Infisical instances that require specific authentication headers. ## History diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 35912fc8c..1784b77e8 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -7,20 +7,20 @@ To set a strong foundation, this section outlines how we, the community and memb should approach the development and contribution process. ## Code-bases + Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. - - - - - The Infisical platform is the core of the Infisical ecosystem. - - - The SDKs are the official Infisical client libraries, used by developers to easily interact with the Infisical platform. - - +- The Infisical SDKs, please refer to each individual SDK repositories for more information. + - [Node.js SDK](https://github.com/Infisical/node-sdk-v2) + - [Python SDK](https://github.com/Infisical/python-sdk-official) + - [Java SDK](https://github.com/Infisical/java-sdk) + - [.NET SDK](https://github.com/Infisical/infisical-dotnet-sdk) + - [Go SDK](https://github.com/Infisical/go-sdk) + - [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 @@ -45,15 +45,12 @@ If you're ever in doubt about whether or not a proposed feature aligns with Infi ## Writing and submitting code -Anyone can contribute code to Infisical. To get started, check out the local development guides for each language. - -- Local development guide for Platform is [here](/contributing/platform/developing). -- Local development guide for SDK is [here](/contributing/sdk/developing). +Anyone can contribute code to Infisical. To get started, check out the local development guide for the platform: +- Local development guide for Platform is [here](/contributing/platform/developing). ## Licensing Most of Infisical's code is under the MIT license, though some paid feature restrictions are covered by a proprietary license. Any third party components incorporated into our code are licensed under the original license provided by the applicable component owner. - diff --git a/docs/docs.json b/docs/docs.json index 66976630b..86aca8145 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -130,6 +130,7 @@ "integrations/app-connections/mssql", "integrations/app-connections/mysql", "integrations/app-connections/netlify", + "integrations/app-connections/northflank", "integrations/app-connections/oci", "integrations/app-connections/okta", "integrations/app-connections/oracledb", @@ -176,16 +177,14 @@ "pages": [ "documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-deployment", - "documentation/platform/gateways/relay-deployment", - "documentation/platform/gateways/security", { - "group": "Gateway (Deprecated)", + "group": "Relay Deployment", "pages": [ - "documentation/platform/gateways-deprecated/overview", - "documentation/platform/gateways-deprecated/gateway-security", - "documentation/platform/gateways-deprecated/networking" + "documentation/platform/gateways/relay-deployment/overview", + "documentation/platform/gateways/relay-deployment/terraform" ] - } + }, + "documentation/platform/gateways/security" ] } ] @@ -554,6 +553,7 @@ "integrations/secret-syncs/humanitec", "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", + "integrations/secret-syncs/northflank", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", "integrations/secret-syncs/render", @@ -808,6 +808,15 @@ ] } ] + }, + { + "item": "Infisical PAM", + "groups": [ + { + "group": "Infisical PAM", + "pages": ["documentation/platform/pam/overview"] + } + ] } ] }, @@ -1873,6 +1882,18 @@ "api-reference/endpoints/app-connections/netlify/delete" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/app-connections/northflank/list", + "api-reference/endpoints/app-connections/northflank/available", + "api-reference/endpoints/app-connections/northflank/get-by-id", + "api-reference/endpoints/app-connections/northflank/get-by-name", + "api-reference/endpoints/app-connections/northflank/create", + "api-reference/endpoints/app-connections/northflank/update", + "api-reference/endpoints/app-connections/northflank/delete" + ] + }, { "group": "OCI", "pages": [ @@ -2331,6 +2352,20 @@ "api-reference/endpoints/secret-syncs/netlify/remove-secrets" ] }, + { + "group": "Northflank", + "pages": [ + "api-reference/endpoints/secret-syncs/northflank/list", + "api-reference/endpoints/secret-syncs/northflank/get-by-id", + "api-reference/endpoints/secret-syncs/northflank/get-by-name", + "api-reference/endpoints/secret-syncs/northflank/create", + "api-reference/endpoints/secret-syncs/northflank/update", + "api-reference/endpoints/secret-syncs/northflank/delete", + "api-reference/endpoints/secret-syncs/northflank/sync-secrets", + "api-reference/endpoints/secret-syncs/northflank/import-secrets", + "api-reference/endpoints/secret-syncs/northflank/remove-secrets" + ] + }, { "group": "OCI", "pages": [ diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index f773019ec..e10d594da 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -38,3 +38,4 @@ Infisical consists of several tightly integrated products, each designed to solv - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. diff --git a/docs/documentation/getting-started/overview.mdx b/docs/documentation/getting-started/overview.mdx index 769990987..f51136278 100644 --- a/docs/documentation/getting-started/overview.mdx +++ b/docs/documentation/getting-started/overview.mdx @@ -40,6 +40,12 @@ description: "The open source platform for managing secrets, certificates, and s > Replace static SSH keys with short-lived SSH certificates to simplify access and improve security. + + Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. + diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index e267cc5e9..c58e1ea6b 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -26,6 +26,12 @@ Infisical provides two approaches for migrating from HashiCorp Vault. This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. + + **Organization Admin Access Required:** All in-platform migration features + (importing secrets, Kubernetes configurations, and policies from Vault) are + only accessible to organization admins. + + ### Step 1: Set Up Your Vault Connection @@ -84,6 +90,19 @@ This migration approach lets you set up a connection to your Vault instance once path "auth/+/role/*" { capabilities = ["read"] } + + # Kubernetes secrets engine - for reading secrets engine configuration and roles + path "+/config" { + capabilities = ["read"] + } + + path "+/roles" { + capabilities = ["list"] + } + + path "+/roles/*" { + capabilities = ["read"] + } ``` @@ -160,6 +179,34 @@ The authentication settings (service accounts, TTL, policies, etc.) will be auto must be manually provided in the form after importing the configuration. +#### Import Kubernetes Dynamic Secret Configurations + +When creating a Kubernetes dynamic secret, you can import the configuration from a Vault Kubernetes secrets engine role: + +1. Navigate to your project and select an environment +2. Click **"+ Add Secret"** dropdown and choose **"Dynamic Secret"** +3. Select **Kubernetes** as the provider +4. Click **"Load from Vault"** at the top of the form + + ![Load Kubernetes Dynamic Secret from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png) + +5. Select your Vault namespace, Kubernetes secrets engine mount, and role +6. Click **"Load Configuration"** + +The form will be automatically populated with the role's configuration including: + +- Cluster URL and CA certificate +- Credential type (Static or Dynamic) +- Service account name or Kubernetes role settings +- Allowed namespaces +- Token TTL values +- Token audiences + + + Sensitive values like cluster tokens cannot be retrieved from Vault and must + be manually provided in the form after loading the configuration. + + #### Import and Translate Access Control Policies When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions. diff --git a/docs/documentation/platform/gateways-deprecated/gateway-security.mdx b/docs/documentation/platform/gateways-deprecated/gateway-security.mdx deleted file mode 100644 index 93a7f662f..000000000 --- a/docs/documentation/platform/gateways-deprecated/gateway-security.mdx +++ /dev/null @@ -1,91 +0,0 @@ ---- -title: "Gateway Security Architecture" -sidebarTitle: "Architecture" -description: "Understand the security model and tenant isolation of Infisical's Gateway" ---- - -# Gateway Security Architecture - -The Infisical Gateway enables Infisical Cloud to securely interact with private resources using mutual TLS authentication and private PKI (Public Key Infrastructure) system to ensure secure, isolated communication between multiple tenants. -This document explains the internal security architecture and how tenant isolation is maintained. - -## Security Model Overview - -### Private PKI System -Each organization (tenant) in Infisical has its own private PKI system consisting of: - -1. **Root CA**: The ultimate trust anchor for the organization -2. **Intermediate CAs**: - - Client CA: Issues certificates for cloud components - - Gateway CA: Issues certificates for gateway instances - -This hierarchical structure ensures complete isolation between organizations as each has its own independent certificate chain. - -### Certificate Hierarchy -``` -Root CA (Organization Specific) -├── Client CA -│ └── Client Certificates (Cloud Components) -└── Gateway CA - └── Gateway Certificates (Gateway Instances) -``` - -## Communication Security - -### 1. Gateway Registration -When a gateway is first deployed: - -1. Establishes initial connection using machine identity token -2. Allocates a relay address for communication -3. Exchanges certificates through a secure handshake: - - Gateway receives a unique certificate signed by organization's Gateway CA along with certificate chain for verification - -### 2. Mutual TLS Authentication -All communication between gateway and cloud uses mutual TLS (mTLS): - -- **Gateway Authentication**: - - Presents certificate signed by organization's Gateway CA - - Certificate contains unique identifiers (Organization ID, Gateway ID) - - Cloud validates complete certificate chain - -- **Cloud Authentication**: - - Presents certificate signed by organization's Client CA - - Certificate includes required organizational unit ("gateway-client") - - Gateway validates certificate chain back to organization's root CA - -### 3. Relay Communication -The relay system provides secure tunneling: - -1. **Connection Establishment**: - - Uses QUIC protocol over UDP for efficient, secure communication - - Provides built-in encryption, congestion control, and multiplexing - - Enables faster connection establishment and reduced latency - - Each organization's traffic is isolated using separate relay sessions - -2. **Traffic Isolation**: - - Each gateway gets unique relay credentials - - Traffic is end-to-end encrypted using QUIC's TLS 1.3 - - Organization's private keys never leave their environment - -## Tenant Isolation - -### Certificate-Based Isolation -- Each organization has unique root CA and intermediate CAs -- Certificates contain organization-specific identifiers -- Cross-tenant communication is cryptographically impossible - -### Gateway-Project Mapping -- Gateways are explicitly mapped to specific projects -- Access controls enforce organization boundaries -- Project-level permissions determine resource accessibility - -### Resource Access Control -1. **Project Verification**: - - Gateway verifies project membership - - Validates organization ownership - - Enforces project-level permissions - -2. **Resource Restrictions**: - - Gateways only accept connections to approved resources - - Each connection requires explicit project authorization - - Resources remain private to their assigned organization diff --git a/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png b/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png deleted file mode 100644 index 5f942bcf0..000000000 Binary files a/docs/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png and /dev/null differ diff --git a/docs/documentation/platform/gateways-deprecated/networking.mdx b/docs/documentation/platform/gateways-deprecated/networking.mdx deleted file mode 100644 index 51a81ee42..000000000 --- a/docs/documentation/platform/gateways-deprecated/networking.mdx +++ /dev/null @@ -1,170 +0,0 @@ ---- -title: "Networking" -description: "Network configuration and firewall requirements for Infisical Gateway" ---- - -The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. -This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. - -## Network Architecture - -The gateway uses a relay-based architecture to establish secure connections: - -1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol -2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud -3. All traffic is end-to-end encrypted using mutual TLS over QUIC - -## Required Network Connectivity - -### Outbound Connections (Required) - -The gateway requires the following outbound connectivity: - -| Protocol | Destination | Ports | Purpose | -|----------|-------------|-------|---------| -| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | - -### Relay Server IP Addresses - -Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. - - - - ``` - 54.235.197.91:49152-65535 - 18.215.196.229:49152-65535 - 3.222.120.233:49152-65535 - 34.196.115.157:49152-65535 - ``` - - - ``` - 3.125.237.40:49152-65535 - 52.28.157.98:49152-65535 - 3.125.176.90:49152-65535 - ``` - - - Please contact your Infisical account manager for dedicated relay server IP addresses. - - - - - These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. - - -## Protocol Details - -### QUIC over UDP - -The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: - -- **Port 5349**: STUN/TURN over TLS (secure relay communication) -- **Built-in features**: Connection migration, multiplexing, reduced latency -- **Encryption**: TLS 1.3 with certificate pinning - -## Understanding Firewall Behavior with UDP - -Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. -When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. -Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. - - -### Connection Tracking - -Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: -- Automatically handles return responses -- Reduces firewall rule complexity -- Avoids the need for manual IP whitelisting - -In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. - -## Common Network Scenarios - -### Corporate Firewalls - -For corporate environments with strict egress filtering: - -1. **Whitelist relay IP addresses** (listed above) -2. **Allow UDP port 5349** outbound -3. **Configure connection tracking** for UDP return traffic -4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled - -### Cloud Environments (AWS/GCP/Azure) - -Configure security groups to allow: -- **Outbound UDP** to relay IPs on port 5349 -- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 -- **Inbound UDP** on ephemeral ports (if not using stateful rules) - -## Frequently Asked Questions - - - -The gateway is designed to handle network interruptions gracefully: - -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost -- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention -- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers -- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions -- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity - -No manual intervention is typically required during network interruptions. - - - -QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: - -- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time -- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default -- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) -- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other -- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery -- **Lower latency**: Optimized for real-time communication between gateway and cloud services - -While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. - - - -No inbound ports need to be opened. The gateway only makes outbound connections: - -- **Outbound UDP** to relay servers on ports 49152-65535 -- **Outbound HTTPS** to Infisical API endpoints -- **Return responses** are handled by connection tracking or explicit IP whitelisting - -This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. - - - -If your firewall has strict UDP restrictions: - -1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses -2. **Use explicit IP whitelisting** if connection tracking is disabled -3. **Consider network policy exceptions** for the gateway host -4. **Monitor firewall logs** to identify which specific rules are blocking traffic - -The gateway requires UDP connectivity to function - TCP-only configurations are not supported. - - - -The gateway connects to **one relay server at a time**: - -- **Single active connection**: Only one relay connection is established per gateway instance -- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay -- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing -- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity - -You should whitelist all relay IP addresses to ensure proper failover functionality. - - -No, relay servers cannot decrypt any traffic passing through them: - -- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning -- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys -- **No data storage**: Relay servers do not store any traffic or network-identifiable information -- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation - -The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. - - diff --git a/docs/documentation/platform/gateways-deprecated/overview.mdx b/docs/documentation/platform/gateways-deprecated/overview.mdx deleted file mode 100644 index f81809f7b..000000000 --- a/docs/documentation/platform/gateways-deprecated/overview.mdx +++ /dev/null @@ -1,352 +0,0 @@ ---- -title: "Gateway" -sidebarTitle: "Overview" -description: "How to access private network resources from Infisical" ---- - -![Alt text](/documentation/platform/gateways-deprecated/images/gateway-highlevel-diagram.png) - -The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. -This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. -Common use cases include generating dynamic credentials or rotating credentials for private databases. - - - Gateway is a paid feature available under the Enterprise Tier for Infisical - Cloud users. Self-hosted Infisical users can contact - [sales@infisical.com](mailto:sales@infisical.com) to purchase an enterprise - license. - - -## How It Works - -The Gateway serves as a secure intermediary that facilitates direct communication between the Infisical server and your private network. -It’s a lightweight daemon packaged within the Infisical CLI, making it easy to deploy and manage. Once set up, the Gateway establishes a connection with a relay server, ensuring that all communication between Infisical and your Gateway is fully end-to-end encrypted. -This setup guarantees that only the platform and your Gateway can decrypt the transmitted information, keeping communication with your resources secure, private and isolated. - -## Deployment - -The Infisical Gateway is seamlessly integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. -You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. -For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). - -To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. -Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. - -### Get started - - - - 1. Navigate to **Organization Access Control** in your Infisical dashboard. - 2. Create a dedicated machine identity for your Gateway. - 3. **Best Practice:** Assign a unique identity to each Gateway for better security and management. - ![Create Gateway Identity](../../../images/platform/gateways/create-identity-for-gateway.png) - - - - You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). - - - - Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: - - - - For production deployments on Linux, install the Gateway as a systemd service: - ```bash - sudo infisical gateway install --token --domain - sudo systemctl start infisical-gateway - ``` - This will install and start the Gateway as a secure systemd service that: - - Runs with restricted privileges: - - Runs as root user (required for secure token management) - - Restricted access to home directories - - Private temporary directory - - Automatically restarts on failure - - Starts on system boot - - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` - - - The install command requires: - - Linux operating system - - Root/sudo privileges - - Systemd - - - - - - The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. - - For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: - - ### Install the latest Helm Chart repository - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - ``` - - ### Update the Helm Chart repository - ```bash - helm repo update - ``` - - ### Create a Kubernetes Secret containing gateway environment variables - - The gateway supports all identity authentication methods through the use of environment variables. - The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. - - - #### Supported authentication methods - - - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=universal-auth --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= - ``` - - - - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= - ``` - - - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= - ``` - - - - - #### Other environment variables - - - - The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. - - - - - ### Install the Infisical Gateway Helm Chart - ```bash - helm install infisical-gateway infisical-helm-charts/infisical-gateway - ``` - - ### Check the gateway logs - After installing the gateway, you can check the logs to ensure it's running as expected. - - ```bash - kubectl logs deployment/infisical-gateway - ``` - - You should see the following output which indicates the gateway is running as expected. - ```bash - $ kubectl logs deployment/infisical-gateway - INF Provided relay port 5349. Using TLS - INF Connected with relay - INF 10.0.101.112:56735 - INF Starting relay connection health check - INF Gateway started successfully - INF New connection from: 10.0.1.8:34051 - INF Gateway is reachable by Infisical - ``` - - - - - For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: - ```bash - infisical gateway --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) - ``` - - Alternatively, if you already have the token, use it directly with the `--token` flag: - ```bash - infisical gateway --token - ``` - - Or set it as an environment variable: - ```bash - export INFISICAL_TOKEN= - infisical gateway - ``` - - - - For detailed information about the gateway command and its options, see the [gateway command documentation](/cli/commands/gateway). - - - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical. - - - - - - To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. - ![Gateway List](../../../images/platform/gateways/gateway-list.png) - - diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment/overview.mdx similarity index 95% rename from docs/documentation/platform/gateways/relay-deployment.mdx rename to docs/documentation/platform/gateways/relay-deployment/overview.mdx index 767cf3732..ba7689196 100644 --- a/docs/documentation/platform/gateways/relay-deployment.mdx +++ b/docs/documentation/platform/gateways/relay-deployment/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Relay Deployment" +title: "Overview" description: "How to deploy Infisical Relay Servers" --- @@ -107,13 +107,8 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. - - Install the Infisical CLI on the server where you plan to deploy the relay. The CLI is required for relay installation and management. - - See the [CLI Installation Guide](/cli/overview) for instructions. - - This server must have a static IP address or DNS name to be identifiable by the Infisical platform. - + + Provision a server or virtual machine where you plan to deploy the relay. This server must have a static IP address or DNS name to be identifiable by the Infisical platform. @@ -133,6 +128,8 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. + You can deploy the Infisical Relay in various ways. This guide provides a manual setup example using the Infisical CLI. For an infrastructure-as-code approach, see our [Terraform guide](/documentation/platform/gateways/relay-deployment/terraform). + The Infisical CLI is used to install and start the relay in your chosen environment. The CLI provides commands for both production and development scenarios, and supports a variety of options/flags to configure your deployment. To view all available flags and equivalent environment variables for relay deployment, see the [Relay CLI Command Reference](/cli/commands/relay). diff --git a/docs/documentation/platform/gateways/relay-deployment/terraform.mdx b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx new file mode 100644 index 000000000..e89871cd9 --- /dev/null +++ b/docs/documentation/platform/gateways/relay-deployment/terraform.mdx @@ -0,0 +1,151 @@ +--- +title: "Terraform" +description: "How to deploy Infisical Relay Servers using Terraform" +--- + +This guide walks you through deploying an Infisical Relay server using Terraform. Select a provider below for specific instructions. + + + +The provided configuration automates the creation of the EC2 instance, sets up the necessary security group rules, and uses a startup script to install and configure the Infisical Relay service. + +### Prerequisites + +Before you start, make sure you have the following: +- An AWS account with permissions to create EC2 instances, Security Groups, and Elastic IPs. +- An existing VPC and Subnet ID in your desired AWS region. +- The AMI ID for your chosen OS (this guide uses an Ubuntu 22.04 LTS AMI). +- Credentials for the Infisical Relay to authenticate with your Infisical instance. This guide uses a Machine Identity token, but other methods are available. You can find a full list of authentication options [here](/cli/commands/relay#available-authentication-methods). + +### Terraform Configuration + +Here is the complete Terraform configuration to deploy the Infisical Relay. + +```terraform +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "us-west-2" # Change to your desired AWS region +} + +# Security Group for the Infisical Relay instance +resource "aws_security_group" "infisical_relay_sg" { + name = "infisical-relay-sg" + description = "Allows inbound traffic for Infisical Relay and SSH" + vpc_id = "vpc-0c71f9c5709d88d18" # Change to your VPC ID + + # Inbound: Allows the Infisical platform to securely communicate with the Relay server. + ingress { + from_port = 8443 + to_port = 8443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows Infisical Gateway to securely communicate via the Relay. + ingress { + from_port = 2222 + to_port = 2222 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + # Inbound: Allows secure shell (SSH) access for administration. + ingress { + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # Restrict this to your IP in production + } + + # Outbound: Allows the Relay server to make necessary outbound connections to the Infisical platform. + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "infisical-relay-sg" + } +} + +# Elastic IP for a static public IP address +resource "aws_eip" "infisical_relay_eip" { + tags = { + Name = "infisical-relay-eip" + } +} + +# EC2 instance to run Infisical Relay +module "infisical_relay_instance" { + source = "terraform-aws-modules/ec2-instance/aws" + version = "~> 5.6" + + name = "infisical-relay-example" + ami = "ami-065778886ef8ec7c8" # Change to your desired AMI ID + instance_type = "t3.micro" + subnet_id = "subnet-0fd2337a1c604a494" # Change to your Subnet ID + + vpc_security_group_ids = [aws_security_group.infisical_relay_sg.id] + associate_public_ip_address = false # We are using an Elastic IP instead + + user_data = <<-EOT + #!/bin/bash + set -e + # Install Infisical CLI + curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash + apt-get update && apt-get install -y infisical + + # Install the relay as a systemd service. + # This example uses a Machine Identity token for authentication via the INFISICAL_TOKEN environment variable. + # + # Note: For production environments, you might consider fetching the token from AWS Parameter Store or AWS Secrets Manager. + export INFISICAL_TOKEN="your-machine-identity-token" + sudo -E infisical relay systemd install \ + --name "my-relay-example" \ + --domain "https://app.infisical.com" \ + --host "${aws_eip.infisical_relay_eip.public_ip}" + + # Start and enable the service to run on boot + sudo systemctl start infisical-relay + sudo systemctl enable infisical-relay + EOT +} + +# Associate the Elastic IP with the EC2 instance +resource "aws_eip_association" "eip_assoc" { + instance_id = module.infisical_relay_instance.id + allocation_id = aws_eip.infisical_relay_eip.id +} +``` + + +The provided security group rules are open to the internet (`0.0.0.0/0`) for simplicity. In a production environment, you should restrict the `cidr_blocks` to known IP addresses for enhanced security, especially for the SSH port (22). + + +### How to Deploy + +1. **Save the configuration:** Save the code above to a file named `main.tf`. +2. **Customize values:** Update the placeholder values in `main.tf` to match your AWS environment and Infisical credentials. You'll need to replace: + - `region` in the `provider` block. + - `vpc_id` in the `aws_security_group` resource. + - `ami` and `subnet_id` in the `infisical_relay_instance` module. + - The `INFISICAL_TOKEN` environment variable in the `user_data` script (e.g., `export INFISICAL_TOKEN="your-machine-identity-token"`). + - The `--domain` in the `user_data` script if you are self-hosting Infisical. +3. **Apply the configuration:** Run the following Terraform commands in your terminal: + ```bash + terraform init + terraform plan + terraform apply + ``` + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index 7e40f85f9..d7b7663a9 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -13,7 +13,7 @@ Each identity must authenticate with the Infisical API using a supported authent Key Features: -- Role Assignment: Identities must be assigned [roles](/documentation/platform/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. +- Role Assignment: Identities must be assigned [roles](/documentation/platform/access-controls/role-based-access-controls). These roles determine the scope of access to resources, either at the organization level or project level. - Auth/Token Configuration: Identities must be configured with corresponding authentication methods and access token properties to securely interact with the Infisical API. ## Workflow diff --git a/docs/documentation/platform/kms/hsm-integration.mdx b/docs/documentation/platform/kms/hsm-integration.mdx index 45d883977..7a8d15fe5 100644 --- a/docs/documentation/platform/kms/hsm-integration.mdx +++ b/docs/documentation/platform/kms/hsm-integration.mdx @@ -1428,7 +1428,7 @@ Enabling HSM encryption has a set of key benefits: infisical: image: repository: infisical/infisical - tag: "v0.151.0-nightly-20251013.1" + tag: "v0.151.0" pullPolicy: IfNotPresent extraVolumeMounts: diff --git a/docs/documentation/platform/kms/kmip.mdx b/docs/documentation/platform/kms/kmip.mdx index 1e025d51a..d82269447 100644 --- a/docs/documentation/platform/kms/kmip.mdx +++ b/docs/documentation/platform/kms/kmip.mdx @@ -8,135 +8,256 @@ description: "Learn more about integrating with Infisical KMS using KMIP (Key Ma sales@infisical.com if you have any questions. -## Overview +Infisical KMS provides Key Management Interoperability Protocol (KMIP) support for integration with KMIP-compatible clients. This allows for enhanced key management across various applications that support the KMIP 1.4 protocol. -Infisical KMS provides **Key Management Interoperability Protocol (KMIP)** support, enabling seamless integration with KMIP-compatible clients. This allows for enhanced key management across various applications that support the **KMIP 1.4 protocol**. +## How KMIP Works with Infisical -## Supported Operations +At a high level, the KMIP integration follows this architecture: -The Infisical KMIP server supports the following operations for **symmetric keys**: -- **Create** - Generate symmetric keys. -- **Register** - Register externally created keys. -- **Locate** - Find keys using attributes. -- **Get** - Retrieve keys securely. -- **Activate** - Enable keys for usage. -- **Revoke** - Revoke existing keys. -- **Destroy** - Permanently remove keys. -- **Get Attributes** - Retrieve metadata associated with keys. -- **Query** - Query server capabilities and supported operations. +![KMIP Architecture Diagram](/images/kmip-diagram.png) -## Benefits of KMIP Integration +At a high level, the KMIP integration works as follows: -Integrating Infisical KMS with KMIP-compatible clients provides the following benefits: +1. KMIP clients (your applications or tools) communicate with the KMIP server +2. The KMIP server acts as a proxy and forwards requests to Infisical KMS +3. The KMIP server authenticates to Infisical using a machine identity -- **Standardized Key Management**: Allows interoperability with security and cryptographic applications that support KMIP. -- **Enterprise-Grade Security**: Utilizes Infisical’s encryption mechanisms to securely store and manage keys. -- **Centralized Key Management**: Enables a unified approach for managing cryptographic keys across multiple environments. +The KMIP server itself is deployed using the Infisical CLI (`infisical kmip start` command) and serves as an intermediary between your KMIP clients and Infisical's key management system. -## Compatibility +### Supported Operations -Infisical KMIP supports **KMIP versions 1.0 to 1.4**, ensuring compatibility with a wide range of clients and security tools. +The Infisical KMIP server supports the following operations for symmetric keys: -## Secure Communication & Authorization +- **Create** - Generate symmetric keys +- **Register** - Register externally created keys +- **Locate** - Find keys using attributes +- **Get** - Retrieve keys securely +- **Activate** - Enable keys for usage +- **Revoke** - Revoke existing keys +- **Destroy** - Permanently remove keys +- **Get Attributes** - Retrieve metadata associated with keys +- **Query** - Query server capabilities and supported operations -KMIP client-server communication is secured using **mutual TLS (mTLS)**, ensuring strong identity verification and encrypted data exchange via **PKI certificates**. Each KMIP entity must possess valid certificates signed by a trusted Root CA to establish trust. -For strong isolation, each Infisical organization has its own KMIP PKI (Public Key Infrastructure), ensuring that cryptographic operations and certificate authorities remain separate across organizations. +### Compatibility -Infisical KMS enforces a **two-layer authorization model** for KMIP operations: +Infisical KMIP supports KMIP versions 1.0 to 1.4, ensuring compatibility with a wide range of clients and security tools. -1. **KMIP Server Authorization** – The KMIP server, acting as a proxy, must have the `proxy KMIP` permission to forward client requests to Infisical KMS. This is done using a **machine identity** attached to the KMIP server. -2. **KMIP Client Authorization** – Clients must have the necessary KMIP-level permissions to perform specific key management operations. +### Network Requirements -By combining **mTLS for secure communication** and **machine identity-based proxying**, Infisical KMS ensures **strong authentication, controlled access, and centralized key management** for KMIP operations. +Ensure the following network connectivity is in place: -## Setup Instructions +- **KMIP Client → KMIP Server**: KMIP clients must be able to reach the KMIP server on port 5696 (or your configured port). Ensure firewalls allow this traffic and DNS resolution works if using hostnames. -### Setup KMIP for your organization +- **KMIP Server → Infisical Platform**: The KMIP server needs outbound HTTP access to Infisical. For self-hosted instances, ensure connectivity to your custom domain. + +## Configure and Deploy the KMIP Server + +Follow these steps in order to set up KMIP integration with Infisical: - - From there, press Setup KMIP. + + First, you need to enable KMIP for your entire Infisical organization and set up its PKI infrastructure. + + Navigate to **Organization Settings > KMIP** and click **Setup KMIP**. + ![KMIP org navigate](/images/platform/kms/kmip/kmip-org-setup-navigation.png) - - - In the modal, select the desired key algorithm to use for the KMIP PKI of your organization. Press continue. + + In the modal, select the desired key algorithm to use for the KMIP PKI of your organization, then click **Continue**. + ![KMIP org PKI setup](/images/platform/kms/kmip/kmip-org-setup-modal.png) - This generates the KMIP PKI for your organization. After this, you can proceed to setting up your KMIP server. - + This generates the KMIP PKI for your organization, creating the cryptographic foundation that will be used for secure KMIP communications. + + You do not need to manage these certificates yourself; Infisical handles the PKI infrastructure for you. + - -### Deploying and Configuring the KMIP Server + + The KMIP server needs a machine identity to authenticate with Infisical and proxy requests on behalf of clients. -Follow these steps to configure and deploy a KMIP server. + Configure a [machine identity](/documentation/platform/identities/machine-identities#machine-identities) by heading to your organization's **Access Control** and switching over to the **identities** tab. + From there you can click **Create Identity**. - - - Configure a [machine identity](https://infisical.com/docs/documentation/platform/identities/machine-identities#machine-identities) for the KMIP server to use. + This guide assumes you'll be using the [Universal Auth](/documentation/platform/identities/universal-auth) method for the machine identity but you can choose any supported authentication method. + ![KMIP create machine identity](/images/platform/kms/kmip/kmip-create-mi.png) + This machine identity will be used by the KMIP server to authenticate and forward client requests to Infisical KMS. + + + + The machine identity needs permission to proxy KMIP requests. + Create a custom organization role and give it the **Proxy KMIP** permission. + ![KMIP create custom role](/images/platform/kms/kmip/kmip-create-custom-role.png) ![KMIP assign proxy to role](/images/platform/kms/kmip/kmip-assign-custom-role-proxy.png) - Assign the machine identity to the custom organization role. This allows the machine identity to serve KMIP client requests and forward them from your KMIP server to Infisical. - ![KMIP assign role to machine identity](/images/platform/kms/kmip/kmip-assign-mi-to-role.png) - + This permission allows the KMIP server to act as an intermediary between KMIP clients and Infisical. - - To deploy the KMIP server, use the Infisical CLI’s `kmip start` command. - Before proceeding, make sure you have the [Infisical CLI installed](https://infisical.com/docs/cli/overview). + + Now connect the machine identity to the role you just created. - Once installed, launch the KMIP server with the following command: + Assign the machine identity to the custom organization role. + + ![KMIP assign role to machine identity](/images/platform/kms/kmip/kmip-assign-mi-to-role.png) + + This grants the machine identity the ability to serve KMIP client requests and forward them from your KMIP server to Infisical. + + + + Now you're ready to deploy the KMIP server. + You can run the KMIP server on any infrastructure that can reach the Infisical platform, such as a VM or container. + + Once you have your infrastructure ready, you'll need to install the Infisical CLI on the server where you want to run the KMIP server. + + To install the latest Infisical CLI visit [Infisical CLI instructions](https://infisical.com/docs/cli/overview). + If you need to install specific versions of the CLI, you can find them on the [Infisical CLI GitHub Releases](https://github.com/Infisical/cli/releases). + + Then, launch the KMIP server with the following command: ```bash infisical kmip start \ - --identity-client-id= \ # This can be set by defining the INFISICAL_UNIVERSAL_AUTH_CLIENT_ID ENV variable - --identity-client-secret= \ # This can be set by defining the INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET ENV variable - --domain=https://app.infisical.com \ + --identity-client-id=example-client-id \ + --identity-client-secret=example-client-secret \ + --domain=https://my-infisical-instance.com \ + --listen-address="0.0.0.0:5696" \ --hostnames-or-ips="my-kmip-server.com" ``` - The following flags are available for the `infisical kmip start` command:: - - **listen-address** (default: localhost:5696): The address the KMIP server listens on. - - **identity-auth-method** (default: universal-auth): The authentication method for the machine identity. - - **identity-client-id**: The client ID of the machine identity. This can be set by defining the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` ENV variable. - - **identity-client-secret**: The client secret of the machine identity. This can be set by defining the `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` ENV variable. - - **server-name** (default: "kmip-server"): The name of the KMIP server. - - **certificate-ttl** (default: "1y"): The duration for which the server certificate is valid. - - **hostnames-or-ips:** A comma-separated list of hostnames or IPs the KMIP server will use (required). + **Available flags:** + - **listen-address** (default: localhost:5696): The address the KMIP server listens on. In most cases you'll want to listen on all interfaces (0.0.0.0:5696) + - **identity-auth-method** (default: universal-auth): The authentication method for the machine identity + - **identity-client-id**: The client ID of the machine identity (can be set via `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` env var) + - **identity-client-secret**: The client secret of the machine identity (can be set via `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` env var) + - **server-name** (default: "kmip-server"): The name of the KMIP server + - **certificate-ttl** (default: "1y"): The duration for which the server certificate is valid + - **hostnames-or-ips**: The IP address or the hostname of the server where you have deployed the KMIP server. + Once started, your KMIP server is now running and ready to accept client connections. It will authenticate to Infisical using the machine identity and proxy all KMIP operations. - -### Add and Configure KMIP Clients + + Now that the KMIP server is running, you need to register KMIP clients that will connect to it. - - - From there, press Add KMIP Client + Navigate to the desired KMS project if you already have one or create a new project of type KMS, then select **KMIP** once inside the project, and click **Add KMIP Client**. + ![KMIP client overview](/images/platform/kms/kmip/kmip-client-overview.png) - - In the modal, provide the details of your client. The selected permissions determine what KMIP operations can be performed in your KMS project. + + + Define the client and its permissions. + + In the modal, provide the details of your client. The selected permissions determine what KMIP operations (Create, Get, Revoke, etc.) can be performed in your KMS project. + ![KMIP client modal](/images/platform/kms/kmip/kmip-client-modal.png) + + This creates a KMIP client entity in Infisical that will be authenticated via mTLS certificates. - - Once the KMIP client is created, you will have to generate a client certificate. - Press Generate Certificate. + + + Each KMIP client needs its own certificate for mTLS authentication. + + Click **Generate Certificate** for your newly created client. + ![KMIP generate client cert](/images/platform/kms/kmip/kmip-client-generate-cert.png) - Provide the desired TTL and key algorithm to use and press Generate Client Certificate. + Provide the desired TTL (time-to-live) and key algorithm, then click **Generate Client Certificate**. + ![KMIP client cert config](/images/platform/kms/kmip/kmip-client-cert-config-modal.png) - Configure your KMIP clients to use the generated client certificate, certificate chain and private key. + Download the generated client certificate, certificate chain, and private key. + ![KMIP client cert modal](/images/platform/kms/kmip/kmip-client-certificate-modal.png) + Configure your KMIP-compatible applications or tools to use these credentials when connecting to the KMIP server. The client will now authenticate via mTLS and perform authorized key management operations through the KMIP server, which proxies requests to Infisical KMS. -## Additional Resources +## Connecting your KMIP Client to Infisical -- [KMIP 1.4 Specification](http://docs.oasis-open.org/kmip/spec/v1.4/os/kmip-spec-v1.4-os.html) +After completing the setup, configure your KMIP compatible application to connect to the KMIP server. + +While exact configuration steps vary by application, you'll generally need to provide: + +1. **KMIP Server Address**: The hostname or IP and port where your KMIP server is listening (e.g., `my-kmip-server.com:5696`) + +2. **Client Certificates**: The certificate credentials generated from your Infisical KMS project: + - **Client Certificate** (`client-cert.pem`) - Identifies your KMIP client + - **Client Private Key** (`client-key.pem`) - Used for mTLS authentication + - **Certificate Chain** (`cert-chain.pem`) - Verifies the KMIP server + +### General Configuration Steps + + + Determine the address where your KMIP server is accessible. This should match one of the hostnames or IPs you specified when starting the KMIP server with the `--hostnames-or-ips` flag. + + **Example endpoints:** + - `my-kmip-server.com:5696` + - `10.0.1.50:5696` + - `kmip.example.com:5696` + + The default port is `5696`, but this can be changed using the `--listen-address` flag when starting the server. + + + + Organize the certificate materials you downloaded when generating the client certificate from the Infisical KMS project. You should have three files: + + - **client-cert.pem** - The client certificate + - **cert-chain.pem** - The certificate chain (includes intermediate and root CA certificates) + - **client-key.pem** - The private key + + Most KMIP clients require these files in PEM format, which is what Infisical provides by default. + + + + The exact configuration steps vary depending on your KMIP client application. Generally, you'll need to specify: + + **Common configuration parameters:** + - **Server hostname/IP**: Your KMIP server address (e.g., `my-kmip-server.com`) + - **Server port**: Default is `5696` + - **Client certificate**: Path to `client-cert.pem` + - **Client private key**: Path to `client-key.pem` + - **CA certificate**: Path to `cert-chain.pem` (used to verify the server) + - **Protocol version**: KMIP 1.0 through 1.4 are supported + + **Example configuration for PyKMIP:** + ```ini + [client] + host=my-kmip-server.com + port=5696 + certfile=/path/to/client-cert.pem + keyfile=/path/to/client-key.pem + ca_certs=/path/to/cert-chain.pem + ``` + + + + + Once configured, test the connection by performing a simple KMIP operation, such as: + - Querying server capabilities + - Creating a test key + - Listing available keys + + If the connection is successful, your KMIP client is now integrated with Infisical KMS and can perform key management operations according to the permissions you assigned. + + **Troubleshooting connection issues:** + - Verify network connectivity between your KMIP client and the KMIP server + - Check that certificate files are readable and in the correct format + - Ensure the KMIP server is running and accessible + - Review KMIP server logs for authentication errors + - Confirm the client certificate has not expired + + If you require further verification of your certificate details and connectivity to the KMIP server from your KMIP client, you can use the following command from your client machine: + + ```bash + openssl s_client -connect kmip-server-ip-here:5696 --cert /path/to/client-cert.pem --key /path/to/client-cert.pem --CAfile /path/to/cert-chain.pem --tls1_2 --showcerts --state --debug + ``` + + This command attempts to establish a TLS connection to the KMIP server using your client certificate and key, displaying detailed information about the handshake process. + If the connection is successful, you'll see the server's certificate chain and a message indicating that the handshake was completed. + + + diff --git a/docs/documentation/platform/pam/overview.mdx b/docs/documentation/platform/pam/overview.mdx new file mode 100644 index 000000000..a6e0094f5 --- /dev/null +++ b/docs/documentation/platform/pam/overview.mdx @@ -0,0 +1,45 @@ +--- +title: "Infisical PAM" +sidebarTitle: "Overview" +description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals." +--- + +Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to. + +### How it Works + +Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable. + +#### 1. Create a Resource + +The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port. + +![Create Resource](/images/pam/overview/create-resource.png) + +#### 2. Add Accounts to the Resource + +Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place. + +![Create Account](/images/pam/overview/create-account.png) + +### Infisical PAM Features + +#### Session Logging and Auditing + +- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session. +- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project. + +![Session Page](/images/pam/overview/session-page.png) + +#### Automated Credential Rotation + +Infisical PAM can automatically rotate account credentials to enhance your security posture. + +Here’s how it works: +1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource. +![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png) + +2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days). +![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png) + +Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness. diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index 7d0df2e22..f2570f290 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -22,6 +22,7 @@ The supported project types are: - [Infisical PKI](/documentation/platform/pki/overview): Issue and manage X.509 certificates using protocols like EST, with support for internal and external CAs. - [Infisical SSH](/documentation/platform/ssh/overview): Provide short-lived SSH access to servers using certificate-based authentication, replacing static keys with policy-driven, time-bound control. - [Infisical KMS](/documentation/platform/kms/overview): Encrypt and decrypt data using centrally managed keys with enforced access policies and full audit visibility. +- [Infisical PAM](/documentation/platform/pam/overview): Manage access to resources like databases, servers, and accounts with policy-based controls and approvals. ## Roles and Access Control diff --git a/docs/images/app-connections/github/create-pat-form.png b/docs/images/app-connections/github/create-pat-form.png new file mode 100644 index 000000000..cc8abdf15 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-form.png differ diff --git a/docs/images/app-connections/github/create-pat-method.png b/docs/images/app-connections/github/create-pat-method.png new file mode 100644 index 000000000..eec811705 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-method.png differ diff --git a/docs/images/app-connections/github/create-pat-token-generated.png b/docs/images/app-connections/github/create-pat-token-generated.png new file mode 100644 index 000000000..430e67671 Binary files /dev/null and b/docs/images/app-connections/github/create-pat-token-generated.png differ diff --git a/docs/images/app-connections/github/pat-connection.png b/docs/images/app-connections/github/pat-connection.png new file mode 100644 index 000000000..802ed4e38 Binary files /dev/null and b/docs/images/app-connections/github/pat-connection.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-group-access-token-created.png b/docs/images/app-connections/gitlab/gitlab-group-access-token-created.png new file mode 100644 index 000000000..f5a7383f2 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-group-access-token-created.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-group-access-token-form-secret-sync.png b/docs/images/app-connections/gitlab/gitlab-group-access-token-form-secret-sync.png new file mode 100644 index 000000000..04dee5a5c Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-group-access-token-form-secret-sync.png differ diff --git a/docs/images/app-connections/gitlab/gitlab-group-access-token-list.png b/docs/images/app-connections/gitlab/gitlab-group-access-token-list.png new file mode 100644 index 000000000..e29df0418 Binary files /dev/null and b/docs/images/app-connections/gitlab/gitlab-group-access-token-list.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-form.png b/docs/images/app-connections/northflank/northflank-app-connection-form.png new file mode 100644 index 000000000..226346517 Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-form.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-generated.png b/docs/images/app-connections/northflank/northflank-app-connection-generated.png new file mode 100644 index 000000000..89038637a Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-generated.png differ diff --git a/docs/images/app-connections/northflank/northflank-app-connection-option.png b/docs/images/app-connections/northflank/northflank-app-connection-option.png new file mode 100644 index 000000000..17106494a Binary files /dev/null and b/docs/images/app-connections/northflank/northflank-app-connection-option.png differ diff --git a/docs/images/app-connections/northflank/step-1.png b/docs/images/app-connections/northflank/step-1.png new file mode 100644 index 000000000..fe97a4a30 Binary files /dev/null and b/docs/images/app-connections/northflank/step-1.png differ diff --git a/docs/images/app-connections/northflank/step-2.png b/docs/images/app-connections/northflank/step-2.png new file mode 100644 index 000000000..e77d1e740 Binary files /dev/null and b/docs/images/app-connections/northflank/step-2.png differ diff --git a/docs/images/app-connections/northflank/step-3.png b/docs/images/app-connections/northflank/step-3.png new file mode 100644 index 000000000..739c4e48b Binary files /dev/null and b/docs/images/app-connections/northflank/step-3.png differ diff --git a/docs/images/app-connections/northflank/step-4-1.png b/docs/images/app-connections/northflank/step-4-1.png new file mode 100644 index 000000000..cd4a5dfaf Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-1.png differ diff --git a/docs/images/app-connections/northflank/step-4-2.png b/docs/images/app-connections/northflank/step-4-2.png new file mode 100644 index 000000000..52789f9d1 Binary files /dev/null and b/docs/images/app-connections/northflank/step-4-2.png differ diff --git a/docs/images/app-connections/northflank/step-5.png b/docs/images/app-connections/northflank/step-5.png new file mode 100644 index 000000000..d8e9c817b Binary files /dev/null and b/docs/images/app-connections/northflank/step-5.png differ diff --git a/docs/images/app-connections/northflank/step-6.png b/docs/images/app-connections/northflank/step-6.png new file mode 100644 index 000000000..457a60cdf Binary files /dev/null and b/docs/images/app-connections/northflank/step-6.png differ diff --git a/docs/images/app-connections/northflank/step-7.png b/docs/images/app-connections/northflank/step-7.png new file mode 100644 index 000000000..76480665f Binary files /dev/null and b/docs/images/app-connections/northflank/step-7.png differ diff --git a/docs/images/integrations/github/integrations-github-config-new-pat.png b/docs/images/integrations/github/integrations-github-config-new-pat.png new file mode 100644 index 000000000..11766edfd Binary files /dev/null and b/docs/images/integrations/github/integrations-github-config-new-pat.png differ diff --git a/docs/images/kmip-diagram.png b/docs/images/kmip-diagram.png new file mode 100644 index 000000000..cc56607a2 Binary files /dev/null and b/docs/images/kmip-diagram.png differ diff --git a/docs/images/pam/overview/create-account.png b/docs/images/pam/overview/create-account.png new file mode 100644 index 000000000..34f1c7434 Binary files /dev/null and b/docs/images/pam/overview/create-account.png differ diff --git a/docs/images/pam/overview/create-resource.png b/docs/images/pam/overview/create-resource.png new file mode 100644 index 000000000..ac34b9dca Binary files /dev/null and b/docs/images/pam/overview/create-resource.png differ diff --git a/docs/images/pam/overview/credential-rotation-account.png b/docs/images/pam/overview/credential-rotation-account.png new file mode 100644 index 000000000..5e379eccc Binary files /dev/null and b/docs/images/pam/overview/credential-rotation-account.png differ diff --git a/docs/images/pam/overview/rotate-credentials-account.png b/docs/images/pam/overview/rotate-credentials-account.png new file mode 100644 index 000000000..3c908cd49 Binary files /dev/null and b/docs/images/pam/overview/rotate-credentials-account.png differ diff --git a/docs/images/pam/overview/session-page.png b/docs/images/pam/overview/session-page.png new file mode 100644 index 000000000..5c2fa41cf Binary files /dev/null and b/docs/images/pam/overview/session-page.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png new file mode 100644 index 000000000..7cb345637 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-role-modal.png differ diff --git a/docs/images/platform/gateways/assign-project.png b/docs/images/platform/gateways/assign-project.png deleted file mode 100644 index a1ff61909..000000000 Binary files a/docs/images/platform/gateways/assign-project.png and /dev/null differ diff --git a/docs/images/platform/gateways/create-identity-for-gateway.png b/docs/images/platform/gateways/create-identity-for-gateway.png deleted file mode 100644 index d7ef6b02a..000000000 Binary files a/docs/images/platform/gateways/create-identity-for-gateway.png and /dev/null differ diff --git a/docs/images/platform/gateways/dynamic-secret.png b/docs/images/platform/gateways/dynamic-secret.png deleted file mode 100644 index bf742413e..000000000 Binary files a/docs/images/platform/gateways/dynamic-secret.png and /dev/null differ diff --git a/docs/images/platform/gateways/edit-gateway.png b/docs/images/platform/gateways/edit-gateway.png deleted file mode 100644 index 04ef2a7d2..000000000 Binary files a/docs/images/platform/gateways/edit-gateway.png and /dev/null differ diff --git a/docs/images/platform/gateways/gateway-list.png b/docs/images/platform/gateways/gateway-list.png deleted file mode 100644 index 11f8206fe..000000000 Binary files a/docs/images/platform/gateways/gateway-list.png and /dev/null differ diff --git a/docs/images/secret-syncs/northflank/configure-destination.png b/docs/images/secret-syncs/northflank/configure-destination.png new file mode 100644 index 000000000..08dcb96bf Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-destination.png differ diff --git a/docs/images/secret-syncs/northflank/configure-details.png b/docs/images/secret-syncs/northflank/configure-details.png new file mode 100644 index 000000000..edbfa0dac Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-details.png differ diff --git a/docs/images/secret-syncs/northflank/configure-source.png b/docs/images/secret-syncs/northflank/configure-source.png new file mode 100644 index 000000000..530613f03 Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-source.png differ diff --git a/docs/images/secret-syncs/northflank/configure-sync-options.png b/docs/images/secret-syncs/northflank/configure-sync-options.png new file mode 100644 index 000000000..6e03b1f5f Binary files /dev/null and b/docs/images/secret-syncs/northflank/configure-sync-options.png differ diff --git a/docs/images/secret-syncs/northflank/review-configuration.png b/docs/images/secret-syncs/northflank/review-configuration.png new file mode 100644 index 000000000..59df97a80 Binary files /dev/null and b/docs/images/secret-syncs/northflank/review-configuration.png differ diff --git a/docs/images/secret-syncs/northflank/select-option.png b/docs/images/secret-syncs/northflank/select-option.png new file mode 100644 index 000000000..0ee9ea2fa Binary files /dev/null and b/docs/images/secret-syncs/northflank/select-option.png differ diff --git a/docs/images/secret-syncs/northflank/sync-created.png b/docs/images/secret-syncs/northflank/sync-created.png new file mode 100644 index 000000000..388d78f4d Binary files /dev/null and b/docs/images/secret-syncs/northflank/sync-created.png differ diff --git a/docs/integrations/app-connections/github.mdx b/docs/integrations/app-connections/github.mdx index e44fc405a..40eb0525f 100644 --- a/docs/integrations/app-connections/github.mdx +++ b/docs/integrations/app-connections/github.mdx @@ -3,7 +3,7 @@ title: "GitHub Connection" description: "Learn how to configure a GitHub Connection for Infisical." --- -Infisical supports two methods for connecting to GitHub. +Infisical supports three methods for connecting to GitHub. @@ -178,5 +178,72 @@ Infisical supports two methods for connecting to GitHub. + + Infisical will use a Personal Access Token to connect to GitHub. + + ## Create a Personal Access Token + + + Navigate to your user Settings > Developer settings > Personal Access Tokens to create a new Personal Access Token. + + ![integrations github config](../../images/integrations/github/integrations-github-config-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-dev-settings.png) + ![integrations github config](../../images/integrations/github/integrations-github-config-new-pat.png) + + Click **Generate new token** to create the token. + + + Fill in the Personal Access Token details: + - **Token name:** A descriptive name for the token (e.g., "infisical-connection-token") + - **Repository access:** Select the repositories you want to grant access to + - Select `All repositories` or `Only selected repositories` to be able to manage the secrets in the selected repositories. + - **Select scopes:** Add the following scopes: + - **Metadata**: Read-only + - **Environments**: Read and write + - **Secrets**: Read and write + + ![Personal Access Token Form](/images/app-connections/github/create-pat-form.png) + + Click **Generate token** to create the token. + + + Copy the generated token immediately as it won't be shown again. + + ![Personal Access Token Generated](/images/app-connections/github/create-pat-token-generated.png) + + + Keep your Personal Access Token secure and do not share it. Anyone with access to this token can access your GitHub account and repositories. + + + + + ## Setup GitHub Connection in Infisical + + + + Navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitHub Connection** option from the connection options modal. + ![Select GitHub Connection](/images/app-connections/github/select-github-connection.png) + + + Select the **Personal Access Token** method and fill in the **Personal Access Token** field with your Personal Access Token. + + You may optionally configure GitHub Enterprise options: + - **Gateway:** The gateway connected to your private network + - **Hostname:** The hostname at which to access your GitHub Enterprise instance + + Click **Create Connection**. + + ![Personal Access Token](/images/app-connections/github/create-pat-method.png) + + + Your **GitHub Connection** is now available for use. + ![Personal Access Token Connection](/images/app-connections/github/pat-connection.png) + + + diff --git a/docs/integrations/app-connections/gitlab.mdx b/docs/integrations/app-connections/gitlab.mdx index 4f7223d93..c9af952a7 100644 --- a/docs/integrations/app-connections/gitlab.mdx +++ b/docs/integrations/app-connections/gitlab.mdx @@ -187,31 +187,92 @@ Infisical supports two methods for connecting to GitLab: **OAuth** and **Access - - ## Setup GitLab Access Token Connection in Infisical + + Group access tokens provide access to all projects within a GitLab group, offering group-level control. - - - Navigate to the **App Connections** page in the desired project. - ![App Connections Tab](/images/app-connections/general/add-connection.png) - - - Select the **GitLab Connection** option from the connection options modal. - ![Select GitLab Connection](/images/app-connections/gitlab/select-gitlab-connection.png) - - - Select the **Access Token** method, paste your GitLab access token in the provided field, and select the appropriate token type. + + + Go to your GitLab group and navigate to Settings > Access Tokens. Click **Add new token** to create a new group access token. + ![GitLab Group Access Tokens](/images/app-connections/gitlab/gitlab-group-access-token-list.png) + + + Fill in the token details: + - **Token name**: A descriptive name for the token + - **Expiration date**: Set an appropriate expiration date + - **Select role and scopes**: Depending on your use case, add the required role and one or more of the following scopes: - ![Configure Access Token](/images/app-connections/gitlab/create-gitlab-access-token-connection.png) + + + For Secret Syncs, the required role depends on your sync destination: + - **Project variables**: Requires **Maintainer** role or higher + - **Group variables**: Requires **Owner** role - Click **Connect** to establish the connection. - - - Your **GitLab Connection** is now available for use. - ![GitLab Access Token Connection](/images/app-connections/gitlab/gitlab-access-token-connection.png) - - + Your token will require the `api` scope. + + ![GitLab Create Group Token](/images/app-connections/gitlab/gitlab-group-access-token-form-secret-sync.png) + + Click **Create group access token** to create the token. + + + Use the **Owner** role if you need to sync to group-level variables. The **Maintainer** role is sufficient only for project-level variables. + + + + To set up Secret Scanning, the required permissions depend on the data source level: + - **Project-level data source:** Requires **Maintainer** role or higher + - **Group-level data source:** Requires **Owner** role + + Your token will require the `api` scope. + + ![GitLab Create Group Token](/images/app-connections/gitlab/gitlab-group-access-token-form-secret-sync.png) + + Click **Create group access token** to create the token. + + + + + Group Access Token connections require manual token rotation when your GitLab access token expires or is regenerated. Monitor your connection status and update the token as needed. + + + + Copy the generated token immediately as it won't be shown again. + ![GitLab Group Token Created](/images/app-connections/gitlab/gitlab-group-access-token-created.png) + + Keep your access token secure and do not share it. Anyone with access to this token can access all projects within your GitLab group. + + + + + + + + +## Setup GitLab Access Token Connection in Infisical + + + + Navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **GitLab Connection** option from the connection options modal. + ![Select GitLab Connection](/images/app-connections/gitlab/select-gitlab-connection.png) + + + Select the **Access Token** method, paste your GitLab access token in the provided field, and select the appropriate token type. + + ![Configure Access Token](/images/app-connections/gitlab/create-gitlab-access-token-connection.png) + + Click **Connect** to establish the connection. + + + + + Your **GitLab Connection** is now available for use. + ![GitLab Access Token Connection](/images/app-connections/gitlab/gitlab-access-token-connection.png) + + diff --git a/docs/integrations/app-connections/northflank.mdx b/docs/integrations/app-connections/northflank.mdx new file mode 100644 index 000000000..0435c3e95 --- /dev/null +++ b/docs/integrations/app-connections/northflank.mdx @@ -0,0 +1,125 @@ +--- +title: "Northflank Connection" +description: "Learn how to configure a Northflank Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://northflank.com/docs/v1/api/use-the-api) to connect with Northflank. + + + Infisical recommends creating a specific API role for the app connection and only giving access to projects that will use the integration. + + +## Create a Northflank API Token + + + + Navigate to your team page and click **Create token**. + + ![Create API Role](/images/app-connections/northflank/step-1.png) + + Click on **Create API role**. + + ![Create API Role](/images/app-connections/northflank/step-2.png) + + Select all the projects you want this role to have access to, or leave this unchecked if you want to give access to all projects. + + ![Create API Role](/images/app-connections/northflank/step-3.png) + + Add the **Projects** -> **Manage** -> **Read** permission. + + ![Create API Role](/images/app-connections/northflank/step-4-1.png) + + Add the **Config & Secrets** -> **Secret Groups** -> **List**, **Update** and **Read Values** permissions. + + ![Create API Role](/images/app-connections/northflank/step-4-2.png) + + Scroll to the bottom and save the API role. + + + Click on the **API** -> **Tokens** menu on the left and then click the **Create API token** button. + + ![Create API Token](/images/app-connections/northflank/step-5.png) + + Give a name to the API token and click the **Use role** button for the new API role you just created. + + ![Create API Token](/images/app-connections/northflank/step-6.png) + + Click the **View API token** icon to view and copy your token. + + ![Create API Token](/images/app-connections/northflank/step-7.png) + + + +## Create a Northflank Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Northflank Connection** from the list of integrations. + + ![Select Northflank Connection](/images/app-connections/northflank/northflank-app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + + ![Northflank Connection Modal](/images/app-connections/northflank/northflank-app-connection-form.png) + + + After submitting the form, your **Northflank Connection** will be successfully created and ready to use with your Infisical project. + + ![Northflank Connection Created](/images/app-connections/northflank/northflank-app-connection-generated.png) + + + + + + To create a Northflank Connection via API, send a request to the [Create Northflank Connection](/api-reference/endpoints/app-connections/northflank/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-connection", + "method": "api-token", + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-northflank-connection", + "description": null, + "projectId": "abcdef12-3456-7890-abcd-ef1234567890", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-01-23T10:15:00.000Z", + "updatedAt": "2025-01-23T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "northflank", + "method": "api-token", + "credentials": {} + } + } + ``` + + \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index d5d48598b..b7ecf5815 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -63,6 +63,7 @@ The Infisical Agent Injector supports the following annotations: - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. + - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. diff --git a/docs/integrations/platforms/kubernetes/overview.mdx b/docs/integrations/platforms/kubernetes/overview.mdx index ce9afcced..71ae05c46 100644 --- a/docs/integrations/platforms/kubernetes/overview.mdx +++ b/docs/integrations/platforms/kubernetes/overview.mdx @@ -41,6 +41,29 @@ If you require stronger isolation and stricter access controls, a namespace-scop ```bash helm install --generate-name infisical-helm-charts/secrets-operator ``` + + + By default a service account is created for the operator based on the operator release name. + You can bring your own service account by setting `controllerManager.serviceAccount.create` to `false` and setting `controllerManager.serviceAccount.name` to the name of the service account you want to use in your values.yaml file. + + Example values.yaml file: + + ```yaml values.yaml + controllerManager: + serviceAccount: + create: false + name: my-service-account + # other values... + ``` + + + Please note that if you set `controllerManager.serviceAccount.create` to `false`, the service account needs to already exist in the namespace you are installing the operator in. + + + + Custom service accounts are supported in chart version `0.10.11` and above. Please upgrade your helm chart to `0.10.11` or above before attempting to use custom service accounts. + + The operator can be configured to watch and manage secrets in a specific namespace instead of having cluster-wide access. This is useful for: @@ -67,6 +90,29 @@ If you require stronger isolation and stricter access controls, a namespace-scop --set installCRDs=false ``` + + By default a service account is created for the operator based on the operator release name. + You can bring your own service account by setting `controllerManager.serviceAccount.create` to `false` and setting `controllerManager.serviceAccount.name` to the name of the service account you want to use in your values.yaml file. + + Example values.yaml file: + + ```yaml values.yaml + controllerManager: + serviceAccount: + create: false + name: my-service-account + # other values... + ``` + + + Please note that if you set `controllerManager.serviceAccount.create` to `false`, the service account needs to already exist in the namespace you are installing the operator in. + + + + Custom service accounts are supported in chart version `0.10.11` and above. Please upgrade your helm chart to `0.10.11` or above before attempting to use custom service accounts. + + + When scoped to a namespace, the operator will: - Only watch InfisicalSecrets in the specified namespace @@ -158,14 +204,17 @@ The Infisical Secrets Operator integrates with the [Sprig library](https://githu ## Global configuration -To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. -For example, you can configure all `InfisicalSecret` instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. +To configure global settings that will apply to all CRD instances (`InfisicalSecret`, `InfisicalPushSecret`, and `InfisicalDynamicSecret`), you can define these configurations in a Kubernetes ConfigMap. +For example, you can configure all CRD instances to fetch secrets from a single backend API without specifying the `hostAPI` parameter for each instance. ### Available global properties | Property | Description | Default value | | -------- | --------------------------------------------------------------------------------- | ----------------------------- | -| hostAPI | If `hostAPI` in `InfisicalSecret` instance is left empty, this value will be used | https://app.infisical.com/api | +| hostAPI | If `hostAPI` in a CRD instance is left empty, this value will be used | https://app.infisical.com/api | +| tls.caRef.secretName | If `tls.caRef.secretName` in a CRD instance is left empty, this value will be used | - | +| tls.caRef.secretNamespace | If `tls.caRef.secretNamespace` in a CRD instance is left empty, this value will be used | - | +| tls.caRef.key | If `tls.caRef.key` in a CRD instance is left empty, this value will be used | - | ### Applying global configurations @@ -185,6 +234,9 @@ metadata: namespace: infisical-operator-system data: hostAPI: https://example.com/api # <-- global hostAPI + tls.caRef.secretName: custom-ca-certificate # <-- global TLS CA secret name + tls.caRef.secretNamespace: default # <-- global TLS CA secret namespace + tls.caRef.key: ca.crt # <-- global TLS CA secret key ``` Then apply this change via kubectl by running the following diff --git a/docs/integrations/secret-syncs/northflank.mdx b/docs/integrations/secret-syncs/northflank.mdx new file mode 100644 index 000000000..66075c967 --- /dev/null +++ b/docs/integrations/secret-syncs/northflank.mdx @@ -0,0 +1,160 @@ +--- +title: "Northflank Sync" +description: "Learn how to configure a Northflank Sync for Infisical." +--- + +**Prerequisites:** +- Create a [Northflank Connection](/integrations/app-connections/northflank) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Northflank](/images/secret-syncs/northflank/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/northflank/configure-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/northflank/configure-destination.png) + + - **Northflank Connection**: The Northflank Connection to authenticate with. + - **Project**: The Northflank project to sync secrets to. + - **Secret Group**: The Northflank secret group to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Sync Options](/images/secret-syncs/northflank/configure-sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Destination Secrets - Prioritize Infisical Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Infisical over Northflank when keys conflict. + - **Import Destination Secrets - Prioritize Northflank Values**: Imports any secrets present in the Northflank destination prior to syncing, prioritizing values from Northflank over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Northflank Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/northflank/configure-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Northflank Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/northflank/review-configuration.png) + + + If enabled, your Northflank Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/northflank/sync-created.png) + + + + + To create a **Northflank Sync**, make an API request to the [Create Northflank Sync](/api-reference/endpoints/secret-syncs/northflank/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/northflank \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-northflank-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}" + }, + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + }' + ``` + + ### Sample response + + ```json Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-northflank-sync", + "description": "an example sync", + "isAutoSyncEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "keySchema": "INFISICAL_{{secretKey}}", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "northflank", + "name": "my-northflank-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "northflank", + "destinationConfig": { + "projectId": "my-project-id", + "secretGroupId": "my-secret-group-id" + } + } + } + ``` + + \ No newline at end of file diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 066ecc060..670e0975a 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -72,6 +72,7 @@ The SDK methods are organized into the following high-level categories: 1. `auth`: Handles authentication methods. 2. `secrets`: Manages CRUD operations for secrets. 3. `kms`: Perform cryptographic operations with Infisical KMS. +4. `folders`: Manages folder-related operations. ### `auth` @@ -415,4 +416,66 @@ decrypted_data = client.kms.decrypt_data( - `ciphertext` (str): The ciphertext returned from the encrypt operation. **Returns:** -- `str`: The base64 encoded plaintext. \ No newline at end of file +- `str`: The base64 encoded plaintext. + +### `folders` + +This sub-class handles operations related to folders: + +#### List Folders + +```python +folders = client.folders.list_folders( + project_id="", + environment_slug="dev", + path="/", + recursive=False, # Optional + last_secret_modified=None # Optional +) +``` + +**Parameters:** +- `project_id` (str): The ID of your project. +- `environment_slug` (str): The environment in which to list folders. +- `path` (str): The path to list folders from. +- `recursive` (bool, optional): Whether to list folders recursively from the specified path and downwards. Defaults to `False`. +- `last_secret_modified` (datetime, optional): The timestamp used to filter folders with secrets modified after the specified date. Defaults to `None`. + +**Returns:** +- `ListFoldersResponse`: The response containing the list of folders. + +#### Create Folder + +```python +new_folder = client.folders.create_folder( + name="my-folder", + environment_slug="dev", + project_id="", + path="/", # Optional + description=None # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the folder to create. +- `environment_slug` (str): The slug of the environment to create the folder in. +- `project_id` (str): The ID of your project to create the folder in. +- `path` (str, optional): The path to create the folder in. Defaults to `/`. +- `description` (str, optional): An optional description label for the folder. Defaults to `None`. + +**Returns:** +- `CreateFolderResponseItem`: The response containing the created folder. + +#### Get Folder by ID + +```python +folder = client.folders.get_folder_by_id( + id="" +) +``` + +**Parameters:** +- `id` (str): The ID of the folder to retrieve. + +**Returns:** +- `SingleFolderResponseItem`: The response containing the folder details. \ No newline at end of file diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 551c79184..edebf1670 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -116,6 +116,27 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Configure the SSL certificate for securing a Postgres connection by first encoding it in base64. Use the following command to encode your certificate: `echo "" | base64` + + Many cloud providers provide a CA certificate for their data regions that you can use to secure your connection with SSL. + + + + If you're hosting your database on AWS RDS, you can use their publicly available CA certificate as the database root certificate. + + You can find all the available CA certificates for AWS RDS on the official [AWS RDS documentation](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html). + + As an example, if your RDS cluster is hosted in `us-east-1` _(US East, N. Virginia)_, you can use the following root certificate: https://truststore.pki.rds.amazonaws.com/us-east-1/us-east-1-bundle.pem. + + All the available CA certificates can be found in the AWS RDS documentation linked above. + + Remember to base64 encode the certificate before setting it as the `DB_ROOT_CERT` environment variable. `cat /path/to/certificate.pem | base64`. + + ```bash + DB_ROOT_CERT=LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1 # .... (base64 encoded certificate) + DB_CONNECTION_URI=?sslmode=verify-ca # or verify-full depending on your security policies + ``` + + diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index f8a2a6864..f4ef67e1e 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -1,8 +1,10 @@ --- title: "Kubernetes via Helm Chart" description: "Learn how to use Helm chart to install Infisical on your Kubernetes cluster." ---- +--- + **Prerequisites** + - You have extensive understanding of [Kubernetes](https://kubernetes.io/) - Installed [Helm package manager](https://helm.sh/) version v3.11.3 or greater - You have [kubectl](https://kubernetes.io/docs/reference/kubectl/kubectl/) installed and connected to your kubernetes cluster @@ -12,7 +14,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete ```bash helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' ``` - ``` + ```bash helm repo update ``` @@ -61,6 +63,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete For production environments, we recommend using Cloud-based Platform as a Service (PaaS) solutions for PostgreSQL and Redis to ensure high availability. In on-premise setups, it's recommended to configure Redis and Postgres for high availability, either by using Bitnami charts or a custom configuration. + ```yaml simple-values-example.yaml apiVersion: v1 kind: Secret @@ -74,6 +77,10 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete DB_CONNECTION_URI: <> SITE_URL: <> ``` + + + If you need to configure the SSL certificate for your production Postgres instance, you can use the `DB_ROOT_CERT` environment variable. [Learn more about configuring the SSL certificate](/self-hosting/configuration/envars#aws-rds). + diff --git a/docs/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx index 1c2d05702..b23c51b27 100644 --- a/docs/self-hosting/guides/monitoring-telemetry.mdx +++ b/docs/self-hosting/guides/monitoring-telemetry.mdx @@ -319,80 +319,137 @@ helm install otel-collector open-telemetry/opentelemetry-collector \ --set config.exporters.prometheus.endpoint=0.0.0.0:8889 ``` -## Alternative Backends - -Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus: - -### Cloud-Native Examples - -```yaml -# Add to your otel-collector-config.yaml exporters section -exporters: - # AWS CloudWatch - awsemf: - region: us-west-2 - log_group_name: /aws/emf/infisical - log_stream_name: metrics - - # Google Cloud Monitoring - googlecloud: - project_id: your-project-id - - # Azure Monitor - azuremonitor: - connection_string: "your-connection-string" - - # Datadog - datadog: - api: - key: "your-api-key" - site: "datadoghq.com" - - # New Relic - newrelic: - apikey: "your-api-key" - host_override: "otlp.nr-data.net" -``` - -### Multi-Backend Configuration - -```yaml -service: - pipelines: - metrics: - receivers: [otlp] - processors: [batch] - exporters: [prometheus, awsemf, datadog] # Send to multiple backends -``` - -## Setting Up Grafana - -1. **Access Grafana**: Navigate to your Grafana instance -2. **Login**: Use your configured credentials -3. **Add Prometheus Data Source**: - - Go to Configuration → Data Sources - - Click "Add data source" - - Select "Prometheus" - - Set URL to your Prometheus endpoint - - Click "Save & Test" - ## Available Metrics Infisical exposes the following key metrics in OpenTelemetry format: -### API Performance Metrics +### Core API Metrics -- `API_latency` - API request latency histogram in milliseconds +These metrics track all HTTP API requests to Infisical, including request counts, latency, and errors. Use these to monitor overall API health, identify performance bottlenecks, and track usage patterns across users and machine identities. - - **Labels**: `route`, `method`, `statusCode` - - **Example**: Monitor response times for specific endpoints +#### Total API Requests -- `API_errors` - API error count histogram - - **Labels**: `route`, `method`, `type`, `name` - - **Example**: Track error rates by endpoint and error type +- **Metric Name**: `infisical.http.server.request.count` +- **Type**: Counter +- **Unit**: `{request}` +- **Description**: Total number of API requests to Infisical (covers both human users and machine identities) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name (e.g., "Platform Engineering Team") + - `infisical.user.id` (string, optional): User ID if human user + - `infisical.user.email` (string, optional): User email (e.g., "jane.doe@cisco.com") + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name (e.g., "prod-k8s-operator") + - `infisical.auth.method` (string, optional): Auth method used + - `http.request.method` (string): HTTP method (GET, POST, PUT, DELETE) + - `http.route` (string): API endpoint route pattern + - `http.response.status_code` (int): HTTP status code + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + - `user_agent.original` (string, optional): User agent string + - `client.address` (string, optional): IP address + +#### Request Duration + +- **Metric Name**: `infisical.http.server.request.duration` +- **Type**: Histogram +- **Unit**: `s` (seconds) +- **Description**: API request latency +- **Buckets**: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10] +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human user + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name + - `http.request.method` (string): HTTP method + - `http.route` (string): API endpoint route pattern + - `http.response.status_code` (int): HTTP status code + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + +#### API Errors by Actor + +- **Metric Name**: `infisical.http.server.error.count` +- **Type**: Counter +- **Unit**: `{error}` +- **Description**: API errors grouped by actor (for identifying misconfigured services) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Identity ID if machine + - `infisical.identity.name` (string, optional): Identity name + - `http.route` (string): API endpoint where error occurred + - `http.request.method` (string): HTTP method + - `error.type` (string): Error category/type (client_error, server_error, auth_error, rate_limit_error, etc.) + - `infisical.project.id` (string, optional): Project ID + - `infisical.project.name` (string, optional): Project name + - `client.address` (string, optional): IP address + - `user_agent.original` (string, optional): User agent information + +### Secret Operations Metrics + +These metrics provide visibility into secret access patterns, helping you understand which secrets are being accessed, by whom, and from where. Essential for security auditing and access pattern analysis. + +#### Secret Read Operations + +- **Metric Name**: `infisical.secret.read.count` +- **Type**: Counter +- **Unit**: `{operation}` +- **Description**: Number of secret read operations +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.project.id` (string): Project ID + - `infisical.project.name` (string): Project name (e.g., "payment-service-secrets") + - `infisical.environment` (string): Environment (dev, staging, prod) + - `infisical.secret.path` (string): Path to secrets (e.g., "/microservice-a/database") + - `infisical.secret.name` (string, optional): Name of secret + - `infisical.user.id` (string, optional): User ID if human + - `infisical.user.email` (string, optional): User email + - `infisical.identity.id` (string, optional): Machine identity ID + - `infisical.identity.name` (string, optional): Machine identity name + - `user_agent.original` (string, optional): User agent/SDK information + - `client.address` (string, optional): IP address + +### Authentication Metrics + +These metrics track authentication attempts and outcomes, enabling you to monitor login success rates, detect potential security threats, and identify authentication issues. + +#### Login Attempts + +- **Metric Name**: `infisical.auth.attempt.count` +- **Type**: Counter +- **Unit**: `{attempt}` +- **Description**: Authentication attempts (both successful and failed) +- **Attributes**: + - `infisical.organization.id` (string): Organization ID + - `infisical.organization.name` (string): Organization name + - `infisical.user.id` (string, optional): User ID if human (if identifiable) + - `infisical.user.email` (string, optional): User email (if identifiable) + - `infisical.identity.id` (string, optional): Identity ID if machine (if identifiable) + - `infisical.identity.name` (string, optional): Identity name (if identifiable) + - `infisical.auth.method` (string): Authentication method attempted + - `infisical.auth.result` (string): success or failure + - `error.type` (string, optional): Reason for failure if failed (invalid_credentials, expired_token, invalid_token, etc.) + - `client.address` (string): IP address + - `user_agent.original` (string, optional): User agent/client information + - `infisical.auth.attempt.username` (string, optional): Attempted username/email (if available) + +### Legacy Metrics + +These metrics are from the previous instrumentation and may be deprecated in future versions. Consider migrating to the new Core API Metrics for more comprehensive observability. + +- `API_latency` - API request latency histogram in milliseconds (Labels: `route`, `method`, `statusCode`) +- `API_errors` - API error count histogram (Labels: `route`, `method`, `type`, `name`) ### Integration & Secret Sync Metrics +These metrics monitor secret synchronization operations between Infisical and external systems, helping you track sync health, identify integration failures, and troubleshoot connectivity issues. + - `integration_secret_sync_errors` - Integration secret sync error count - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` @@ -414,16 +471,11 @@ Infisical exposes the following key metrics in OpenTelemetry format: ### System Metrics -These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: +These low-level HTTP metrics are automatically collected by OpenTelemetry's instrumentation layer, providing baseline performance data for all HTTP traffic. - `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) - `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) -### Custom Business Metrics - -- `infisical_secret_operations_total` - Total secret operations -- `infisical_secrets_processed_total` - Total secrets processed - ## Troubleshooting ### Common Issues diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index cfc65d1fd..7761d4bfc 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -47,6 +47,7 @@ export const AppConnectionsBrowser = () => { {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index 3598bf68e..d71ef1a80 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -37,7 +37,8 @@ export const SecretSyncsBrowser = () => { {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, - {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"} + {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"}, + {"name": "Northflank", "slug": "northflank", "path": "/integrations/secret-syncs/northflank", "description": "Learn how to sync secrets from Infisical to Northflank projects.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf36d..f52343a92 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +*storybook.log +storybook-static diff --git a/frontend/.storybook/decorators/DocumentDecorator.tsx b/frontend/.storybook/decorators/DocumentDecorator.tsx new file mode 100644 index 000000000..b0d817c6e --- /dev/null +++ b/frontend/.storybook/decorators/DocumentDecorator.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import type { Decorator } from "@storybook/react-vite"; + +export const DocumentDecorator: Decorator = (Story) => { + useEffect(() => { + const root = document.documentElement; + + root.setAttribute("class", "overflow-visible"); + }, []); + + return ; +}; diff --git a/frontend/.storybook/decorators/RouterDecorator.tsx b/frontend/.storybook/decorators/RouterDecorator.tsx new file mode 100644 index 000000000..a559c5cd1 --- /dev/null +++ b/frontend/.storybook/decorators/RouterDecorator.tsx @@ -0,0 +1,17 @@ +import { useMemo } from "react"; +import type { Decorator } from "@storybook/react-vite"; +import { createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router"; + +export const RouterDecorator: Decorator = (Story) => { + const router = useMemo(() => { + const routeTree = createRootRoute({ + component: Story + }); + + return createRouter({ + routeTree + }); + }, [Story]); + + return ; +}; diff --git a/frontend/.storybook/decorators/index.ts b/frontend/.storybook/decorators/index.ts new file mode 100644 index 000000000..7bbb10f15 --- /dev/null +++ b/frontend/.storybook/decorators/index.ts @@ -0,0 +1,2 @@ +export * from "./DocumentDecorator"; +export * from "./RouterDecorator"; diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..0a7b7adb5 --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,14 @@ +import type { StorybookConfig } from "@storybook/react-vite"; + +const config: StorybookConfig = { + stories: [ + "../src/components/v3/**/*.mdx", + "../src/components/v3/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + addons: ["@storybook/addon-docs", "@storybook/addon-a11y"], + framework: { + name: "@storybook/react-vite", + options: {} + } +}; +export default config; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx new file mode 100644 index 000000000..d2aaca582 --- /dev/null +++ b/frontend/.storybook/preview.tsx @@ -0,0 +1,36 @@ +import type { Preview } from "@storybook/react-vite"; + +import { DocumentDecorator, RouterDecorator } from "./decorators"; + +import "../src/index.css"; + +const preview: Preview = { + decorators: [DocumentDecorator, RouterDecorator], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i + } + }, + docs: { + backgroundColor: "var(--background)" + }, + a11y: { + test: "todo" + }, + backgrounds: { + default: "dark", + options: { + dark: { name: "Dark", value: "var(--background)" } + } + } + }, + initialGlobals: { + backgrounds: { + value: "dark" + } + } +}; + +export default preview; diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 00c024056..843a9602e 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + import js from "@eslint/js"; import globals from "globals"; import reactHooks from "eslint-plugin-react-hooks"; @@ -133,5 +136,6 @@ export default tseslint.config( rules: Object.fromEntries( Object.keys(stylisticPlugin.configs["all-flat"].rules ?? {}).map((key) => [key, "off"]) ) - } + }, + storybook.configs["flat/recommended"] ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a9f4b070c..e8fdda096 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,9 @@ "@radix-ui/react-popper": "^1.2.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", @@ -57,6 +59,7 @@ "argon2-browser": "^1.18.0", "axios": "^1.12.0", "classnames": "^2.5.1", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.2.4", @@ -69,6 +72,7 @@ "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", "lexical": "^0.29.0", + "lucide-react": "^0.544.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -98,6 +102,9 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.15.0", "@kesills/eslint-config-airbnb-typescript": "^20.0.0", + "@storybook/addon-a11y": "^9.1.9", + "@storybook/addon-docs": "^9.1.9", + "@storybook/react-vite": "^9.1.9", "@stylistic/eslint-plugin": "^2.12.1", "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.15", @@ -123,6 +130,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-storybook": "^9.1.9", "globals": "^15.12.0", "postcss": "^8.4.49", "prettier": "3.4.2", @@ -130,13 +138,21 @@ "tailwindcss": "^4.1.14", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.18", - "vite-plugin-node-polyfills": "^0.22.0", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-top-level-await": "^1.4.4", - "vite-plugin-wasm": "^3.3.0", + "vite-plugin-wasm": "^3.4.0", "vite-tsconfig-paths": "^5.1.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -150,38 +166,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.3.tgz", - "integrity": "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", "dev": true, "license": "MIT", "engines": { @@ -189,22 +191,22 @@ } }, "node_modules/@babel/core": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", - "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.0", - "@babel/generator": "^7.26.0", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.0", - "@babel/parser": "^7.26.0", - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.26.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -243,15 +245,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.3.tgz", - "integrity": "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.3", - "@babel/types": "^7.26.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -259,14 +261,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", - "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.9", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -275,16 +277,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -295,29 +287,38 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -337,27 +338,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -365,26 +366,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.26.9", - "@babel/types": "^7.26.10" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.26.10" + "@babel/types": "^7.28.4" }, "bin": { "parser": "bin/babel-parser.js" @@ -438,54 +439,45 @@ } }, "node_modules/@babel/template": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.26.9", - "@babel/types": "^7.26.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.26.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.4.tgz", - "integrity": "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.3", - "@babel/parser": "^7.26.3", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -725,9 +717,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", "cpu": [ "ppc64" ], @@ -738,13 +730,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", "cpu": [ "arm" ], @@ -755,13 +747,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", "cpu": [ "arm64" ], @@ -772,13 +764,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", "cpu": [ "x64" ], @@ -789,13 +781,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], @@ -806,13 +798,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", "cpu": [ "x64" ], @@ -823,13 +815,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", "cpu": [ "arm64" ], @@ -840,13 +832,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", "cpu": [ "x64" ], @@ -857,13 +849,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", "cpu": [ "arm" ], @@ -874,13 +866,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", "cpu": [ "arm64" ], @@ -891,13 +883,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", "cpu": [ "ia32" ], @@ -908,13 +900,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", "cpu": [ "loong64" ], @@ -925,13 +917,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", "cpu": [ "mips64el" ], @@ -942,13 +934,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", "cpu": [ "ppc64" ], @@ -959,13 +951,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", "cpu": [ "riscv64" ], @@ -976,13 +968,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", "cpu": [ "s390x" ], @@ -993,13 +985,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", "cpu": [ "x64" ], @@ -1010,13 +1002,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", "cpu": [ "arm64" ], @@ -1031,9 +1023,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", "cpu": [ "x64" ], @@ -1044,13 +1036,13 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", "cpu": [ "arm64" ], @@ -1065,9 +1057,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", "cpu": [ "x64" ], @@ -1078,13 +1070,30 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", "cpu": [ "x64" ], @@ -1095,13 +1104,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", "cpu": [ "arm64" ], @@ -1112,13 +1121,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", "cpu": [ "ia32" ], @@ -1129,13 +1138,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", "cpu": [ "x64" ], @@ -1146,7 +1155,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@eslint-community/eslint-utils": { @@ -1454,6 +1463,53 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1467,18 +1523,82 @@ "node": ">=18.0.0" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.6.1.tgz", + "integrity": "sha512-J4BaTocTOYFkMHIra1JDWrMWpNmBl4EkplIwHEsV8aeUOtdWjwSnln9U7twjMFTAEB7mptNtSKyVi1Y2W9sDJw==", + "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" + "glob": "^10.0.0", + "magic-string": "^0.30.0", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=6.0.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/remapping": { @@ -1501,15 +1621,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1517,9 +1628,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1822,6 +1933,24 @@ "integrity": "sha512-01d+UjJ8NG7ZStYQxtb8FPzknzGmauG7gEkcH+wHfSdiSQJY9PoBNVSTB9V6F5hAnmFqOxaocTtd7TIEEnzMnA==", "license": "MIT" }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2170,6 +2299,17 @@ "tsyringe": "^4.8.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -2254,6 +2394,24 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz", @@ -2363,6 +2521,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", @@ -2429,6 +2605,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", @@ -2652,6 +2846,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.3.tgz", @@ -2689,6 +2901,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz", @@ -2792,6 +3022,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.1.tgz", @@ -2879,6 +3127,171 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.3.tgz", @@ -2922,7 +3335,7 @@ } } }, - "node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", @@ -2940,6 +3353,39 @@ } } }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-switch": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.2.tgz", @@ -3067,6 +3513,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -3278,9 +3742,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", - "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "cpu": [ "arm" ], @@ -3292,9 +3756,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", - "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "cpu": [ "arm64" ], @@ -3306,9 +3770,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", - "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "cpu": [ "arm64" ], @@ -3320,9 +3784,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", - "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "cpu": [ "x64" ], @@ -3334,9 +3798,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", - "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", "cpu": [ "arm64" ], @@ -3348,9 +3812,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", - "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", "cpu": [ "x64" ], @@ -3362,9 +3826,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", - "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "cpu": [ "arm" ], @@ -3376,9 +3840,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", - "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "cpu": [ "arm" ], @@ -3390,9 +3854,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", - "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "cpu": [ "arm64" ], @@ -3404,9 +3868,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", - "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "cpu": [ "arm64" ], @@ -3417,10 +3881,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", - "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", "cpu": [ "loong64" ], @@ -3431,10 +3895,10 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", - "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "cpu": [ "ppc64" ], @@ -3446,9 +3910,23 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", - "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "cpu": [ "riscv64" ], @@ -3460,9 +3938,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", - "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "cpu": [ "s390x" ], @@ -3474,9 +3952,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", - "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "cpu": [ "x64" ], @@ -3488,9 +3966,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", - "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "cpu": [ "x64" ], @@ -3501,10 +3979,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", - "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "cpu": [ "arm64" ], @@ -3516,9 +4008,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", - "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "cpu": [ "ia32" ], @@ -3529,10 +4021,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", - "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "cpu": [ "x64" ], @@ -3605,6 +4111,297 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@storybook/addon-a11y": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-9.1.13.tgz", + "integrity": "sha512-4enIl1h2XSZnFKUQJJoZbp1X40lzdj7f5JE15ZhU1al4z6hHWp7i2zD7ySyDpEbMypBCz1xnLvyiyw79m1fp7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "axe-core": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/addon-docs": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-9.1.13.tgz", + "integrity": "sha512-V1nCo7bfC3kQ5VNVq0VDcHsIhQf507m+BxMA5SIYiwdJHljH2BXpW2fL3FFn9gv9Wp57AEEzhm+wh4zANaJgkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/csf-plugin": "9.1.13", + "@storybook/icons": "^1.4.0", + "@storybook/react-dom-shim": "9.1.13", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/builder-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-9.1.13.tgz", + "integrity": "sha512-pmtIjU02ASJOZKdL8DoxWXJgZnpTDgD5WmMnjKJh9FaWmc2YiCW2Y6VRxPox96OM655jYHQe5+UIbk3Cwtwb4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/csf-plugin": "9.1.13", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/csf-plugin": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-9.1.13.tgz", + "integrity": "sha512-EMpzYuyt9FDcxxfBChWzfId50y8QMpdenviEQ8m+pa6c+ANx3pC5J6t7y0khD8TQu815sTy+nc6cc8PC45dPUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unplugin": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", + "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" + } + }, + "node_modules/@storybook/react": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-9.1.13.tgz", + "integrity": "sha512-B0UpYikKf29t8QGcdmumWojSQQ0phSDy/Ne2HYdrpNIxnUvHHUVOlGpq4lFcIDt52Ip5YG5GuAwJg3+eR4LCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "9.1.13" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-9.1.13.tgz", + "integrity": "sha512-/tMr9TmV3+98GEQO0S03k4gtKHGCpv9+k9Dmnv+TJK3TBz7QsaFEzMwe3gCgoTaebLACyVveDiZkWnCYAWB6NA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13" + } + }, + "node_modules/@storybook/react-vite": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-9.1.13.tgz", + "integrity": "sha512-mV1bZ1bpkNQygnuDo1xMGAS5ZXuoXFF0WGmr/BzNDGmRhZ1K1HQh42kC0w3PklckFBUwCFxmP58ZwTFzf+/dJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "0.6.1", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "9.1.13", + "@storybook/react": "9.1.13", + "find-up": "^7.0.0", + "magic-string": "^0.30.0", + "react-docgen": "^8.0.0", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", + "storybook": "^9.1.13", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/find-up": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-7.0.0.tgz", + "integrity": "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^7.2.0", + "path-exists": "^5.0.0", + "unicorn-magic": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/node_modules/locate-path": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/p-locate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@storybook/react-vite/node_modules/path-exists": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@storybook/react-vite/node_modules/yocto-queue": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.1.tgz", + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", @@ -4441,6 +5238,82 @@ "zod": "^3.23.8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/argon2-browser": { "version": "1.18.4", "resolved": "https://registry.npmjs.org/@types/argon2-browser/-/argon2-browser-1.18.4.tgz", @@ -4448,6 +5321,14 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4484,13 +5365,25 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/d3-color": { @@ -4556,10 +5449,25 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -4607,6 +5515,13 @@ "@types/unist": "*" } }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ms": { "version": "0.7.34", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", @@ -4710,6 +5625,13 @@ "@types/react": "*" } }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5032,6 +5954,107 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xyflow/react": { "version": "12.4.4", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.4.4.tgz", @@ -5424,6 +6447,31 @@ "util": "^0.12.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5459,7 +6507,6 @@ "integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==", "dev": true, "license": "MPL-2.0", - "peer": true, "engines": { "node": ">=4" } @@ -5561,12 +6608,36 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz", + "integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/before-after-hook": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "license": "Apache-2.0" }, + "node_modules/better-opn": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", + "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "open": "^8.0.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5777,9 +6848,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", - "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -5797,10 +6868,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -5914,9 +6986,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001688", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001688.tgz", - "integrity": "sha512-Nmqpru91cuABu/DTCXbM2NSRHzM2uVHfPnhJ/1zEAJx/ILBRVmz3pzH4N7DZqbdG0gWClsCC05Oj0mJ/1AWMbA==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, "funding": [ { @@ -5970,6 +7042,24 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6023,6 +7113,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6399,6 +7500,14 @@ "utrie": "^1.0.2" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -6646,6 +7755,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6670,6 +7790,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -6782,6 +7913,14 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -6827,10 +7966,17 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { - "version": "1.5.73", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.73.tgz", - "integrity": "sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==", + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", "dev": true, "license": "ISC" }, @@ -6862,8 +8008,7 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/enhanced-resolve": { "version": "5.18.3", @@ -7062,9 +8207,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7072,32 +8217,49 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", + "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" } }, "node_modules/escalade": { @@ -7570,6 +8732,23 @@ "eslint": ">=5.0.0" } }, + "node_modules/eslint-plugin-storybook": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/eslint-plugin-storybook/-/eslint-plugin-storybook-9.1.13.tgz", + "integrity": "sha512-kPuhbtGDiJLB5OLZuwFZAxgzWakNDw64sJtXUPN8g0+VAeXfHyZEmsE28qIIETHxtal71lPKVm8QNnERaJHPJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "eslint": ">=8", + "storybook": "^9.1.13" + } + }, "node_modules/eslint-scope": { "version": "7.2.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", @@ -7694,6 +8873,21 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "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" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -7883,6 +9077,24 @@ "reusify": "^1.0.4" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7995,6 +9207,23 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", @@ -8768,6 +9997,17 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -9034,6 +10274,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9339,6 +10596,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -9392,6 +10663,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -9910,6 +11197,44 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.544.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.544.0.tgz", + "integrity": "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -10591,6 +11916,17 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -10670,9 +12006,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -10716,9 +12052,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -10929,6 +12265,25 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "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", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10995,6 +12350,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -11140,6 +12502,30 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -11149,6 +12535,17 @@ "node": ">=8" } }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pbkdf2": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.5.tgz", @@ -11181,9 +12578,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -11224,9 +12621,9 @@ } }, "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -11244,7 +12641,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11406,6 +12803,44 @@ } } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -11686,6 +13121,38 @@ "react": ">=16.8.0" } }, + "node_modules/react-docgen": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.2.tgz", + "integrity": "sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -11985,12 +13452,70 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/recast": { + "version": "0.23.11", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", + "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redaxios": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/redaxios/-/redaxios-0.5.1.tgz", "integrity": "sha512-FSD2AmfdbkYwl7KDExYQlVvIrFz6Yd83pGfaGjBzM9F6rpq8g652Q4Yq5QD4c+nf4g2AgeElv1y+8ajUPiOYMg==", "license": "Apache-2.0" }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -12193,13 +13718,13 @@ } }, "node_modules/rollup": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", - "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -12209,25 +13734,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.28.1", - "@rollup/rollup-android-arm64": "4.28.1", - "@rollup/rollup-darwin-arm64": "4.28.1", - "@rollup/rollup-darwin-x64": "4.28.1", - "@rollup/rollup-freebsd-arm64": "4.28.1", - "@rollup/rollup-freebsd-x64": "4.28.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", - "@rollup/rollup-linux-arm-musleabihf": "4.28.1", - "@rollup/rollup-linux-arm64-gnu": "4.28.1", - "@rollup/rollup-linux-arm64-musl": "4.28.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", - "@rollup/rollup-linux-riscv64-gnu": "4.28.1", - "@rollup/rollup-linux-s390x-gnu": "4.28.1", - "@rollup/rollup-linux-x64-gnu": "4.28.1", - "@rollup/rollup-linux-x64-musl": "4.28.1", - "@rollup/rollup-win32-arm64-msvc": "4.28.1", - "@rollup/rollup-win32-ia32-msvc": "4.28.1", - "@rollup/rollup-win32-x64-msvc": "4.28.1", + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, @@ -12500,6 +14028,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -12545,6 +14086,43 @@ "node": ">=0.1.14" } }, + "node_modules/storybook": { + "version": "9.1.13", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.13.tgz", + "integrity": "sha512-G3KZ36EVzXyHds72B/qtWiJnhUpM0xOUeYlDcO9DSHL1bDTv15cW4+upBl+mcBZrDvU838cn7Bv4GpF+O5MCfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@storybook/global": "^5.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.1", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/spy": "3.2.4", + "better-opn": "^3.0.2", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", + "esbuild-register": "^3.5.0", + "recast": "^0.23.5", + "semver": "^7.6.2", + "ws": "^8.18.0" + }, + "bin": { + "storybook": "bin/index.cjs" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "prettier": "^2 || ^3" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -12579,6 +14157,76 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -12719,6 +14367,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -12729,6 +14391,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -12904,6 +14579,45 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -12968,6 +14682,16 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, "node_modules/tsconfck": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.4.tgz", @@ -13028,438 +14752,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz", - "integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.1", - "@esbuild/android-arm": "0.25.1", - "@esbuild/android-arm64": "0.25.1", - "@esbuild/android-x64": "0.25.1", - "@esbuild/darwin-arm64": "0.25.1", - "@esbuild/darwin-x64": "0.25.1", - "@esbuild/freebsd-arm64": "0.25.1", - "@esbuild/freebsd-x64": "0.25.1", - "@esbuild/linux-arm": "0.25.1", - "@esbuild/linux-arm64": "0.25.1", - "@esbuild/linux-ia32": "0.25.1", - "@esbuild/linux-loong64": "0.25.1", - "@esbuild/linux-mips64el": "0.25.1", - "@esbuild/linux-ppc64": "0.25.1", - "@esbuild/linux-riscv64": "0.25.1", - "@esbuild/linux-s390x": "0.25.1", - "@esbuild/linux-x64": "0.25.1", - "@esbuild/netbsd-arm64": "0.25.1", - "@esbuild/netbsd-x64": "0.25.1", - "@esbuild/openbsd-arm64": "0.25.1", - "@esbuild/openbsd-x64": "0.25.1", - "@esbuild/sunos-x64": "0.25.1", - "@esbuild/win32-arm64": "0.25.1", - "@esbuild/win32-ia32": "0.25.1", - "@esbuild/win32-x64": "0.25.1" - } - }, "node_modules/tsyringe": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", @@ -13660,6 +14952,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", + "integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unified": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", @@ -13762,9 +15067,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", - "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -13783,7 +15088,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -13973,21 +15278,24 @@ } }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -13996,19 +15304,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -14029,13 +15343,19 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vite-plugin-node-polyfills": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.22.0.tgz", - "integrity": "sha512-F+G3LjiGbG8QpbH9bZ//GSBr9i1InSTkaulfUHFa9jkLqVGORFBoqc2A/Yu5Mmh1kNAbiAeKeK+6aaQUf3x0JA==", + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/vite-plugin-node-polyfills/-/vite-plugin-node-polyfills-0.23.0.tgz", + "integrity": "sha512-4n+Ys+2bKHQohPBKigFlndwWQ5fFKwaGY6muNDMTb0fSQLyBzS+jjUNRZG9sKF0S/Go4ApG6LFnUGopjkILg3w==", "dev": true, "license": "MIT", "dependencies": { @@ -14046,7 +15366,7 @@ "url": "https://github.com/sponsors/davidmyersdev" }, "peerDependencies": { - "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" } }, "node_modules/vite-plugin-top-level-await": { @@ -14065,13 +15385,13 @@ } }, "node_modules/vite-plugin-wasm": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.3.0.tgz", - "integrity": "sha512-tVhz6w+W9MVsOCHzxo6SSMSswCeIw4HTrXEi6qL3IRzATl83jl09JVO1djBqPSwfjgnpVHNLYcaMbaDX5WB/pg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", "dev": true, "license": "MIT", "peerDependencies": { - "vite": "^2 || ^3 || ^4 || ^5" + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" } }, "node_modules/vite-tsconfig-paths": { @@ -14266,6 +15586,107 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -14273,6 +15694,29 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d89025650..8009c2118 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "lint": "eslint ./src", "lint:fix": "eslint --fix ./src", - "type:check": "tsc --noEmit --project ./tsconfig.app.json" + "type:check": "tsc --noEmit --project ./tsconfig.app.json", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "overrides": { "sha.js": "2.4.12" @@ -47,7 +49,9 @@ "@radix-ui/react-popper": "^1.2.1", "@radix-ui/react-progress": "^1.1.1", "@radix-ui/react-radio-group": "^1.2.2", + "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.1.3", + "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.3", @@ -64,6 +68,7 @@ "argon2-browser": "^1.18.0", "axios": "^1.12.0", "classnames": "^2.5.1", + "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", "dompurify": "^3.2.4", @@ -76,6 +81,7 @@ "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", "lexical": "^0.29.0", + "lucide-react": "^0.544.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -105,6 +111,9 @@ "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.15.0", "@kesills/eslint-config-airbnb-typescript": "^20.0.0", + "@storybook/addon-a11y": "^9.1.9", + "@storybook/addon-docs": "^9.1.9", + "@storybook/react-vite": "^9.1.9", "@stylistic/eslint-plugin": "^2.12.1", "@tailwindcss/postcss": "^4.1.14", "@tailwindcss/typography": "^0.5.15", @@ -130,6 +139,7 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.14", "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-storybook": "^9.1.9", "globals": "^15.12.0", "postcss": "^8.4.49", "prettier": "3.4.2", @@ -137,10 +147,10 @@ "tailwindcss": "^4.1.14", "typescript": "~5.6.2", "typescript-eslint": "^8.15.0", - "vite": "^5.4.18", - "vite-plugin-node-polyfills": "^0.22.0", + "vite": "^6.2.0", + "vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-top-level-await": "^1.4.4", - "vite-plugin-wasm": "^3.3.0", + "vite-plugin-wasm": "^3.4.0", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/frontend/public/fonts/AllianceNo2-Regular.otf b/frontend/public/fonts/AllianceNo2-Regular.otf new file mode 100644 index 000000000..a51796093 Binary files /dev/null and b/frontend/public/fonts/AllianceNo2-Regular.otf differ diff --git a/frontend/public/images/logotransparent_trimmed.png b/frontend/public/images/logotransparent_trimmed.png new file mode 100644 index 000000000..32911fb83 Binary files /dev/null and b/frontend/public/images/logotransparent_trimmed.png differ diff --git a/frontend/public/lotties/infisical_loading_white.json b/frontend/public/lotties/infisical_loading_white.json new file mode 100644 index 000000000..ee2b15c3b --- /dev/null +++ b/frontend/public/lotties/infisical_loading_white.json @@ -0,0 +1 @@ +{"v":"5.7.5","fr":100,"ip":0,"op":300,"w":800,"h":420,"nm":"Comp 1","ddd":0,"metadata":{},"assets":[{"id":"0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Vector (Stroke)","sr":1,"ks":{"p":{"a":0,"k":[-205,-431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","nm":"Vector (Stroke)","it":[{"ty":"gr","nm":"Path 1","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[20.447,-130.148],[-77.721,-88.116],[-139.588,-16.199],[-148.936,-1.772],[-139.006,12.261],[-71.202,92.517],[20.447,130.148],[148.936,0],[20.447,-130.148]],"i":[[0,0],[29.018,-24.696],[16.327,-25.197],[0,0],[0,0],[-23.672,-20.706],[-35.524,0],[0,71.767],[71.074,0]],"o":[[-35.542,0],[-24.403,20.768],[0,0],[0,0],[22.425,31.688],[26.864,23.498],[71.074,0],[0,-71.768],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-61.273,-68.789],[-77.721,-88.116],[-61.273,-68.789]],"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 3","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-44.825,-49.463],[20.447,-79.392],[98.18,0],[20.447,79.392],[-37.785,54.313],[-87.278,-2.751],[-44.825,-49.463],[-44.825,-49.463]],"i":[[0,0],[-18.899,0],[0,-44.371],[42.411,0],[20.197,17.666],[17.78,24.244],[-15.565,13.246],[0,0]],"o":[[24.072,-20.487],[42.411,0],[0,44.371],[-20.186,0],[-15.87,-13.881],[12.425,-17.11],[0,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[-86.28473663330078,1.4994840621948242],"ix":2},"e":{"a":0,"k":[462.44384765625,-5.23858642578125],"ix":2},"t":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[747.4509887695312,615.6649780273438],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[140.5150055885315,140.5150055885315],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Vector (Stroke)","sr":1,"ks":{"p":{"a":0,"k":[-205,-431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","nm":"Vector (Stroke)","it":[{"ty":"gr","nm":"Path 1","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-98.468,0],[-20.444,-79.392],[46.888,-43.887],[46.888,-43.887],[87.062,-0.165],[48.675,41.472],[65.75,60.248],[48.675,41.472],[-20.444,79.392],[-98.468,0]],"i":[[0,0],[-42.675,0],[-27.224,-24.453],[0,0],[-11.056,-14.336],[14.986,-13.628],[0,0],[0,0],[15.764,0],[0,44.267]],"o":[[0,-44.266],[16.468,0],[0,0],[15.303,13.745],[-10.159,13.06],[0,0],[0,0],[-28.618,26.025],[-42.675,0],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gr","nm":"Path 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[-20.444,-130.149],[-149.224,0],[-20.444,130.149],[82.824,79.024],[82.824,79.023],[139.462,14.105],[149.224,-0.255],[139.377,-14.556],[80.805,-81.647],[-20.444,-130.149]],"i":[[0,0],[0,-71.764],[-71.238,0],[-30.616,27.842],[0,0],[-12.465,18.334],[0,0],[0,0],[24.224,21.758],[35.661,0]],"o":[[-71.238,0],[0,71.764],[35.674,0],[0,0],[24.139,-21.952],[0,0],[0,0],[-13.86,-20.129],[-29.959,-26.91],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"gf","o":{"a":0,"k":100,"ix":2},"r":1,"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[62.10511016845703,5.982990741729736],"ix":2},"e":{"a":0,"k":[-461.3863220214844,-3.3000035285949707],"ix":2},"t":1},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[414.8349914550781,614.260009765625],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[140.5150055885315,140.5150055885315],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 2","sr":1,"ks":{"p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"shapes":[{"ty":"gr","nm":"Shape Layer 2","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[64.219,60.955],[75.017,50.459],[64.706,38.974],[56.737,41.937],[52.529,45.908],[46.994,53.295],[43.203,57.056],[35.02,60.825],[24.169,49.937],[34.908,38.936],[42.707,42.246],[47.079,46.558],[64.219,60.955]],"i":[[0,0],[-0.155,3.832],[5.611,0.221],[2.488,-1.995],[1.254,-1.476],[2.012,-2.367],[1.531,-1.317],[2.721,0.003],[0,5.642],[-6.195,0.163],[-2.204,-1.755],[-1.435,-1.717],[-7.72,0.139]],"o":[[7.629,-0.137],[0.248,-6.111],[-2.688,-0.106],[-1.511,1.212],[-2.012,2.368],[-1.153,1.356],[-3.063,2.455],[-6.495,-0.007],[0,-5.642],[2.954,-0.078],[1.264,1.149],[4.489,5.371],[0,0]]}}},{"ty":"tm","s":{"a":0,"k":0,"ix":2},"e":{"a":0,"k":20,"ix":2},"o":{"a":1,"k":[{"t":0,"s":[61.00000000000001],"i":{"x":[0.833],"y":[0.904]},"o":{"x":[0.167],"y":[0.167]}},{"t":50,"s":[149],"i":{"x":[0.833],"y":[0.715]},"o":{"x":[0.167],"y":[0.258]}},{"t":153,"s":[216.99999999999997],"i":{"x":[0.833],"y":[0.889]},"o":{"x":[0.167],"y":[0.092]}},{"t":207,"s":[325.00000000000006],"i":{"x":[0.833],"y":[0.81]},"o":{"x":[0.167],"y":[0.25]}},{"t":287,"s":[397.00000000000006],"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.095]}},{"t":300,"s":[421.00000000000006],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}}],"ix":2},"m":1},{"ty":"gs","o":{"a":0,"k":100,"ix":2},"bm":0,"g":{"p":3,"k":{"a":0,"k":[0,1,1,1,0.5,1,1,1,1,1,1,1,0,1,0.5,0.5,1,0],"ix":2}},"s":{"a":0,"k":[49.759,50.221],"ix":2},"e":{"a":1,"k":[{"t":0,"s":[120.553,-22.385],"i":{"x":[0.692],"y":[0.616]},"o":{"x":[0.362],"y":[0]}},{"t":67,"s":[65.727,-39.14],"i":{"x":[0.682],"y":[1]},"o":{"x":[0.359],"y":[0.551]}},{"t":150,"s":[8.856,-27.571],"i":{"x":[0.653],"y":[0.47]},"o":{"x":[0.329],"y":[0]}},{"t":227,"s":[65.202,-32.204],"i":{"x":[0.637],"y":[1]},"o":{"x":[0.31],"y":[0.455]}},{"t":300,"s":[120.553,-22.385],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}}],"ix":2},"t":1,"w":{"a":0,"k":5.4,"ix":2},"lc":1,"lj":1,"ml":4},{"ty":"tr","p":{"a":0,"k":[400.3317565917969,210.40078735351562],"ix":2},"a":{"a":0,"k":[49.59747009478117,49.94493849689434],"ix":2},"s":{"a":0,"k":[1339.9999618530273,1339.9999618530273],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}}]}],"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":3,"nm":"","sr":1,"ks":{"p":{"a":0,"k":[400.0003356933594,210.00125122070312],"ix":2},"a":{"a":0,"k":[580.941,614.963],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":100,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"ip":0,"op":301,"st":0,"bm":0},{"ddd":0,"refId":"0","w":752,"h":368,"ind":5,"ty":0,"nm":"Vector (Stroke) :M","sr":1,"ks":{"p":{"a":0,"k":[205,431],"ix":2},"a":{"a":0,"k":[0,0],"ix":2},"s":{"a":0,"k":[100,100],"ix":2},"r":{"a":0,"k":0,"ix":2},"o":{"a":0,"k":33,"ix":2},"sk":{"a":0,"k":0,"ix":2},"sa":{"a":0,"k":0,"ix":2}},"ao":0,"ip":0,"op":301,"st":0,"bm":0,"parent":4}],"markers":[]} \ No newline at end of file diff --git a/frontend/src/components/app-connections/AppConnectionOption.tsx b/frontend/src/components/app-connections/AppConnectionOption.tsx index 30a37425c..793f9d821 100644 --- a/frontend/src/components/app-connections/AppConnectionOption.tsx +++ b/frontend/src/components/app-connections/AppConnectionOption.tsx @@ -1,9 +1,11 @@ import { components, OptionProps } from "react-select"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; -import { faBuilding, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Badge, Tooltip } from "@app/components/v2"; +import { Tooltip } from "@app/components/v2"; +import { Badge, OrgIcon, SubOrgIcon } from "@app/components/v3"; +import { useOrganization } from "@app/context"; import { TAvailableAppConnection } from "@app/hooks/api/appConnections"; export const AppConnectionOption = ({ @@ -13,6 +15,8 @@ export const AppConnectionOption = ({ }: OptionProps) => { const isCreateOption = props.data.id === "_create"; + const { isSubOrganization } = useOrganization(); + return (
@@ -23,15 +27,22 @@ export const AppConnectionOption = ({
) : ( <> -

{children}

+

{children}

{!props.data.projectId && ( - -
- - + + {isSubOrganization ? ( + + + Sub-Organization + + ) : ( + + Organization -
+ )}
)} {isSelected && ( diff --git a/frontend/src/components/license/UpgradePlanModal/UpgradePlanModal.tsx b/frontend/src/components/license/UpgradePlanModal/UpgradePlanModal.tsx index 17c0777f1..2279d6f8d 100644 --- a/frontend/src/components/license/UpgradePlanModal/UpgradePlanModal.tsx +++ b/frontend/src/components/license/UpgradePlanModal/UpgradePlanModal.tsx @@ -8,22 +8,40 @@ type Props = { isOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; text: string; + isEnterpriseFeature?: boolean; }; -export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Element => { +export const UpgradePlanModal = ({ + text, + isOpen, + onOpenChange, + isEnterpriseFeature = false +}: Props): JSX.Element => { const { subscription } = useSubscription(); const { currentOrg } = useOrganization(); const { mutateAsync, isPending } = useGetOrgTrialUrl(); - const link = - subscription && subscription.slug !== null - ? ("/organization/billing" as const) - : "https://infisical.com/scheduledemo"; + + const getLink = () => { + // self-hosting + if (!subscription || subscription.slug === null) { + return "https://infisical.com/scheduledemo"; + } + + // Infisical cloud + if (isEnterpriseFeature) { + return "https://infisical.com/talk-to-us"; + } + + return "/organization/billing" as const; + }; + + const link = getLink(); const handleUpgradeBtnClick = async () => { try { if (!subscription || !currentOrg) return; - if (!subscription.has_used_trial) { + if (!subscription.has_used_trial && !isEnterpriseFeature) { // direct user to start pro trial const url = await mutateAsync({ @@ -40,6 +58,17 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele console.error(err); } }; + const getUpgradePlanLabel = () => { + if (subscription) { + if (isEnterpriseFeature) { + return "Talk to Us"; + } + if (!subscription.has_used_trial) { + return "Start Pro Free Trial"; + } + } + return "Upgrade Plan"; + }; return ( @@ -55,7 +84,7 @@ export const UpgradePlanModal = ({ text, isOpen, onOpenChange }: Props): JSX.Ele onClick={handleUpgradeBtnClick} className="mr-4" > - {subscription && !subscription.has_used_trial ? "Start Pro Free Trial" : "Upgrade Plan"} + {getUpgradePlanLabel()} {selectedTabIndex > 0 && ( diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx index d80dbc640..59871e880 100644 --- a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -6,6 +6,7 @@ type Props = { onConfirm: () => void; isLoading?: boolean; duplicateProjectId?: string; + isDisabled?: boolean; }; export const DuplicateDestinationConfirmationModal = ({ @@ -13,7 +14,8 @@ export const DuplicateDestinationConfirmationModal = ({ onOpenChange, onConfirm, isLoading, - duplicateProjectId + duplicateProjectId, + isDisabled }: Props) => { return ( @@ -21,7 +23,12 @@ export const DuplicateDestinationConfirmationModal = ({

Another secret sync in your organization is already configured with the same - destination. Proceeding may cause conflicts or overwrite existing data. + destination.{" "} + + {isDisabled + ? "Your organization does not allow duplicate destination configurations." + : "Proceeding may cause conflicts or overwrite existing data."} +

{duplicateProjectId && (

@@ -31,26 +38,28 @@ export const DuplicateDestinationConfirmationModal = ({

)} -

Are you sure you want to continue?

+ {!isDisabled &&

Are you sure you want to continue?

}
-
- - - - - - -
+ {!isDisabled && ( +
+ + + + + + +
+ )}
); diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 3c0e4ea17..2085afe9c 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { Button, ModalClose } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, @@ -30,6 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); const [pendingFormData, setPendingFormData] = useState(null); + const { currentOrg } = useOrganization(); const formMethods = useForm({ resolver: zodResolver(UpdateSecretSyncFormSchema), @@ -209,6 +211,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => onConfirm={handleConfirmDuplicate} isLoading={updateSecretSync.isPending} duplicateProjectId={storedDuplicateProjectId} + isDisabled={currentOrg?.blockDuplicateSecretSyncDestinations} /> ); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx index e7d988f46..08414c14e 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx @@ -5,14 +5,8 @@ import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; -import { - Badge, - FilterableSelect, - FormControl, - Select, - SelectItem, - Tooltip -} from "@app/components/v2"; +import { FilterableSelect, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { GCP_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { useGcpConnectionListProjectLocations, @@ -26,10 +20,7 @@ import { TSecretSyncForm } from "../schemas"; const formatOptionLabel = ({ displayName, locationId }: TGcpLocation) => (
- {displayName}{" "} - - {locationId} - + {displayName} {locationId}
); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx new file mode 100644 index 000000000..2392e4394 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/NorthflankSyncFields.tsx @@ -0,0 +1,123 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TNorthflankProject, + TNorthflankSecretGroup, + useNorthflankConnectionListProjects, + useNorthflankConnectionListSecretGroups +} from "@app/hooks/api/appConnections/northflank"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const NorthflankSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Northflank } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const projectId = useWatch({ name: "destinationConfig.projectId", control }); + + const { data: projects = [], isPending: isProjectsLoading } = useNorthflankConnectionListProjects( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: secretGroups = [], isPending: isSecretGroupsLoading } = + useNorthflankConnectionListSecretGroups(connectionId, projectId, { + enabled: Boolean(connectionId) && Boolean(projectId) + }); + + return ( + <> + { + setValue("destinationConfig.projectId", ""); + setValue("destinationConfig.projectName", ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + /> + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.projectName", v?.name ?? ""); + setValue("destinationConfig.secretGroupId", ""); + setValue("destinationConfig.secretGroupName", ""); + }} + options={projects} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + ( + +
+ Don't see the secret group you're looking for?{" "} + +
+ + } + > + sg.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.secretGroupName", v?.name ?? ""); + }} + options={secretGroups} + placeholder="Select a secret group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index 61ba54369..ffad9aa42 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -25,6 +25,7 @@ import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; import { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; +import { NorthflankSyncFields } from "./NorthflankSyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; import { RenderSyncFields } from "./RenderSyncFields"; @@ -103,6 +104,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.LaravelForge: return ; + case SecretSync.Northflank: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/shared/AwsRegionSelect.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/shared/AwsRegionSelect.tsx index 6b1894626..829d7c44f 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/shared/AwsRegionSelect.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/shared/AwsRegionSelect.tsx @@ -2,7 +2,8 @@ import { components, OptionProps, SingleValue } from "react-select"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Badge, FilterableSelect } from "@app/components/v2"; +import { FilterableSelect } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { AWS_REGIONS } from "@app/helpers/appConnections"; const Option = ({ isSelected, children, ...props }: OptionProps<(typeof AWS_REGIONS)[number]>) => { @@ -10,7 +11,7 @@ const Option = ({ isSelected, children, ...props }: OptionProps<(typeof AWS_REGI

{children}

- + {props.data.slug} {isSelected && ( diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index f66cadaea..a7fb037de 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -68,6 +68,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Supabase: case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: + case SecretSync.Northflank: case SecretSync.Bitbucket: case SecretSync.LaravelForge: AdditionalSyncOptionsFieldsComponent = null; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx index 073cb4175..a70752703 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsParameterStoreSyncReviewFields.tsx @@ -1,10 +1,10 @@ import { useFormContext } from "react-hook-form"; -import { faEye } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { EyeIcon } from "lucide-react"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { AWS_REGIONS } from "@app/helpers/appConnections"; import { SecretSync } from "@app/hooks/api/secretSyncs"; @@ -41,11 +41,9 @@ export const AwsParameterStoreSyncOptionsReviewFields = () => { } >
- - - - {tags.length} Tag{tags.length > 1 ? "s" : ""} - + + + {tags.length} Tag{tags.length > 1 ? "s" : ""}
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx index 90d7d5023..d37cae3f1 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx @@ -1,10 +1,10 @@ import { useFormContext } from "react-hook-form"; -import { faEye } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { EyeIcon } from "lucide-react"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { AWS_REGIONS } from "@app/helpers/appConnections"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync"; @@ -26,7 +26,7 @@ export const AwsSecretsManagerSyncReviewFields = () => { <> {awsRegion?.name} - + {awsRegion?.slug}{" "} @@ -73,11 +73,9 @@ export const AwsSecretsManagerSyncOptionsReviewFields = () => { } >
- - - - {tags.length} Tag{tags.length > 1 ? "s" : ""} - + + + {tags.length} Tag{tags.length > 1 ? "s" : ""}
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx new file mode 100644 index 000000000..a37597722 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/NorthflankSyncReviewFields.tsx @@ -0,0 +1,20 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncReviewFields = () => { + const { watch } = useFormContext(); + const projectName = watch("destinationConfig.projectName"); + const projectId = watch("destinationConfig.projectId"); + const secretGroupName = watch("destinationConfig.secretGroupName"); + const secretGroupId = watch("destinationConfig.secretGroupId"); + + return ( + <> + {projectName || projectId} + {secretGroupName || secretGroupId} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index f195f0e72..313d7eb3d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -2,7 +2,7 @@ import { useFormContext } from "react-hook-form"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { Badge } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 44d35cd2c..4cc9c7259 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -5,8 +5,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { Badge } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { Badge } from "@app/components/v3"; +import { useOrganization, useProject } from "@app/context"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; @@ -37,6 +37,7 @@ import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; +import { NorthflankSyncReviewFields } from "./NorthflankSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; @@ -51,6 +52,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); let DestinationFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode; @@ -166,6 +168,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Netlify: DestinationFieldsComponent = ; break; + case SecretSync.Northflank: + DestinationFieldsComponent = ; + break; case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; @@ -193,18 +198,50 @@ export const SecretSyncReviewFields = () => { {isChecking && Checking...}
{hasDuplicate && ( -
-
- +
+
+

- Another secret sync in your organization is already configured with the same - destination. This may lead to conflicts or unexpected behavior. + {currentOrg?.blockDuplicateSecretSyncDestinations + ? "Another secret sync in your organization is already configured with the same destination. Your organization does not allow duplicate destination configurations." + : "Another secret sync in your organization is already configured with the same destination. This may lead to conflicts or unexpected behavior."}

{duplicateProjectId && ( -

+

Duplicate found in project ID:{" "} - + {duplicateProjectId}

@@ -224,7 +261,7 @@ export const SecretSyncReviewFields = () => {
- + {isAutoSyncEnabled ? "Enabled" : "Disabled"} @@ -235,7 +272,7 @@ export const SecretSyncReviewFields = () => { {AdditionalSyncOptionsFieldsComponent} {disableSecretDeletion && ( - Disabled + Disabled )}
diff --git a/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts new file mode 100644 index 000000000..1554da63b --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/northflank-sync-destination-schema.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const NorthflankSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Northflank), + destinationConfig: z.object({ + projectId: z.string().trim().min(1, "Project ID is required"), + projectName: z.string().trim().optional(), + secretGroupId: z.string().trim().min(1, "Secret Group ID is required"), + secretGroupName: z.string().trim().optional() + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 5ebc38184..146c862dc 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -22,6 +22,7 @@ import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; import { LaravelForgeSyncDestinationSchema } from "./laravel-forge-sync-destination-schema"; import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; +import { NorthflankSyncDestinationSchema } from "./northflank-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; import { RenderSyncDestinationSchema } from "./render-sync-destination-schema"; @@ -62,6 +63,7 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, + NorthflankSyncDestinationSchema, BitbucketSyncDestinationSchema, LaravelForgeSyncDestinationSchema ]); diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx deleted file mode 100644 index 36541a781..000000000 --- a/frontend/src/components/v2/Badge/Badge.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { forwardRef } from "react"; -import { cva, VariantProps } from "cva"; -import { twMerge } from "tailwind-merge"; - -interface IProps { - children: React.ReactNode; - className?: string; - onClick?: (e: React.MouseEvent) => void; -} - -const badgeVariants = cva( - [ - "inline-block cursor-default rounded-md bg-yellow/20 px-1.5 py-0.5 text-xs text-yellow opacity-80 hover:opacity-100" - ], - { - variants: { - variant: { - primary: "bg-yellow/20 text-yellow", - danger: "bg-red/20 text-red", - success: "bg-green/20 text-green", - org: "bg-org-v1/20 text-org-v1 [&_svg]:text-org-v1 flex items-center opacity-100 hover:bg-org-v1/10 [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap", - namespace: - "bg-namespace-v1/20 text-namespace-v1 [&_svg]:text-namespace-v1 flex opacity-100 hover:bg-namespace-v1/10 items-center [&_svg]:size-3.5 gap-x-1.5 w-min whitespace-nowrap", - project: - "bg-primary/10 text-primary [&_svg]:text-primary opacity-100 hover:bg-primary/10 flex items-center [&_svg]:size-3 w-min gap-x-1.5 whitespace-nowrap", - instance: - "bg-mineshaft-200/20 text-mineshaft-200 [&_svg]:text-mineshaft-200 opacity-100 hover:bg-mineshaft-200/20 flex items-center [&_svg]:size-3 gap-x-1.5 w-min whitespace-nowrap" - } - } - } -); - -export type BadgeProps = VariantProps & IProps; - -export const Badge = forwardRef( - ({ children, className, variant, ...props }, ref) => { - return ( -
- {children} -
- ); - } -); diff --git a/frontend/src/components/v2/Badge/index.tsx b/frontend/src/components/v2/Badge/index.tsx deleted file mode 100644 index 5c7042709..000000000 --- a/frontend/src/components/v2/Badge/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { Badge } from "./Badge"; diff --git a/frontend/src/components/v2/Modal/Modal.tsx b/frontend/src/components/v2/Modal/Modal.tsx index e7efda4b8..5e8f65788 100644 --- a/frontend/src/components/v2/Modal/Modal.tsx +++ b/frontend/src/components/v2/Modal/Modal.tsx @@ -14,6 +14,7 @@ export type ModalContentProps = Omit void; overlayClassName?: string; + showCloseButton?: boolean; }; export const ModalContent = forwardRef( @@ -27,6 +28,7 @@ export const ModalContent = forwardRef( footerContent, bodyClassName, onClose, + showCloseButton = true, ...props }, forwardedRef @@ -57,15 +59,17 @@ export const ModalContent = forwardRef( {children} {footerContent && {footerContent}} - - - - - + {showCloseButton && ( + + + + + + )} @@ -74,7 +78,9 @@ export const ModalContent = forwardRef( ModalContent.displayName = "ModalContent"; -export type ModalProps = Omit & { isOpen?: boolean }; +export type ModalProps = Omit & { + isOpen?: boolean; +}; export const Modal = ({ isOpen, ...props }: ModalProps) => ( ); diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index e3f72f61b..f84edfcd2 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -1,11 +1,16 @@ -import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; -import { faCube, faCubes, faGlobe, faServer } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { createElement } from "react"; import { ReactNode } from "@tanstack/react-router"; +import { LucideIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; -import { Badge } from "@app/components/v2"; -import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { + Badge, + InstanceIcon, + OrgIcon, + ProjectIcon, + SubOrgIcon, + TBadgeProps +} from "@app/components/v3"; import { ProjectType } from "@app/hooks/api/projects/types"; type Props = { @@ -16,19 +21,19 @@ type Props = { scope: "org" | "namespace" | "instance" | ProjectType | null; }; -const SCOPE_NAME: Record, { label: string; icon: IconDefinition }> = { - org: { label: "Organization", icon: faGlobe }, - [ProjectType.SecretManager]: { label: "Project", icon: faCube }, - [ProjectType.CertificateManager]: { label: "Project", icon: faCube }, - [ProjectType.SSH]: { label: "Project", icon: faCube }, - [ProjectType.KMS]: { label: "Project", icon: faCube }, - [ProjectType.PAM]: { label: "Project", icon: faCube }, - [ProjectType.SecretScanning]: { label: "Project", icon: faCube }, - namespace: { label: "Sub-Organization", icon: faCubes }, - instance: { label: "Server", icon: faServer } +const SCOPE_NAME: Record, { label: string; icon: LucideIcon }> = { + org: { label: "Organization", icon: OrgIcon }, + [ProjectType.SecretManager]: { label: "Project", icon: ProjectIcon }, + [ProjectType.CertificateManager]: { label: "Project", icon: ProjectIcon }, + [ProjectType.SSH]: { label: "Project", icon: ProjectIcon }, + [ProjectType.KMS]: { label: "Project", icon: ProjectIcon }, + [ProjectType.PAM]: { label: "Project", icon: ProjectIcon }, + [ProjectType.SecretScanning]: { label: "Project", icon: ProjectIcon }, + namespace: { label: "Sub-Organization", icon: SubOrgIcon }, + instance: { label: "Server", icon: InstanceIcon } }; -const SCOPE_VARIANT: Record, BadgeProps["variant"]> = { +const SCOPE_VARIANT: Record, TBadgeProps["variant"]> = { org: "org", [ProjectType.SecretManager]: "project", [ProjectType.CertificateManager]: "project", @@ -36,8 +41,8 @@ const SCOPE_VARIANT: Record, BadgeProps["variant"]> [ProjectType.KMS]: "project", [ProjectType.PAM]: "project", [ProjectType.SecretScanning]: "project", - namespace: "namespace", - instance: "instance" + namespace: "sub-org", + instance: "neutral" }; export const PageHeader = ({ title, description, children, className, scope }: Props) => ( @@ -47,7 +52,7 @@ export const PageHeader = ({ title, description, children, className, scope }: P

{title}

{scope && ( - + {createElement(SCOPE_NAME[scope].icon)} {SCOPE_NAME[scope].label} )} diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 3954b8b20..99e83823e 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -3,6 +3,7 @@ import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { faCaretDown, faCaretUp, faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as SelectPrimitive from "@radix-ui/react-select"; +import { LucideIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { Spinner } from "../Spinner"; @@ -19,6 +20,7 @@ type Props = { icon?: IconProp; isMulti?: boolean; iconClassName?: string; + LucideIcon?: LucideIcon; dropdownContainerStyle?: React.CSSProperties; side?: SelectPrimitive.SelectContentProps["side"]; }; @@ -39,6 +41,7 @@ export const Select = forwardRef( iconClassName, dropdownContainerStyle, side, + LucideIcon: Lucide, ...props }, ref @@ -65,6 +68,7 @@ export const Select = forwardRef( >
{props.icon && } + {Lucide && }
diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 8019c3589..cb4430aa4 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -2,7 +2,6 @@ export * from "./AccessRestrictedBanner"; export * from "./Accordion"; export * from "./Alert"; -export * from "./Badge"; export * from "./Breadcrumb"; export * from "./Button"; export * from "./Card"; diff --git a/frontend/src/components/v3/generic/Badge/Badge.stories.tsx b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx new file mode 100644 index 000000000..4cbf955f9 --- /dev/null +++ b/frontend/src/components/v3/generic/Badge/Badge.stories.tsx @@ -0,0 +1,376 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Link } from "@tanstack/react-router"; +import { + AlertTriangleIcon, + AsteriskIcon, + BanIcon, + CheckIcon, + ChevronsUpDownIcon, + CircleXIcon, + ExternalLinkIcon, + GlobeIcon, + InfoIcon, + RadarIcon, + TriangleAlertIcon, + UserIcon +} from "lucide-react"; + +import { OrgIcon, ProjectIcon, SubOrgIcon } from "../../platform"; +import { Badge } from "./Badge"; + +/** + * Badges act as an indicator that can optionally be made interactable. + * You can place text and icons inside a badge. + * Badges are often used for the indication of a status, state or scope. + */ +const meta = { + title: "Generic/Badge", + component: Badge, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: { + variant: { + control: "select", + options: ["neutral", "success", "info", "warning", "danger", "project", "org", "sub-org"] + }, + isTruncatable: { + table: { + disable: true + } + }, + asChild: { + table: { + disable: true + } + }, + children: { + table: { + disable: true + } + } + }, + args: { children: "Badge", isTruncatable: false } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Neutral: Story = { + name: "Variant: Neutral", + args: { + variant: "neutral", + children: ( + <> + + Disabled + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating neutral or disabled states or when linking to external documents." + } + } + } +}; + +export const Ghost: Story = { + name: "Variant: Ghost", + args: { + variant: "ghost", + children: ( + <> + + User + + ) + }, + parameters: { + docs: { + description: { + story: + "Use this variant when indicating a configuration or property value. Avoid using this variant as an interactive element as it is not intuitive to interact with." + } + } + } +}; + +export const Success: Story = { + name: "Variant: Success", + args: { + variant: "success", + children: ( + <> + + Success + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating successful or healthy states." + } + } + } +}; + +export const Info: Story = { + name: "Variant: Info", + args: { + variant: "info", + children: ( + <> + + Info + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating informational states." + } + } + } +}; + +export const Warning: Story = { + name: "Variant: Warning", + args: { + variant: "warning", + children: ( + <> + + Warning + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating activity or attention warranting states." + } + } + } +}; + +export const Danger: Story = { + name: "Variant: Danger", + args: { + variant: "danger", + children: ( + <> + + Danger + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating destructive or error states." + } + } + } +}; + +export const Organization: Story = { + name: "Variant: Organization", + args: { + variant: "org", + children: ( + <> + + Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating organization scope or links." + } + } + } +}; + +export const SubOrganization: Story = { + name: "Variant: Sub-Organization", + args: { + variant: "sub-org", + children: ( + <> + + Sub-Organization + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating sub-organization scope or links." + } + } + } +}; + +export const Project: Story = { + name: "Variant: Project", + args: { + variant: "project", + children: ( + <> + + Project + + ) + }, + parameters: { + docs: { + description: { + story: "Use this variant when indicating project scope or links." + } + } + } +}; + +export const AsExternalLink: Story = { + name: "Example: As External Link", + args: { + variant: "info", + asChild: true, + children: ( + + Link + + ) + }, + parameters: { + docs: { + description: { + story: "Use the `asChild` prop with an `a` tag to use a badge as an external link." + } + } + } +}; + +export const AsRouterLink: Story = { + name: "Example: As Router Link", + args: { + variant: "project", + asChild: true, + children: ( + + + Secret Scanning + + ) + }, + parameters: { + docs: { + description: { + story: "Use the `asChild` prop with a `Link` component to use a badge as an internal link." + } + } + } +}; + +export const AsButton: Story = { + name: "Example: As Button", + args: { + variant: "org", + asChild: true, + children: ( + + ) + }, + parameters: { + docs: { + description: { + story: + "Use the `asChild` prop with a `button` tag to use a badge as a button. Do not use a styled `Button` component." + } + } + } +}; + +export const IsTruncatable: Story = { + name: "Example: isTruncatable", + args: { + variant: "org", + isTruncatable: true, + children: ( + <> + + Infisical Infrastructure + + ) + }, + parameters: { + docs: { + description: { + story: + "Use the `isTruncatable` prop with a `span` tag wrapping the text content to support truncation. Parent `div` should have a fixed width and `flex` class." + } + } + }, + decorators: (Story) => ( +
+ +
+ ) +}; + +export const IsSquare: Story = { + name: "Example: isSquare", + args: { + variant: "danger", + isSquare: true, + children: + }, + parameters: { + docs: { + description: { + story: + "Use the `isSquare` prop when displaying a squared badge with 1-2 character text or only an icon." + } + } + } +}; + +export const IsFullWidth: Story = { + name: "Example: isFullWidth", + args: { + variant: "neutral", + isFullWidth: true, + + children: ( + <> + + Secret Value + + ) + }, + parameters: { + docs: { + description: { + story: + "Use the `isFullWidth` prop to expand the badges width to fill it's parent container." + } + } + }, + decorators: (Story) => ( +
+ +
+ ) +}; diff --git a/frontend/src/components/v3/generic/Badge/Badge.tsx b/frontend/src/components/v3/generic/Badge/Badge.tsx new file mode 100644 index 000000000..f94bff5f2 --- /dev/null +++ b/frontend/src/components/v3/generic/Badge/Badge.tsx @@ -0,0 +1,77 @@ +import { forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "cva"; + +import { cn } from "@app/components/v3/utils"; + +const badgeVariants = cva( + [ + "select-none items-center align-middle rounded-sm h-4.5 px-1.5 text-xs", + "gap-x-1 [a&,button&]:cursor-pointer inline-flex font-normal", + "[&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg]:stroke-[2.25] [&_svg:not([class*='size-'])]:size-3", + "transition duration-200 ease-in-out" + ], + { + variants: { + isTruncatable: { + true: "[&>span,&>p]:truncate min-w-0", + false: "w-fit shrink-0 min-w-fit whitespace-nowrap overflow-hidden" + }, + isFullWidth: { + true: "w-full justify-center" + }, + isSquare: { + true: "w-4.5 justify-center px-0.5" + }, + variant: { + ghost: "text-mineshaft-200 gap-x-2", + neutral: "bg-neutral/25 text-neutral [a&,button&]:hover:bg-neutral/35", + success: "bg-success/25 text-success [a&,button&]:hover:bg-success/35", + info: "bg-info/25 text-info [a&,button&]:hover:bg-info/35", + warning: "bg-warning/25 text-warning [a&,button&]:hover:bg-warning/35", + danger: "bg-danger/25 text-danger [a&,button&]:hover:bg-danger/35", + project: "bg-project/25 text-project [a&,button&]:hover:bg-project/35", + org: "bg-org/25 text-org [a&,button&]:hover:bg-org/35", + "sub-org": "bg-sub-org/25 text-sub-org [a&,button&]:hover:bg-sub-org/35" + } + }, + defaultVariants: { + variant: "neutral" + } + } +); + +type TBadgeProps = VariantProps & + React.ComponentProps<"span"> & { + asChild?: boolean; + variant: NonNullable["variant"]>; // TODO: REMOVE + }; + +const Badge = forwardRef( + ( + { + className, + variant, + asChild = false, + isTruncatable = false, + isFullWidth = false, + isSquare = false, + ...props + }, + ref + ): JSX.Element => { + const Comp = asChild ? Slot : "span"; + return ( + + ); + } +); + +Badge.displayName = "Badge"; + +export { Badge, badgeVariants, type TBadgeProps }; diff --git a/frontend/src/components/v3/generic/Badge/index.ts b/frontend/src/components/v3/generic/Badge/index.ts new file mode 100644 index 000000000..ae21190ba --- /dev/null +++ b/frontend/src/components/v3/generic/Badge/index.ts @@ -0,0 +1 @@ +export * from "./Badge"; diff --git a/frontend/src/components/v3/generic/index.ts b/frontend/src/components/v3/generic/index.ts new file mode 100644 index 000000000..ae21190ba --- /dev/null +++ b/frontend/src/components/v3/generic/index.ts @@ -0,0 +1 @@ +export * from "./Badge"; diff --git a/frontend/src/components/v3/index.ts b/frontend/src/components/v3/index.ts new file mode 100644 index 000000000..1b74c0e26 --- /dev/null +++ b/frontend/src/components/v3/index.ts @@ -0,0 +1,2 @@ +export * from "./generic"; +export * from "./platform"; diff --git a/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.stories.tsx b/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.stories.tsx new file mode 100644 index 000000000..bd746532e --- /dev/null +++ b/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { DocumentationLinkBadge } from "./DocumentationLinkBadge"; + +/** + * The documentation link badge is a re-usable component to link to Infisical documentation references. + */ +const meta = { + title: "Platform/Documentation Link Badge", + component: DocumentationLinkBadge, + parameters: { + layout: "centered" + }, + tags: ["autodocs"], + argTypes: {}, + args: { href: "https://infisical.com/docs/documentation/getting-started/introduction" } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + name: "Example: Default" +}; diff --git a/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx b/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx new file mode 100644 index 000000000..b847f93ae --- /dev/null +++ b/frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx @@ -0,0 +1,18 @@ +import { BookOpenIcon } from "lucide-react"; + +import { Badge } from "@app/components/v3"; + +type TDocumentationLinkBadgeProps = { + href: string; +}; + +export function DocumentationLinkBadge({ href }: TDocumentationLinkBadgeProps) { + return ( + + + + Documentation + + + ); +} diff --git a/frontend/src/components/v3/platform/DocumentationLinkBadge/index.ts b/frontend/src/components/v3/platform/DocumentationLinkBadge/index.ts new file mode 100644 index 000000000..aed3c2244 --- /dev/null +++ b/frontend/src/components/v3/platform/DocumentationLinkBadge/index.ts @@ -0,0 +1 @@ +export * from "./DocumentationLinkBadge"; diff --git a/frontend/src/components/v3/platform/ScopeIcons.tsx b/frontend/src/components/v3/platform/ScopeIcons.tsx new file mode 100644 index 000000000..8f8712319 --- /dev/null +++ b/frontend/src/components/v3/platform/ScopeIcons.tsx @@ -0,0 +1,8 @@ +import { BoxesIcon, BoxIcon, Building2Icon, ServerIcon } from "lucide-react"; + +const InstanceIcon = ServerIcon; +const OrgIcon = Building2Icon; +const SubOrgIcon = BoxesIcon; +const ProjectIcon = BoxIcon; + +export { InstanceIcon, OrgIcon, ProjectIcon, SubOrgIcon }; diff --git a/frontend/src/components/v3/platform/index.ts b/frontend/src/components/v3/platform/index.ts new file mode 100644 index 000000000..ab5839618 --- /dev/null +++ b/frontend/src/components/v3/platform/index.ts @@ -0,0 +1,2 @@ +export * from "./DocumentationLinkBadge"; +export * from "./ScopeIcons"; diff --git a/frontend/src/components/v3/utils/index.ts b/frontend/src/components/v3/utils/index.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/frontend/src/components/v3/utils/index.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index 410df7440..c9a6b326d 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -62,7 +62,11 @@ export const ROUTE_PATHS = Object.freeze({ "/organization/app-connections/$appConnection/oauth/callback", "/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback" ) - } + }, + NetworkingPage: setRoute( + "/organization/networking", + "/_authenticate/_inject-org-details/_org-layout/organization/networking" + ) }, SecretManager: { ApprovalPage: setRoute( diff --git a/frontend/src/consts/pam.ts b/frontend/src/consts/pam.ts new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index bdb26c796..351357d20 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -50,6 +50,7 @@ import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/type import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection"; import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; +import { NorthflankConnectionMethod } from "@app/hooks/api/appConnections/types/northflank-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; import { RenderConnectionMethod } from "@app/hooks/api/appConnections/types/render-connection"; @@ -121,6 +122,7 @@ export const APP_CONNECTION_MAP: Record< name: "Netlify", image: "Netlify.png" }, + [AppConnection.Northflank]: { name: "Northflank", image: "Northflank.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, [AppConnection.LaravelForge]: { @@ -135,6 +137,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case GitHubConnectionMethod.App: case GitHubRadarConnectionMethod.App: return { name: "GitHub App", icon: faGithub }; + case GitHubConnectionMethod.Pat: + return { name: "Personal Access Token", icon: faKey }; case AzureKeyVaultConnectionMethod.OAuth: case AzureAppConfigurationConnectionMethod.OAuth: case AzureClientSecretsConnectionMethod.OAuth: @@ -162,6 +166,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case BitbucketConnectionMethod.ApiToken: case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: + case NorthflankConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; diff --git a/frontend/src/helpers/policies.ts b/frontend/src/helpers/policies.ts index d798d3c10..58feaf703 100644 --- a/frontend/src/helpers/policies.ts +++ b/frontend/src/helpers/policies.ts @@ -1,20 +1,20 @@ -import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; -import { faArrowRightToBracket, faEdit } from "@fortawesome/free-solid-svg-icons"; +import { LucideIcon, UserCheckIcon, UserPenIcon } from "lucide-react"; +import { TBadgeProps } from "@app/components/v3"; import { PolicyType } from "@app/hooks/api/policies/enums"; export const policyDetails: Record< PolicyType, - { name: string; className: string; icon: IconDefinition } + { name: string; variant: TBadgeProps["variant"]; Icon: LucideIcon } > = { [PolicyType.AccessPolicy]: { - className: "bg-green/20 text-green", + variant: "ghost", name: "Access Policy", - icon: faArrowRightToBracket + Icon: UserCheckIcon }, [PolicyType.ChangePolicy]: { - className: "bg-yellow/20 text-yellow", + variant: "ghost", name: "Change Policy", - icon: faEdit + Icon: UserPenIcon } }; diff --git a/frontend/src/helpers/secretScanningV2.ts b/frontend/src/helpers/secretScanningV2.ts index 4e88b8482..ee5226611 100644 --- a/frontend/src/helpers/secretScanningV2.ts +++ b/frontend/src/helpers/secretScanningV2.ts @@ -1,10 +1,6 @@ -import { - faBan, - faCheck, - faMagnifyingGlassMinus, - faWarning -} from "@fortawesome/free-solid-svg-icons"; +import { AlertTriangleIcon, BanIcon, CheckIcon, LucideIcon, SearchSlashIcon } from "lucide-react"; +import { TBadgeProps } from "@app/components/v3"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretScanningDataSource, @@ -74,12 +70,28 @@ export const RESOURCE_DESCRIPTION_HELPER: Record< } }; -export const SECRET_SCANNING_FINDING_STATUS_ICON_MAP = { - [SecretScanningFindingStatus.Resolved]: { icon: faCheck, className: "text-green" }, - [SecretScanningFindingStatus.Unresolved]: { icon: faWarning, className: "text-yellow" }, - [SecretScanningFindingStatus.Ignore]: { icon: faBan, className: "text-mineshaft-400" }, +export const SECRET_SCANNING_FINDING_STATUS_MAP: Record< + SecretScanningFindingStatus, + { Icon: LucideIcon; variant: TBadgeProps["variant"]; className: string } +> = { + [SecretScanningFindingStatus.Resolved]: { + Icon: CheckIcon, + variant: "success", + className: "text-success" + }, + [SecretScanningFindingStatus.Unresolved]: { + Icon: AlertTriangleIcon, + variant: "warning", + className: "text-warning" + }, + [SecretScanningFindingStatus.Ignore]: { + Icon: BanIcon, + variant: "neutral", + className: "text-neutral" + }, [SecretScanningFindingStatus.FalsePositive]: { - icon: faMagnifyingGlassMinus, - className: "text-mineshaft-400" + Icon: SearchSlashIcon, + variant: "neutral", + className: "text-neutral" } }; diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 12cae6ea6..a96af15d4 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -114,6 +114,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, + [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.LaravelForge]: AppConnection.LaravelForge }; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 66fed8a5d..4af1dbb27 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -36,6 +36,7 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", + Northflank = "northflank", Okta = "okta", Redis = "redis", LaravelForge = "laravel-forge" diff --git a/frontend/src/hooks/api/appConnections/gitlab/types.ts b/frontend/src/hooks/api/appConnections/gitlab/types.ts index 0d8d9baf0..7d2699c75 100644 --- a/frontend/src/hooks/api/appConnections/gitlab/types.ts +++ b/frontend/src/hooks/api/appConnections/gitlab/types.ts @@ -10,5 +10,6 @@ export type TGitLabGroup = { export enum GitLabAccessTokenType { Personal = "personal", - Project = "project" + Project = "project", + Group = "group" } diff --git a/frontend/src/hooks/api/appConnections/northflank/index.ts b/frontend/src/hooks/api/appConnections/northflank/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/northflank/queries.tsx b/frontend/src/hooks/api/appConnections/northflank/queries.tsx new file mode 100644 index 000000000..bf63cd976 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/queries.tsx @@ -0,0 +1,65 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TNorthflankProject, TNorthflankSecretGroup } from "./types"; + +const northflankConnectionKeys = { + all: [...appConnectionKeys.all, "northflank"] as const, + listProjects: (connectionId: string) => + [...northflankConnectionKeys.all, "projects", connectionId] as const, + listSecretGroups: (connectionId: string, projectId: string) => + [...northflankConnectionKeys.all, "secret-groups", connectionId, projectId] as const +}; + +export const useNorthflankConnectionListProjects = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TNorthflankProject[], + unknown, + TNorthflankProject[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listProjects(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: TNorthflankProject[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects` + ); + + return data.projects; + }, + ...options + }); +}; + +export const useNorthflankConnectionListSecretGroups = ( + connectionId: string, + projectId: string, + options?: Omit< + UseQueryOptions< + TNorthflankSecretGroup[], + unknown, + TNorthflankSecretGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: northflankConnectionKeys.listSecretGroups(connectionId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ secretGroups: TNorthflankSecretGroup[] }>( + `/api/v1/app-connections/northflank/${connectionId}/projects/${projectId}/secret-groups` + ); + + return data.secretGroups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/northflank/types.ts b/frontend/src/hooks/api/appConnections/northflank/types.ts new file mode 100644 index 000000000..061d2179f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/northflank/types.ts @@ -0,0 +1,9 @@ +export type TNorthflankProject = { + id: string; + name: string; +}; + +export type TNorthflankSecretGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 797e7a7c6..4d4425ef6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -168,6 +168,10 @@ export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { app: AppConnection.LaravelForge; }; +export type TNorthflankConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Northflank; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -213,6 +217,7 @@ export type TAppConnectionOption = | TSupabaseConnectionOption | TDigitalOceanConnectionOption | TNetlifyConnectionOption + | TNorthflankConnectionOption | TOktaConnectionOption | TAzureAdCsConnectionOption | TLaravelForgeConnectionOption; @@ -254,6 +259,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Supabase]: TSupabaseConnectionOption; [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; [AppConnection.Netlify]: TNetlifyConnectionOption; + [AppConnection.Northflank]: TNorthflankConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/github-connection.ts b/frontend/src/hooks/api/appConnections/types/github-connection.ts index 27bed2dcb..595e5adbb 100644 --- a/frontend/src/hooks/api/appConnections/types/github-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/github-connection.ts @@ -3,7 +3,8 @@ import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-con export enum GitHubConnectionMethod { App = "github-app", - OAuth = "oauth" + OAuth = "oauth", + Pat = "pat" } export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub } & ( @@ -24,4 +25,12 @@ export type TGitHubConnection = TRootAppConnection & { app: AppConnection.GitHub host?: string; }; } + | { + method: GitHubConnectionMethod.Pat; + credentials: { + personalAccessToken: string; + instanceType?: "cloud" | "server"; + host?: string; + }; + } ); diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index fd840d5de..d82ad90ec 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -27,6 +27,7 @@ import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; import { TNetlifyConnection } from "./netlify-connection"; +import { TNorthflankConnection } from "./northflank-connection"; import { TOCIConnection } from "./oci-connection"; import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; @@ -66,6 +67,8 @@ export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; +export * from "./netlify-connection"; +export * from "./northflank-connection"; export * from "./oci-connection"; export * from "./okta-connection"; export * from "./oracledb-connection"; @@ -119,6 +122,7 @@ export type TAppConnection = | TSupabaseConnection | TDigitalOceanConnection | TNetlifyConnection + | TNorthflankConnection | TOktaConnection | TRedisConnection; diff --git a/frontend/src/hooks/api/appConnections/types/northflank-connection.ts b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts new file mode 100644 index 000000000..9e29693e4 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/northflank-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum NorthflankConnectionMethod { + ApiToken = "api-token" +} + +export type TNorthflankConnection = TRootAppConnection & { app: AppConnection.Northflank } & { + method: NorthflankConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 778656da4..16a350dbd 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -53,6 +53,6 @@ export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCert case CaStatus.DISABLED: return "danger"; default: - return "primary"; + return "warning"; } }; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index a9b6b7060..b5c53e11b 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { apiConfig?: { id: string; autoRenew: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; @@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = { }; apiConfig?: { autoRenew?: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; @@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = { }; apiConfig?: { autoRenew?: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index ddac04730..a60ebf91e 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,2 +1,8 @@ -export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations"; +export { + useDeleteCert, + useImportCertificate, + useRenewCertificate, + useRevokeCert, + useUpdateRenewalConfig +} from "./mutations"; export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 388295b0a..eed1d9e5f 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -9,7 +9,10 @@ import { TDeleteCertDTO, TImportCertificateDTO, TImportCertificateResponse, - TRevokeCertDTO + TRenewCertificateDTO, + TRenewCertificateResponse, + TRevokeCertDTO, + TUpdateRenewalConfigDTO } from "./types"; export const useDeleteCert = () => { @@ -77,3 +80,57 @@ export const useImportCertificate = () => { } }); }; + +export const useRenewCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ certificateId }) => { + const { data } = await apiRequest.post( + `/api/v3/certificates/${certificateId}/renew`, + {} + ); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); + queryClient.invalidateQueries({ + queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + if (data.projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(data.projectId) + }); + } + } + }); +}; + +export const useUpdateRenewalConfig = () => { + const queryClient = useQueryClient(); + return useMutation< + { message: string; renewBeforeDays?: number }, + object, + TUpdateRenewalConfigDTO + >({ + mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => { + const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>( + `/api/v3/certificates/${certificateId}/config`, + { renewBeforeDays, enableAutoRenewal } + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 1ec3292a3..622276e24 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -4,6 +4,7 @@ export type TCertificate = { id: string; caId: string; certificateTemplateId?: string; + profileId?: string; status: CertStatus; friendlyName: string; commonName: string; @@ -13,6 +14,12 @@ export type TCertificate = { notAfter: string; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; + renewBeforeDays?: number; + renewedBy?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; + renewalError?: string; + hasPrivateKey?: boolean; }; export type TDeleteCertDTO = { @@ -43,3 +50,24 @@ export type TImportCertificateResponse = { privateKey: string; serialNumber: string; }; + +export type TRenewCertificateDTO = { + certificateId: string; +}; + +export type TRenewCertificateResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; +}; + +export type TUpdateRenewalConfigDTO = { + certificateId: string; + renewBeforeDays?: number; + enableAutoRenewal?: boolean; + projectSlug: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 6167f6e07..e9e80feec 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -31,6 +31,7 @@ export * from "./pkiSubscriber"; export * from "./projects"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; +export * from "./relays"; export * from "./roles"; export * from "./scim"; export * from "./secretApproval"; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e4ce6824d..198bf887b 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -5,7 +5,8 @@ import { apiRequest } from "@app/config/request"; import { ExternalMigrationProviders, TVaultExternalMigrationConfig, - VaultKubernetesAuthRole + VaultKubernetesAuthRole, + VaultKubernetesRole } from "./types"; export const externalMigrationQueryKeys = { @@ -31,6 +32,11 @@ export const externalMigrationQueryKeys = { "vault-kubernetes-auth-roles", namespace, mountPath + ], + vaultKubernetesRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-roles", + namespace, + mountPath ] }; @@ -172,3 +178,30 @@ export const useGetVaultKubernetesAuthRoles = ( enabled: enabled && !!namespace && !!mountPath }); }; + +export const useGetVaultKubernetesRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultKubernetesRoles(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + roles: VaultKubernetesRole[]; + }>("/api/v3/external-migration/vault/kubernetes-roles", { + params: { + namespace, + mountPath + } + }); + + return data.roles; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index f4303ea26..4c101548c 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -49,3 +49,24 @@ export type VaultKubernetesAuthRole = { disable_local_ca_jwt?: boolean; }; }; + +export type VaultKubernetesRole = { + name: string; + mountPath: string; + allowed_kubernetes_namespaces?: string[]; + allowed_kubernetes_namespace_selector?: string; + token_max_ttl?: number; + token_default_ttl?: number; + token_default_audiences?: string[]; + service_account_name?: string; + kubernetes_role_name?: string; + kubernetes_role_type?: string; + generated_role_rules?: string; + name_template?: string; + extra_annotations?: Record; + extra_labels?: Record; + config: { + kubernetes_host: string; + kubernetes_ca_cert?: string; + }; +}; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index bbf73dd25..4340f9718 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -125,7 +125,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -146,7 +147,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index e9c36fada..b366277f1 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -29,6 +29,7 @@ export type Organization = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }; export type UpdateOrgDTO = { @@ -52,6 +53,7 @@ export type UpdateOrgDTO = { shareSecretsProductEnabled?: boolean; maxSharedSecretViewLimit?: number | null; maxSharedSecretLifetime?: number; + blockDuplicateSecretSyncDestinations?: boolean; }; export type BillingDetails = { @@ -66,6 +68,8 @@ export type PlanBillingInfo = { interval: "month" | "year"; intervalCount: number; quantity: number; + users: number; + identities: number; }; export type Invoice = { diff --git a/frontend/src/hooks/api/pam/constants.ts b/frontend/src/hooks/api/pam/constants.ts new file mode 100644 index 000000000..8cdbd3324 --- /dev/null +++ b/frontend/src/hooks/api/pam/constants.ts @@ -0,0 +1 @@ +export const UNCHANGED_PASSWORD_SENTINEL = "__INFISICAL_UNCHANGED__"; diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index b6e5ce64c..0684f6073 100644 --- a/frontend/src/hooks/api/pam/enums.ts +++ b/frontend/src/hooks/api/pam/enums.ts @@ -1,5 +1,6 @@ export enum PamResourceType { Postgres = "postgres", + MySQL = "mysql", RDP = "rdp", SSH = "ssh", Kubernetes = "kubernetes" diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts index a27507d7d..c240a12ad 100644 --- a/frontend/src/hooks/api/pam/maps.ts +++ b/frontend/src/hooks/api/pam/maps.ts @@ -5,6 +5,7 @@ export const PAM_RESOURCE_TYPE_MAP: Record< { name: string; image: string; size?: number } > = { [PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, + [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" } diff --git a/frontend/src/hooks/api/pam/queries.tsx b/frontend/src/hooks/api/pam/queries.tsx index 288d65ab9..6339b4761 100644 --- a/frontend/src/hooks/api/pam/queries.tsx +++ b/frontend/src/hooks/api/pam/queries.tsx @@ -3,6 +3,7 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { TPamResourceOption } from "./types/resource-options"; +import { PamResourceType } from "./enums"; import { TPamAccount, TPamFolder, TPamResource, TPamSession } from "./types"; export const pamKeys = { @@ -12,6 +13,12 @@ export const pamKeys = { session: () => [...pamKeys.all, "session"] as const, listResourceOptions: () => [...pamKeys.resource(), "options"] as const, listResources: (projectId: string) => [...pamKeys.resource(), "list", projectId], + getResource: (resourceType: string, resourceId: string) => [ + ...pamKeys.resource(), + "get", + resourceType, + resourceId + ], listAccounts: (projectId: string) => [...pamKeys.account(), "list", projectId], getSession: (sessionId: string) => [...pamKeys.session(), "get", sessionId], listSessions: (projectId: string) => [...pamKeys.session(), "list", projectId] @@ -68,6 +75,28 @@ export const useListPamResources = ( }); }; +export const useGetPamResourceById = ( + resourceType?: PamResourceType, + resourceId?: string, + options?: Omit< + UseQueryOptions>, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pamKeys.getResource(resourceType || "", resourceId || ""), + queryFn: async () => { + const { data } = await apiRequest.get<{ resource: TPamResource }>( + `/api/v1/pam/resources/${resourceType}/${resourceId}` + ); + + return data.resource; + }, + enabled: !!resourceId && !!resourceType && (options?.enabled ?? true), + ...options + }); +}; + // Accounts export const useListPamAccounts = ( projectId: string, diff --git a/frontend/src/hooks/api/pam/types/base-account.ts b/frontend/src/hooks/api/pam/types/base-account.ts index 9f45b1a4a..286c9389a 100644 --- a/frontend/src/hooks/api/pam/types/base-account.ts +++ b/frontend/src/hooks/api/pam/types/base-account.ts @@ -9,9 +9,15 @@ export interface TBasePamAccount { id: string; name: string; resourceType: PamResourceType; + rotationCredentialsConfigured: boolean; }; name: string; description?: string | null; + rotationEnabled: boolean; + rotationIntervalSeconds?: number | null; + lastRotatedAt?: string | null; + lastRotationMessage?: string | null; + rotationStatus?: string | null; createdAt: string; updatedAt: string; } diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 1b4acf6d4..1b1890cbd 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -1,11 +1,13 @@ import { PamResourceType, PamSessionStatus } from "../enums"; +import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; +export * from "./mysql-resource"; export * from "./postgres-resource"; -export type TPamResource = TPostgresResource; +export type TPamResource = TPostgresResource | TMySQLResource; -export type TPamAccount = TPostgresAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount; export type TPamFolder = { id: string; diff --git a/frontend/src/hooks/api/pam/types/mysql-resource.ts b/frontend/src/hooks/api/pam/types/mysql-resource.ts new file mode 100644 index 000000000..f1a7c09c8 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/mysql-resource.ts @@ -0,0 +1,14 @@ +import { PamResourceType } from "../enums"; +import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +// Resources +export type TMySQLResource = TBasePamResource & { resourceType: PamResourceType.MySQL } & { + connectionDetails: TBaseSqlConnectionDetails; +}; + +// Accounts +export type TMySQLAccount = TBasePamAccount & { + credentials: TBaseSqlCredentials; +}; diff --git a/frontend/src/hooks/api/pam/types/postgres-resource.ts b/frontend/src/hooks/api/pam/types/postgres-resource.ts index 513610be1..b1b5b7487 100644 --- a/frontend/src/hooks/api/pam/types/postgres-resource.ts +++ b/frontend/src/hooks/api/pam/types/postgres-resource.ts @@ -6,6 +6,7 @@ import { TBasePamResource } from "./base-resource"; // Resources export type TPostgresResource = TBasePamResource & { resourceType: PamResourceType.Postgres } & { connectionDetails: TBaseSqlConnectionDetails; + rotationAccountCredentials?: TBaseSqlCredentials | null; }; // Accounts diff --git a/frontend/src/hooks/api/pkiSubscriber/constants.tsx b/frontend/src/hooks/api/pkiSubscriber/constants.tsx index 1de5e9ddb..768df9f52 100644 --- a/frontend/src/hooks/api/pkiSubscriber/constants.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/constants.tsx @@ -15,6 +15,6 @@ export const getPkiSubscriberStatusBadgeVariant = (status: PkiSubscriberStatus) case PkiSubscriberStatus.DISABLED: return "danger"; default: - return "primary"; + return "warning"; } }; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index efcc04b6d..149759d33 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,6 +28,7 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", + Northflank = "northflank", Bitbucket = "bitbucket", LaravelForge = "laravel-forge" } diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 3cab195bd..bd2e5af4f 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -23,6 +23,7 @@ import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; +import { TNorthflankSync } from "./northflank-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; import { TRenderSync } from "./render-sync"; @@ -70,6 +71,7 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync + | TNorthflankSync | TBitbucketSync | TLaravelForgeSync; diff --git a/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts new file mode 100644 index 000000000..e1fff68fc --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/northflank-sync.ts @@ -0,0 +1,19 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TNorthflankSync = TRootSecretSync & { + destination: SecretSync.Northflank; + destinationConfig: { + projectId: string; + projectName?: string; + secretGroupId: string; + secretGroupName?: string; + }; + + connection: { + app: AppConnection.Northflank; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 98daf3ec1..80bea3db7 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -59,6 +59,7 @@ export type SubscriptionPlan = { enterpriseAppConnections: boolean; cardDeclined?: boolean; cardDeclinedReason?: string; + cardDeclinedDays?: number; machineIdentityAuthTemplates: boolean; pam: boolean; }; diff --git a/frontend/src/index.css b/frontend/src/index.css index 7c9b7db9b..2a1da3fa2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -37,12 +37,26 @@ } @theme { - /*legacy color schema */ /* Fonts */ --font-inter: "Inter", sans-serif; + --max-width-8xl: 88rem; /* 1408px */ + + /* Colors v2 */ + --color-background: #19191c; + --color-foreground: white; + --color-success: #2ecc71; + --color-info: #34c2db; + --color-warning: #f1c40f; + --color-danger: #e74c3c; + --color-org: #30B3FF; + --color-sub-org: #96ff59; + --color-project: #e0ed34; + --color-neutral: #adaeb0; + + /*legacy color schema */ --color-org-v1: #30B3FF; --color-namespace-v1: #96ff59; - --max-width-8xl: 88rem; /* 1408px */ + /* Primary */ --color-primary-50: #fffff5; --color-primary-100: #fcfce8; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index f9ef51133..3fb909c9b 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -7,15 +7,12 @@ import { faCaretDown, faCheck, faChevronRight, - faCubes, faEnvelope, faExclamationTriangle, - faGlobe, faInfinity, faInfo, faInfoCircle, faPlus, - faServer, faSignOut, faToolbox, faUser, @@ -30,7 +27,6 @@ import { Mfa } from "@app/components/auth/Mfa"; import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; import { - Badge, BreadcrumbContainer, Button, DropdownMenu, @@ -46,6 +42,7 @@ import { TBreadcrumbFormat, Tooltip } from "@app/components/v2"; +import { Badge, InstanceIcon, OrgIcon, SubOrgIcon } from "@app/components/v3"; import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; import { isInfisicalCloud } from "@app/helpers/platform"; @@ -144,12 +141,10 @@ export const Navbar = () => { enabled: Boolean(subscription.subOrganization) }); - useEffect(() => { - if (subscription?.cardDeclined && !sessionStorage.getItem("paymentFailed")) { - sessionStorage.setItem("paymentFailed", "true"); - setShowCardDeclinedModal(true); - } - }, [subscription]); + const isCardDeclined = Boolean(subscription?.cardDeclined); + const isCardDeclinedMoreThan30Days = Boolean( + isCardDeclined && subscription?.cardDeclinedDays && subscription?.cardDeclinedDays >= 30 + ); const { data: orgs } = useGetOrganizations(); const navigate = useNavigate(); @@ -161,6 +156,23 @@ export const Navbar = () => { const [isOrgSelectOpen, setIsOrgSelectOpen] = useState(false); const location = useLocation(); + const isBillingPage = location.pathname === "/organization/billing"; + + const isModalIntrusive = Boolean(!isBillingPage && isCardDeclinedMoreThan30Days); + + useEffect(() => { + if (isModalIntrusive) { + setShowCardDeclinedModal(true); + sessionStorage.setItem("paymentFailed", "true"); + return; + } + + if (isCardDeclined && !sessionStorage.getItem("paymentFailed")) { + sessionStorage.setItem("paymentFailed", "true"); + setShowCardDeclinedModal(true); + } + }, [subscription, isBillingPage, isModalIntrusive]); + const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; @@ -271,9 +283,7 @@ export const Navbar = () => { to="/admin" className="group flex cursor-pointer items-center gap-2 text-sm text-white transition-all duration-100 hover:text-primary" > -
- -
+
Server Console

/

@@ -288,24 +298,31 @@ export const Navbar = () => {
{ - navigate({ - to: "/organization/projects", - search: (search) => ({ ...search, subOrganization: undefined }) - }); - if (isSubOrganization) { - await router.invalidate({ sync: true }).catch(() => null); - } - }} + asChild variant="org" + isTruncatable + // TODO(scott): either add badge size/style variant or create designated component for namespace/org nav bar className={twMerge( - "max-w-full min-w-0 cursor-pointer text-sm", + "gap-x-1.5 text-sm", (!isOrgScope || isSubOrganization) && - "bg-transparent text-mineshaft-200 hover:bg-transparent hover:underline" + "bg-transparent text-mineshaft-200 hover:!bg-transparent hover:underline [&>svg]:!text-org" )} > - -

{currentOrg?.name}

+
{getPlan(subscription)} @@ -443,19 +460,22 @@ export const Navbar = () => { <>

/

- - - -

{currentOrg.subOrganization.name}

-
- + svg]:!text-sub-org" + )} + > + + + {currentOrg.subOrganization.name} + +
{ )} {user.superAdmin && !location.pathname.startsWith("/admin") && ( - + Server Console )} @@ -684,7 +704,10 @@ export const Navbar = () => { - + !isModalIntrusive && setShowCardDeclinedModal(false)} + > @@ -692,6 +715,7 @@ export const Navbar = () => { Your payment could not be processed.
} + showCloseButton={!isModalIntrusive} >
@@ -712,15 +736,16 @@ export const Navbar = () => { > Update Payment Method + + {!isModalIntrusive && ( - + )}
diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index f34c29d0d..5b74703dc 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -18,7 +18,10 @@ export const PamLayout = () => { useEffect(() => { if (subscription && !subscription.pam) { - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + description: "You can use PAM if you switch to Infisical's Enterprise plan.", + isEnterpriseFeature: true + }); } }, [subscription]); @@ -111,7 +114,8 @@ export const PamLayout = () => { onOpenChange={(isOpen) => { handlePopUpToggle("upgradePlan", isOpen); }} - text="You can use PAM if you switch to a paid Infisical plan." + text={popUp.upgradePlan.data?.description} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> ); diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index 72430fec6..c26333332 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -3,7 +3,6 @@ import { faStar } from "@fortawesome/free-regular-svg-icons"; import { faCaretDown, faCheck, - faCube, faMagnifyingGlass, faPlus, faStar as faSolidStar @@ -16,7 +15,6 @@ import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { NewProjectModal } from "@app/components/projects"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -25,6 +23,7 @@ import { Input, Tooltip } from "@app/components/v2"; +import { Badge, ProjectIcon } from "@app/components/v3"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -120,11 +119,9 @@ export const ProjectSelect = () => {

{currentWorkspace?.name}

- - - - {currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"} - + + + {currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"} diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index f3737a2dc..47a2333c7 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -4,7 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Badge, Tab, TabList, Tabs } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useProject, useProjectPermission } from "@app/context"; import { useGetAccessRequestsCount, @@ -88,7 +89,7 @@ export const SecretManagerLayout = () => { {Boolean( secretApprovalReqCount?.open || accessApprovalRequestCount?.pendingCount ) && ( - + {pendingRequestsCount} )} diff --git a/frontend/src/layouts/SecretScanningLayout/SecretScanningLayout.tsx b/frontend/src/layouts/SecretScanningLayout/SecretScanningLayout.tsx index 227f7ae70..0a1fd14f4 100644 --- a/frontend/src/layouts/SecretScanningLayout/SecretScanningLayout.tsx +++ b/frontend/src/layouts/SecretScanningLayout/SecretScanningLayout.tsx @@ -1,7 +1,8 @@ import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Badge, Tab, TabList, Tabs } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionSub, useProject, @@ -66,7 +67,7 @@ export const SecretScanningLayout = () => { Findings {Boolean(unresolvedFindings) && ( - + {unresolvedFindings} )} diff --git a/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx index 5b34aa029..7e207fbc5 100644 --- a/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/components/ServerAdminsTable.tsx @@ -7,16 +7,15 @@ import { faTrash, faUsers, faUserXmark, - faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AlertTriangleIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { - Badge, Button, Checkbox, DeleteActionModal, @@ -38,6 +37,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useSubscription, useUser } from "@app/context"; import { getUserTablePreference, @@ -451,7 +451,7 @@ export const ServerAdminsTable = () => { const email = user.email ?? user.username; return (
  • -
    +

    {user.firstName || user.lastName ? ( <> @@ -464,15 +464,10 @@ export const ServerAdminsTable = () => {

    {userId === user.id && ( -
    - - - Deleting Yourself - -
    + + + Deleting Yourself +
    )}
    diff --git a/frontend/src/pages/admin/CachingPage/components/CachingPageForm.tsx b/frontend/src/pages/admin/CachingPage/components/CachingPageForm.tsx index cac64d135..fbf956b7b 100644 --- a/frontend/src/pages/admin/CachingPage/components/CachingPageForm.tsx +++ b/frontend/src/pages/admin/CachingPage/components/CachingPageForm.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; -import { faRotate } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { RefreshCwIcon } from "lucide-react"; import { createNotification } from "@app/components/notifications"; -import { Badge, Button, DeleteActionModal } from "@app/components/v2"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useUser } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useInvalidateCache } from "@app/hooks/api"; @@ -62,11 +62,8 @@ export const CachingPageForm = () => {
    Secrets Cache {isInvalidating && ( - - + + Invalidating Cache )} diff --git a/frontend/src/pages/admin/EncryptionPage/components/EncryptionPageForm.tsx b/frontend/src/pages/admin/EncryptionPage/components/EncryptionPageForm.tsx index 69f4139a2..8372dd557 100644 --- a/frontend/src/pages/admin/EncryptionPage/components/EncryptionPageForm.tsx +++ b/frontend/src/pages/admin/EncryptionPage/components/EncryptionPageForm.tsx @@ -1,13 +1,13 @@ import { useCallback } from "react"; import { Controller, useForm } from "react-hook-form"; -import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { InfoIcon } from "lucide-react"; import { z } from "zod"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; -import { Badge, Button, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { Button, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useServerConfig, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; import { @@ -54,6 +54,7 @@ export const EncryptionPageForm = () => { if (!subscription.hsm) { handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true, description: "Hardware Security Module's (HSM's), are only available on Enterprise plans." }); return; @@ -132,12 +133,10 @@ export const EncryptionPageForm = () => { {config.fipsEnabled && ( -
    - - FIPS Mode: Enabled - - -
    + + FIPS Mode: Enabled + +
    )}
    @@ -146,6 +145,7 @@ export const EncryptionPageForm = () => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text={(popUp.upgradePlan?.data as { description: string })?.description} + isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature} /> ); diff --git a/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx index 59df496af..5f74593a3 100644 --- a/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx +++ b/frontend/src/pages/admin/EnvironmentPage/components/EnvironmentPageForm.tsx @@ -1,8 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Control, Controller, useForm, useWatch } from "react-hook-form"; import { - faArrowUpRightFromSquare, - faBookOpen, faChevronRight, faExclamationTriangle, faMagnifyingGlass @@ -14,6 +12,7 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, SecretInput, Tooltip } from "@app/components/v2"; import { HighlightText } from "@app/components/v2/HighlightText"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { useGetEnvOverrides, useUpdateServerConfig } from "@app/hooks/api"; type TForm = Record; @@ -213,22 +212,9 @@ export const EnvironmentPageForm = () => { >
    -
    +

    Overrides

    - -
    - - Docs - -
    -
    +

    Override specific environment variables. After saving, it may take up to 5 minutes for diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx index c493fe24d..9929d7500 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx @@ -7,10 +7,10 @@ import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ServerCogIcon } from "lucide-react"; import { createNotification } from "@app/components/notifications"; import { - Badge, DeleteActionModal, DropdownMenu, DropdownMenuContent, @@ -29,6 +29,7 @@ import { THead, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { getUserTablePreference, PreferenceKey, @@ -110,7 +111,8 @@ const IdentityPanelTable = ({ {name} {isInstanceAdmin && ( - + + Server Admin )} diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx index 9405505c8..fbb9462d0 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx @@ -3,7 +3,6 @@ import { faArrowDown, faArrowUp, faBuilding, - faCircleQuestion, faEllipsisV, faEnvelope, faEye, @@ -19,11 +18,11 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; +import { CircleQuestionMarkIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { - Badge, Button, DeleteActionModal, DropdownMenu, @@ -46,6 +45,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useUser } from "@app/context"; import { OrgMembershipRole } from "@app/helpers/roles"; import { @@ -295,15 +295,17 @@ const ViewMembersModalContent = ({ )}

    - - -

    {member.role.replace("-", " ")}

    - {Boolean(member.roleId) && ( - - - - )} -
    + +
    + + + {member.role.replace("-", " ")} + {Boolean(member.roleId) && } + + +
    diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx index 7b5b2c3d5..05d07d426 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx @@ -9,16 +9,15 @@ import { faUsers, faUserShield, faUserXmark, - faWarning, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AlertTriangleIcon, UserCogIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { - Badge, Button, Checkbox, DeleteActionModal, @@ -41,6 +40,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useSubscription, useUser } from "@app/context"; import { getUserTablePreference, @@ -218,7 +218,8 @@ const UserPanelTable = ({ {name ?? Not Set}

    {superAdmin && ( - + + Server Admin )} @@ -548,7 +549,7 @@ export const UserIdentitiesTable = () => { const email = user.email ?? user.username; return (
  • -
    +

    {user.firstName || user.lastName ? ( <> @@ -561,15 +562,10 @@ export const UserIdentitiesTable = () => {

    {userId === user.id && ( -
    - - - Deleting Yourself - -
    + + + Deleting Yourself +
    )}
    diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index a002a1716..ce873f70e 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -138,7 +138,7 @@ export const SignupInvitePage = () => { // Step 4 of the sign up process (download the emergency kit pdf) const stepConfirmEmail = ( -
    +

    Confirm your email

    @@ -179,7 +179,7 @@ export const SignupInvitePage = () => { // Because this is the invite signup - we directly go to the last step of signup (email is already verified) const main = ( -
    +

    Almost there!

    diff --git a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx index e9f65c4f6..c04b8525a 100644 --- a/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertAuthDetailsByIDPage/components/CaCertificatesSection/CaCertificatesTable.tsx @@ -7,7 +7,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -22,6 +21,7 @@ import { THead, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useGetCaCerts } from "@app/hooks/api"; @@ -60,7 +60,7 @@ export const CaCertificatesTable = ({ caId }: Props) => {
    CA Certificate {caCert.version} {isLastItem && ( - + Current )} diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx index 81fce98c6..09fe39af6 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx @@ -6,7 +6,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -22,6 +21,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api"; import { diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx index a948473e8..0f3fca6cd 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx @@ -10,7 +10,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -26,6 +25,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; import { CaStatus, CaType, useListExternalCasByProjectId } from "@app/hooks/api"; import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants"; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index c92844eed..dc0dffb0d 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -111,6 +111,7 @@ type TCertificateDetails = { export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => { const [certificateDetails, setCertificateDetails] = useState(null); + const [shouldShowSubjectSection, setShouldShowSubjectSection] = useState(true); const { currentProject } = useProject(); const inputSerialNumber = @@ -127,19 +128,9 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { mutateAsync: createCertificate } = useCreateCertificateV3(); - const selectedProfileId = useMemo(() => { - const form = document.querySelector('select[name="profileId"]') as HTMLSelectElement; - return form?.value || profileId || ""; - }, [profileId]); - - const selectedProfile = useMemo( - () => profilesData?.certificateProfiles?.find((p) => p.id === selectedProfileId), - [profilesData?.certificateProfiles, selectedProfileId] - ); - - const { data: templateData } = useGetCertificateTemplateV2ById({ - templateId: selectedProfile?.certificateTemplateId || "" - }); + const formResolver = useMemo(() => { + return zodResolver(createSchema(shouldShowSubjectSection)); + }, [shouldShowSubjectSection]); const { control, @@ -150,7 +141,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } formState, formState: { isSubmitting } } = useForm({ - resolver: zodResolver(createSchema((templateData?.subject?.length || 0) > 0)), + resolver: formResolver, defaultValues: { profileId: profileId || "", subjectAttributes: [], @@ -169,6 +160,16 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } [profilesData?.certificateProfiles, actualSelectedProfileId] ); + const { data: templateData } = useGetCertificateTemplateV2ById({ + templateId: actualSelectedProfile?.certificateTemplateId || "" + }); + + useEffect(() => { + if (templateData !== undefined) { + setShouldShowSubjectSection((templateData?.subject?.length || 0) > 0); + } + }, [templateData]); + const { constraints, filteredKeyUsages, @@ -186,6 +187,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const resetAllState = useCallback(() => { setCertificateDetails(null); + setShouldShowSubjectSection(true); resetConstraints(); reset(); }, [reset, resetConstraints]); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx new file mode 100644 index 000000000..d6199678a --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const DEFAULT_RENEWAL_BEFORE_DAYS = 20; +const MIN_RENEWAL_BEFORE_DAYS = 1; +const MAX_RENEWAL_BEFORE_DAYS = 30; + +const createFormSchema = (ttlDays: number, notAfter: string) => + z.object({ + renewBeforeDays: z + .number() + .min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`) + .max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`) + .refine( + (value) => value < ttlDays, + (value) => ({ + message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)` + }) + ) + .refine( + (value) => { + const expiryDate = new Date(notAfter); + const renewalDate = new Date(expiryDate.getTime() - value * 24 * 60 * 60 * 1000); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return renewalDate >= tomorrow; + }, + () => ({ + message: "Renewals can only be scheduled from tomorrow onwards." + }) + ) + }); + +type FormData = z.infer>; + +type Props = { + popUp: UsePopUpState<["manageRenewal"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void; +}; + +const RenewalConfigForm = ({ + control, + errors, + onSubmit, + isLoading, + buttonText, + onCancel +}: { + control: any; + errors: { renewBeforeDays?: { message?: string } }; + onSubmit: (e?: React.BaseSyntheticEvent) => Promise; + isLoading: boolean; + buttonText: string; + onCancel: () => void; +}) => ( +
    + + ( + { + const value = parseInt(e.target.value, 10); + field.onChange(value); + }} + placeholder="Enter days before expiration" + /> + )} + /> + + +
    + + +
    +
    +); + +export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } = + useUpdateRenewalConfig(); + + const certificateData = popUp.manageRenewal.data as { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays?: number; + ttlDays?: number; + notAfter: string; + renewalError?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; + }; + + const { data: profileData } = useGetCertificateProfileById({ + profileId: certificateData?.profileId || "" + }); + + const defaultRenewalDays = useMemo(() => { + if (certificateData?.renewBeforeDays) { + return certificateData.renewBeforeDays; + } + if (profileData?.apiConfig?.renewBeforeDays) { + return profileData.apiConfig.renewBeforeDays; + } + return DEFAULT_RENEWAL_BEFORE_DAYS; + }, [certificateData?.renewBeforeDays, profileData?.apiConfig?.renewBeforeDays]); + + const isAutoRenewalEnabled = Boolean( + certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0 + ); + + const hasRenewalError = Boolean(certificateData?.renewalError); + + const formSchema = createFormSchema( + certificateData?.ttlDays || 365, + certificateData?.notAfter || "" + ); + + const { + control, + handleSubmit, + formState: { errors }, + reset + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + renewBeforeDays: defaultRenewalDays + } + }); + + useEffect(() => { + if (popUp.manageRenewal.isOpen) { + reset({ + renewBeforeDays: defaultRenewalDays + }); + } + }, [popUp.manageRenewal.isOpen, defaultRenewalDays, reset]); + + const onUpdateRenewal = async (data: FormData) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId: certificateData.certificateId, + renewBeforeDays: data.renewBeforeDays, + projectSlug: currentProject.slug + }); + + createNotification({ + text: isAutoRenewalEnabled + ? "Auto-renewal configuration updated successfully" + : "Auto-renewal enabled successfully", + type: "success" + }); + + handlePopUpToggle("manageRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: isAutoRenewalEnabled + ? "Failed to update auto-renewal configuration. Please check your inputs and try again." + : "Failed to enable auto-renewal. Please check your inputs and try again.", + type: "error" + }); + } + }; + + const getModalTitle = () => { + if (hasRenewalError) { + return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`; + } + if (isAutoRenewalEnabled) { + return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`; + } + return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`; + }; + + if (!certificateData) { + return null; + } + + return ( + { + handlePopUpToggle("manageRenewal", isOpen); + }} + > + + {hasRenewalError && ( +
    +
    +
    + ! +
    +
    +

    Automatic Renewal Failed

    +

    + The last automatic renewal attempt failed: {certificateData.renewalError} +

    +

    + You can reconfigure auto-renewal below or disable it completely. +

    +
    +
    +
    + )} + + {(!isAutoRenewalEnabled || hasRenewalError) && ( + handlePopUpToggle("manageRenewal", false)} + /> + )} + + {isAutoRenewalEnabled && !hasRenewalError && ( + handlePopUpToggle("manageRenewal", false)} + /> + )} +
    +
    + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx new file mode 100644 index 000000000..c952f1e54 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx @@ -0,0 +1,178 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const createFormSchema = (ttlDays: number) => + z.object({ + renewBeforeDays: z + .number() + .min(1, "Renewal days must be at least 1") + .max(365, "Renewal days cannot exceed 365") + .refine( + (value) => value < ttlDays, + (value) => ({ + message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)` + }) + ) + }); + +type FormData = z.infer>; + +type Props = { + popUp: UsePopUpState<["configureRenewal"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["configureRenewal"]>, + state?: boolean + ) => void; +}; + +export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig(); + + const certificateData = popUp.configureRenewal.data as { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays?: number; + ttlDays: number; + }; + + const formSchema = createFormSchema(certificateData.ttlDays); + + const { + control, + handleSubmit, + formState: { errors }, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + renewBeforeDays: certificateData?.renewBeforeDays || 1 + } + }); + + const renewBeforeDays = watch("renewBeforeDays"); + + const onSubmit = async (data: FormData) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Project not found", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId: certificateData.certificateId, + renewBeforeDays: data.renewBeforeDays, + projectSlug: currentProject.slug + }); + + createNotification({ + text: "Successfully updated auto-renewal configuration", + type: "success" + }); + + handlePopUpToggle("configureRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update auto-renewal configuration", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("configureRenewal", isOpen); + }} + > + +
    +
    +

    + Configure when this certificate should be automatically renewed. The certificate will + be renewed when it has the specified number of days remaining before expiration. +

    + +
    +

    + Certificate TTL: {certificateData?.ttlDays} days +

    +

    + Current Setting:{" "} + {certificateData?.renewBeforeDays + ? `${certificateData.renewBeforeDays} days before expiration` + : "Disabled"} +

    +
    + + ( + + { + const value = parseInt(e.target.value, 10); + field.onChange(Number.isNaN(value) ? 0 : value); + }} + /> + + )} + /> + + {renewBeforeDays && certificateData?.ttlDays && ( +
    +

    + {renewBeforeDays >= certificateData.ttlDays + ? "⚠️ Renewal days must be less than certificate TTL" + : `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`} +

    +
    + )} +
    + +
    + + +
    +
    +
    +
    + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx new file mode 100644 index 000000000..613080cd7 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx @@ -0,0 +1,94 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["disableRenewal"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void; +}; + +export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig(); + + const certificateData = popUp.disableRenewal.data as { + certificateId: string; + commonName: string; + }; + + const onDisableConfirm = async () => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Project not found", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId: certificateData.certificateId, + projectSlug: currentProject.slug, + enableAutoRenewal: false + }); + + createNotification({ + text: "Successfully disabled auto-renewal", + type: "success" + }); + + handlePopUpToggle("disableRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to disable auto-renewal", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("disableRenewal", isOpen); + }} + > + +
    +

    + Are you sure you want to disable auto-renewal for this certificate? +

    +
    +

    + Warning: Once disabled, this certificate will not be automatically + renewed and may expire without notice. You can re-enable auto-renewal at any time. +

    +
    +
    + +
    + + +
    +
    +
    + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx new file mode 100644 index 000000000..0e2b1c17d --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx @@ -0,0 +1,80 @@ +import { faRedo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { useRenewCertificate } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["renewCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["renewCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { + const { mutateAsync: renewCertificate, isPending: isRenewing } = useRenewCertificate(); + + const onRenewConfirm = async () => { + try { + const { certificateId } = popUp.renewCertificate.data as { certificateId: string }; + + await renewCertificate({ + certificateId + }); + + createNotification({ + text: "Certificate renewed successfully", + type: "success" + }); + + handlePopUpToggle("renewCertificate", false); + } catch (err) { + console.error(err); + } + }; + + const certificateData = popUp.renewCertificate.data as { + certificateId: string; + commonName: string; + profileId: string; + }; + + return ( + { + handlePopUpToggle("renewCertificate", isOpen); + }} + > + +
    +

    + Are you sure you want to renew this certificate now? +

    +
    + +
    + + +
    +
    +
    + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx index c4d25a6ad..629553200 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx @@ -104,6 +104,7 @@ export const CertificateTemplatesSection = ({ caId }: Props) => { handlePopUpToggle("upgradePlan", isOpen)} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan." />
    diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesTable.tsx index e7d137807..c36618309 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesTable.tsx @@ -36,6 +36,7 @@ type Props = { data?: { id?: string; name?: string; + isEnterpriseFeature?: boolean; } ) => void; }; @@ -90,7 +91,9 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => { { if (!subscription?.pkiEst) { - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true + }); return; } diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index bce21123b..4102d8ea5 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; +import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal"; import { CertificateModal } from "./CertificateModal"; +import { CertificateRenewalModal } from "./CertificateRenewalModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; @@ -33,7 +35,9 @@ export const CertificatesSection = () => { "certificateImport", "certificateCert", "deleteCertificate", - "revokeCertificate" + "revokeCertificate", + "manageRenewal", + "renewCertificate" ] as const); const onRemoveCertificateSubmit = async (serialNumber: string) => { @@ -98,6 +102,8 @@ export const CertificatesSection = () => { )} + + { + const expiryDate = new Date(notAfter); + const now = new Date(); + const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + return expiryDate <= oneDayFromNow; +}; + +const getAutoRenewalInfo = (certificate: TCertificate) => { + if (certificate.renewedByCertificateId) { + return { text: "Renewed", variant: "neutral" as const }; + } + + const isRevoked = certificate.status === CertStatus.REVOKED; + const isExpired = new Date(certificate.notAfter) < new Date(); + const hasNoProfile = !certificate.profileId; + const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); + + if (isRevoked) { + return { + text: "Not Available", + variant: "neutral" as const, + tooltip: "Renewal is not available for revoked certificates" + }; + } + + if (isExpired) { + return { + text: "Not Available", + variant: "neutral" as const, + tooltip: "Renewal is not available for expired certificates" + }; + } + + if (hasNoProfile) { + return { + text: "Not Available", + variant: "neutral" as const, + tooltip: "Renewal requires a certificate profile" + }; + } + + if (certificate.hasPrivateKey === false) { + return { + text: "Not Available", + variant: "neutral" as const, + tooltip: "Renewal is not available for certificates with externally generated private keys" + }; + } + + if (isExpiringWithinDay) { + return { + text: "Not Available", + variant: "neutral" as const, + tooltip: "Auto-renewal is not available for certificates expiring within 24 hours" + }; + } + + if (certificate.renewalError) { + return { + text: "Failed", + variant: "danger" as const, + tooltip: certificate.renewalError + }; + } + + if (!certificate.renewBeforeDays) { + return { text: "Auto-Renewal Disabled", variant: "warning" as const }; + } + + const notAfterDate = new Date(certificate.notAfter); + const renewalDate = new Date( + notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000 + ); + const now = new Date(); + + if (renewalDate <= now) { + return { text: "Due Now", variant: "danger" as const }; + } + + const daysUntilRenewal = Math.floor( + (renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) + ); + + if (daysUntilRenewal === 0) { + return { text: "Renews today", variant: "warning" as const }; + } + + if (daysUntilRenewal <= 7) { + return { text: `Renews in ${daysUntilRenewal}d`, variant: "warning" as const }; + } + + return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const }; +}; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< - ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] + [ + "certificate", + "deleteCertificate", + "revokeCertificate", + "certificateCert", + "manageRenewal", + "renewCertificate" + ] >, data?: { serialNumber?: string; commonName?: string; + certificateId?: string; + profileId?: string; + renewBeforeDays?: number; + ttlDays?: number; + notAfter?: string; + renewalError?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; } ) => void; }; @@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25; export const CertificatesTable = ({ handlePopUpOpen }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); + const { subscription } = useSubscription(); const { currentProject } = useProject(); const { data, isPending } = useListWorkspaceCertificates({ @@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { limit: perPage }); - // Fetch CA data to determine capabilities + const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig(); + const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); - // Create mapping from caId to CA type for capability checking const caCapabilityMap = useMemo(() => { if (!caData) return {}; @@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { return map; }, [caData]); + const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Unable to disable auto-renewal: Project not found. Please refresh the page and try again.", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId, + projectSlug: currentProject.slug, + enableAutoRenewal: false + }); + + createNotification({ + text: `Auto-renewal disabled for ${commonName}`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.", + type: "error" + }); + } + }; + return ( @@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + - {isPending && } + {isPending && } {!isPending && data?.certificates.map((certificate) => { const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); + const autoRenewalInfo = getAutoRenewalInfo(certificate); + + const isRevoked = certificate.status === CertStatus.REVOKED; + const isExpired = new Date(certificate.notAfter) < new Date(); + const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); + const hasFailed = Boolean(certificate.renewalError); + const isAutoRenewalEnabled = Boolean( + certificate.renewBeforeDays && certificate.renewBeforeDays > 0 + ); return ( @@ -120,6 +275,21 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ? format(new Date(certificate.notAfter), "yyyy-MM-dd") : "-"} + diff --git a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretsRow.tsx b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretsRow.tsx index 2394253e7..d4b40723b 100644 --- a/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretsRow.tsx +++ b/frontend/src/pages/organization/SecretSharingPage/components/ShareSecret/ShareSecretsRow.tsx @@ -3,7 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { format } from "date-fns"; import { IconButton, Td, Tooltip, Tr } from "@app/components/v2"; -import { Badge } from "@app/components/v2/Badge"; +import { Badge } from "@app/components/v3"; import { TSharedSecret } from "@app/hooks/api/secretSharing"; import { UsePopUpState } from "@app/hooks/usePopUp"; diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx index 7d2868dcc..e60f09c4a 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamForm/CustomProviderAuditLogStreamForm.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"; import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -29,6 +30,8 @@ const formSchema = z.object({ type FormData = z.infer; export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: Props) => { + const [showPassword, setShowPassword] = useState(false); + const isUpdate = Boolean(auditLogStream); const form = useForm({ @@ -96,10 +99,10 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P > { + placeholder="Bearer " + onFocus={() => { if ( auditLogStream && auditLogStream.credentials.headers[i] && @@ -108,9 +111,9 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P ) { field.onChange(""); } - e.target.type = "text"; + setShowPassword(true); }} - onBlur={(e) => { + onBlur={() => { if ( auditLogStream && auditLogStream.credentials.headers[i] && @@ -119,7 +122,7 @@ export const CustomProviderAuditLogStreamForm = ({ auditLogStream, onSubmit }: P ) { field.onChange("******"); } - e.target.type = "password"; + setShowPassword(false); }} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx index f4f7932a4..2ccdfad61 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx @@ -29,7 +29,9 @@ export const AuditLogStreamsTab = withPermission( + + + + + + + ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index ead2b06e1..b9599be2d 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -8,6 +8,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamAccountHeader } from "../PamAccountHeader"; +import { MySQLAccountForm } from "./MySQLAccountForm"; import { PostgresAccountForm } from "./PostgresAccountForm"; type FormProps = { @@ -34,8 +35,6 @@ const CreateForm = ({ }: CreateFormProps) => { const createPamAccount = useCreatePamAccount(); - console.log({ folderId }); - const onSubmit = async ( formData: DiscriminativePick ) => { @@ -64,7 +63,17 @@ const CreateForm = ({ switch (resourceType) { case PamResourceType.Postgres: - return ; + return ( + + ); + case PamResourceType.MySQL: + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -100,6 +109,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { switch (account.resource.resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: throw new Error(`Unhandled resource: ${account.resource.resourceType}`); } diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index 5bcd459aa..e7d2d902b 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -1,26 +1,36 @@ +import { useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; -import { TPostgresAccount } from "@app/hooks/api/pam"; +import { + PamResourceType, + TPostgresAccount, + TPostgresResource, + useGetPamResourceById +} from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; import { SqlAccountFields } from "./shared/SqlAccountFields"; import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; type Props = { account?: TPostgresAccount; + resourceId?: string; + resourceType?: PamResourceType; onSubmit: (formData: FormData) => Promise; }; -const formSchema = genericAccountFieldsSchema.extend({ +const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({ credentials: BaseSqlAccountSchema }); type FormData = z.infer; -export const PostgresAccountForm = ({ account, onSubmit }: Props) => { +export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => { const isUpdate = Boolean(account); const form = useForm({ @@ -30,7 +40,7 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => { ...account, credentials: { ...account.credentials, - password: "******" + password: UNCHANGED_PASSWORD_SENTINEL } } : undefined @@ -41,6 +51,22 @@ export const PostgresAccountForm = ({ account, onSubmit }: Props) => { formState: { isSubmitting, isDirty } } = form; + const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false); + + const { data: resource } = useGetPamResourceById(resourceType, resourceId, { + enabled: !account && !!resourceId && !!resourceType + }); + + useEffect(() => { + if (account) { + setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured); + } else { + setRotationCredentialsConfigured( + !!(resource as TPostgresResource)?.rotationAccountCredentials + ); + } + }, [account, resource]); + return (
    { > +
    diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamResourceOption.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamResourceOption.tsx index 06c04e019..278ac3a62 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamResourceOption.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamResourceOption.tsx @@ -3,7 +3,7 @@ import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Badge } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { PAM_RESOURCE_TYPE_MAP, PamResourceType } from "@app/hooks/api/pam"; export const PamResourceOption = ({ @@ -27,7 +27,7 @@ export const PamResourceOption = ({ <>

    {children}

    - + {`${name} Promise; +}; + +const formSchema = genericResourceFieldsSchema.extend({ + resourceType: z.literal(PamResourceType.MySQL), + connectionDetails: BaseSqlResourceSchema.extend({ + database: z.string().trim().optional().default("") + }) +}); + +type FormData = z.infer; + +export const MySQLResourceForm = ({ resource, onSubmit }: Props) => { + const isUpdate = Boolean(resource); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource ?? { + resourceType: PamResourceType.MySQL, + connectionDetails: { + host: "", + port: 3306, + database: "", + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + + { + setSelectedTabIndex(0); + handleSubmit(onSubmit)(e); + }} + > + + +
    + + + + +
    + +
    + ); +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx index 8cfdc8582..2bc54e7cd 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx @@ -9,6 +9,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamResourceHeader } from "../PamResourceHeader"; +import { MySQLResourceForm } from "./MySQLResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm"; type FormProps = { @@ -57,6 +58,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) => switch (resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -92,8 +95,10 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => { switch (resource.resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: - throw new Error(`Unhandled resource: ${resource.resourceType}`); + throw new Error(`Unhandled resource: ${(resource as any).resourceType}`); } }; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx index a3aba3b67..7e96fffda 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PostgresResourceForm.tsx @@ -5,9 +5,12 @@ import { z } from "zod"; import { Button, ModalClose } from "@app/components/v2"; import { PamResourceType, TPostgresResource } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; +import { BaseSqlAccountSchema } from "@app/pages/pam/PamAccountsPage/components/PamAccountForm/shared/sql-account-schemas"; import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas"; import { SqlResourceFields } from "./shared/SqlResourceFields"; +import { SqlRotateAccountFields } from "./shared/SqlRotateAccountFields"; import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields"; type Props = { @@ -17,7 +20,8 @@ type Props = { const formSchema = genericResourceFieldsSchema.extend({ resourceType: z.literal(PamResourceType.Postgres), - connectionDetails: BaseSqlResourceSchema + connectionDetails: BaseSqlResourceSchema, + rotationAccountCredentials: BaseSqlAccountSchema.nullable().optional() }); type FormData = z.infer; @@ -28,17 +32,27 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => { const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: resource ?? { - resourceType: PamResourceType.Postgres, - connectionDetails: { - host: "", - port: 5432, - database: "default", - sslEnabled: true, - sslRejectUnauthorized: true, - sslCertificate: undefined - } - } + defaultValues: resource + ? { + ...resource, + rotationAccountCredentials: resource.rotationAccountCredentials + ? { + ...resource.rotationAccountCredentials, + password: UNCHANGED_PASSWORD_SENTINEL + } + : resource.rotationAccountCredentials + } + : { + resourceType: PamResourceType.Postgres, + connectionDetails: { + host: "", + port: 5432, + database: "default", + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } }); const { @@ -59,6 +73,7 @@ export const PostgresResourceForm = ({ resource, onSubmit }: Props) => { selectedTabIndex={selectedTabIndex} setSelectedTabIndex={setSelectedTabIndex} /> +
    Status Not Before Not AfterRenewal Status
    {certificate.commonName} + {autoRenewalInfo && + (autoRenewalInfo.tooltip ? ( +
    + + + {autoRenewalInfo.text} + + + +
    + ) : ( + {autoRenewalInfo.text} + ))} +
    @@ -151,31 +321,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { )} - - {(isAllowed) => ( - - handlePopUpOpen("certificate", { - serialNumber: certificate.serialNumber - }) - } - disabled={!isAllowed} - icon={} + {isLegacyTemplatesEnabled && ( + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + )} + {/* Manage auto renewal option - not shown for failed renewals */} + {(() => { + const canManageRenewal = + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !hasFailed && + !isExpiringWithinDay; + + if (!canManageRenewal) return null; + + return ( + - View Details - - )} - + {(isAllowed) => { + return ( + { + const notAfterDate = new Date(certificate.notAfter); + const notBeforeDate = certificate.notBefore + ? new Date(certificate.notBefore) + : new Date( + notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000 + ); + const ttlDays = Math.max( + 1, + Math.ceil( + (notAfterDate.getTime() - notBeforeDate.getTime()) / + (24 * 60 * 60 * 1000) + ) + ); + handlePopUpOpen("manageRenewal", { + certificateId: certificate.id, + commonName: certificate.commonName, + profileId: certificate.profileId, + renewBeforeDays: certificate.renewBeforeDays, + ttlDays, + notAfter: certificate.notAfter, + renewalError: certificate.renewalError, + renewedFromCertificateId: + certificate.renewedFromCertificateId, + renewedByCertificateId: certificate.renewedByCertificateId + }); + }} + disabled={!isAllowed} + icon={} + > + {isAutoRenewalEnabled + ? "Manage auto renewal" + : "Enable auto renewal"} + + ); + }} + + ); + })()} + {/* Disable auto renewal option - only shown when auto renewal is active */} + {(() => { + const canDisableRenewal = + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !isExpiringWithinDay && + isAutoRenewalEnabled; + + if (!canDisableRenewal) return null; + + return ( + + {(isAllowed) => ( + { + await handleDisableAutoRenewal( + certificate.id, + certificate.commonName + ); + }} + disabled={!isAllowed} + icon={} + > + Disable auto renewal + + )} + + ); + })()} + {/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */} + {(() => { + const canRenew = + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired; + + if (!canRenew) return null; + + return ( + + {(isAllowed) => ( + { + handlePopUpOpen("renewCertificate", { + certificateId: certificate.id, + commonName: certificate.commonName + }); + }} + disabled={!isAllowed} + icon={} + > + Renew Now + + )} + + ); + })()} {/* Only show revoke button if CA supports revocation */} {(() => { const caType = caCapabilityMap[certificate.caId]; - // If caId not found in map, assume CA supports revocation to avoid hiding revoke option const supportsRevocation = !caType || caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.utils.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.utils.ts index 93b408bbe..2ac7ec9ce 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.utils.ts +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.utils.ts @@ -5,13 +5,13 @@ export const getCertValidUntilBadgeDetails = (notAfter: string) => { const notAfterDate = new Date(notAfter).getTime(); const diffInMs = notAfterDate - currentDate; - let variant: "success" | "primary" | "danger" = "success"; + let variant: "success" | "warning" | "danger" = "success"; let label = "Healthy"; if (diffInMs > ms("60d")) { variant = "success"; } else if (diffInMs > ms("30d")) { - variant = "primary"; + variant = "warning"; } else { variant = "danger"; } diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts index 871ad00a4..5499a2762 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -11,6 +11,26 @@ import { mapTemplateSignatureAlgorithmToApi } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; +const convertTemplateTtlToCertificateTtl = (templateTtl: string): string => { + const match = templateTtl.match(/^(\d+)([dmyh])$/); + if (!match) return templateTtl; + + const [, value, unit] = match; + const numValue = parseInt(value, 10); + + switch (unit) { + case "m": + return `${numValue * 30}d`; + case "y": + return `${numValue * 365}d`; + case "d": + case "h": + return templateTtl; + default: + return templateTtl; + } +}; + export type TemplateConstraints = { allowedKeyUsages: string[]; allowedExtendedKeyUsages: string[]; @@ -118,7 +138,7 @@ export const useCertificateTemplate = ( // Set TTL if available if (templateData.validity?.max) { - setValue("ttl", templateData.validity.max); + setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max)); } // Handle SAN types diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx index eb16166fc..1c5bff98c 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo } from "react"; import { subject } from "@casl/ability"; import { - faBan, faCalendarCheck, faCheck, faCopy, @@ -19,6 +18,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; +import { BanIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -29,7 +29,6 @@ import { PkiSyncStatusBadge } from "@app/components/pki-syncs"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -39,6 +38,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; @@ -172,10 +172,7 @@ export const PkiSyncRow = ({
    - + Source Deleted @@ -234,12 +231,10 @@ export const PkiSyncRow = ({ className="text-xs" content="Auto-Sync is disabled. Certificate changes in the PKI subscriber will not be automatically synced to the destination." > -
    - - - {!syncStatus && "Auto-Sync Disabled"} - -
    + + + {!syncStatus && "Auto-Sync Disabled"} + )} {syncOption?.canImportCertificates && } diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx index 564eeb789..1c1141382 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useMemo } from "react"; -import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { ProjectPermissionCan } from "@app/components/permissions"; import { CreatePkiSyncModal } from "@app/components/pki-syncs"; import { Button, Spinner } from "@app/components/v2"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionSub, useProject } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; @@ -64,21 +65,10 @@ export const PkiSyncsTab = () => {
    -

    Certificate Syncs

    - -
    - - Docs - -
    -
    +
    +

    Certificate Syncs

    + +

    Use App Connections to sync certificates to third-party services. diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx index d3533d71d..129af29d3 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberCertificatesTable.tsx @@ -7,7 +7,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -24,6 +23,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, @@ -93,7 +93,7 @@ export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen } if (daysUntilExpiry < 30) { - return Expiring Soon; + return Expiring Soon; } return Valid; diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx index d28c2d3a2..89dfd5f02 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscribersTable.tsx @@ -11,7 +11,6 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -27,6 +26,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx index 462678280..bb8e9d02a 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx @@ -1,7 +1,6 @@ import { useCallback } from "react"; import { subject } from "@casl/ability"; import { - faBan, faCheck, faCopy, faDownload, @@ -15,6 +14,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; +import { BanIcon, RefreshCwIcon } from "lucide-react"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; @@ -26,7 +26,6 @@ import { PkiSyncRemoveStatusBadge } from "@app/components/pki-syncs"; import { - Badge, Button, DropdownMenu, DropdownMenuContent, @@ -35,6 +34,7 @@ import { IconButton, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; @@ -136,24 +136,19 @@ export const PkiSyncActionTriggers = ({ pkiSync }: Props) => { {syncOption?.canImportCertificates && } {pkiSync.isAutoSyncEnabled ? ( - - - Auto-Sync Enabled + + + Auto-Sync Enabled ) : ( -

    - - - Auto-Sync Disabled - -
    + + + Auto-Sync Disabled + )}
    diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx index 3af7bf189..b6365f1cd 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx @@ -4,7 +4,8 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; import { GenericFieldLabel } from "@app/components/secret-syncs"; -import { Badge, IconButton } from "@app/components/v2"; +import { IconButton } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { TPkiSync } from "@app/hooks/api/pkiSyncs"; diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx index 5885795e2..c9dc041df 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx @@ -1,10 +1,12 @@ /* eslint-disable jsx-a11y/label-has-associated-control */ import { subject } from "@casl/ability"; -import { faEdit, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AlertTriangleIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { Badge, IconButton, Tooltip } from "@app/components/v2"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { TPkiSync } from "@app/hooks/api/pkiSyncs"; @@ -36,15 +38,10 @@ export const PkiSyncSourceSection = ({ pkiSync, onEditSource }: Props) => {
    {!subscriberId && ( -
    - - - Source Deleted - -
    + + + Source Deleted +
    )} diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx index 440ebb481..b395d0ec2 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx @@ -203,7 +203,9 @@ export const PkiTemplateListPage = () => { onClick={(e) => { e.stopPropagation(); if (!subscription.pkiEst) { - handlePopUpOpen("estUpgradePlan"); + handlePopUpOpen("estUpgradePlan", { + isEnterpriseFeature: true + }); return; } handlePopUpOpen("enrollmentOptions", { @@ -296,6 +298,7 @@ export const PkiTemplateListPage = () => { isOpen={popUp.estUpgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("estUpgradePlan", isOpen)} text="You can only configure template enrollment methods if you switch to Infisical's Enterprise plan." + isEnterpriseFeature={popUp.estUpgradePlan.data?.isEnterpriseFeature} /> ); 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 3f7553f83..ea82fd843 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -13,7 +15,8 @@ import { ModalContent, Select, SelectItem, - TextArea + TextArea, + Tooltip } from "@app/components/v2"; import { useProject } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; @@ -67,7 +70,7 @@ const createSchema = z apiConfig: z .object({ autoRenew: z.boolean().optional(), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(365).optional() }) .optional() }) @@ -115,7 +118,7 @@ const editSchema = z apiConfig: z .object({ autoRenew: z.boolean().optional(), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(365).optional() }) .optional() }) @@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } profile.enrollmentType === "api" ? { autoRenew: profile.apiConfig?.autoRenew || false, - autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined } @@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } certificateTemplateId: "", apiConfig: { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 } } }); @@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } profile.enrollmentType === "api" ? { autoRenew: profile.apiConfig?.autoRenew || false, - autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined }); @@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } } else { setValue("apiConfig", { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 }); setValue("estConfig", undefined); } @@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } setValue("estConfig", undefined); setValue("apiConfig", { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 }); } onChange(value); @@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="apiConfig.autoRenew" render={({ field: { value, onChange }, fieldState: { error } }) => ( - - Enable Auto-Renewal - +
    + + Enable Auto-Renewal By Default + + + + +
    )} /> @@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
    ( diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 27a1e7cd1..ab7eb549e 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -12,7 +12,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -21,6 +20,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useProjectPermission } from "@app/context"; import { ProjectPermissionActions, @@ -45,42 +45,26 @@ const MetricsBadges = ({ }; }) => { if (!metrics) { - return ( - - No metrics - - ); + return No metrics; } if (metrics.totalCertificates === 0) { - return ( - - No certificates - - ); + return No certificates; } return ( <> {metrics.activeCertificates > 0 && ( - - {metrics.activeCertificates} active - + {metrics.activeCertificates} active )} {metrics.expiringCertificates > 0 && ( - - {metrics.expiringCertificates} expiring - + {metrics.expiringCertificates} expiring )} {metrics.expiredCertificates > 0 && ( - - {metrics.expiredCertificates} expired - + {metrics.expiredCertificates} expired )} {metrics.revokedCertificates > 0 && ( - - {metrics.revokedCertificates} revoked - + {metrics.revokedCertificates} revoked )} ); @@ -135,7 +119,7 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = const getEnrollmentTypeBadge = (enrollmentType: string) => { const config = { api: { variant: "success" as const, label: "API" }, - est: { variant: "primary" as const, label: "EST" } + est: { variant: "warning" as const, label: "EST" } } as const; const configKey = Object.keys(config).includes(enrollmentType) diff --git a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx index 9ba85beef..474ad158a 100644 --- a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx +++ b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx @@ -170,7 +170,9 @@ export const KmipClientTable = () => { leftIcon={} onClick={() => { if (subscription && !subscription.kmip) { - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true + }); return; } @@ -343,6 +345,7 @@ export const KmipClientTable = () => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} text="KMIP requires an enterprise plan." + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} />
    diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx index bf721edfb..a6675ea88 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx @@ -23,7 +23,6 @@ import { motion } from "framer-motion"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { - Badge, Button, DropdownMenu, DropdownMenuContent, @@ -44,7 +43,7 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { Badge, TBadgeProps } from "@app/components/v3"; import { ProjectPermissionActions, ProjectPermissionCmekActions, @@ -72,7 +71,7 @@ import { DeleteCmekModal } from "./DeleteCmekModal"; const getStatusBadgeProps = ( isDisabled: boolean -): { variant: BadgeProps["variant"]; label: string } => { +): { variant: TBadgeProps["variant"]; label: string } => { if (isDisabled) { return { variant: "danger", diff --git a/frontend/src/pages/kms/OverviewPage/components/CmekVerifyModal.tsx b/frontend/src/pages/kms/OverviewPage/components/CmekVerifyModal.tsx index 4163beb5a..5c9eac072 100644 --- a/frontend/src/pages/kms/OverviewPage/components/CmekVerifyModal.tsx +++ b/frontend/src/pages/kms/OverviewPage/components/CmekVerifyModal.tsx @@ -7,7 +7,6 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { decodeBase64 } from "@app/components/utilities/cryptography/crypto"; import { - Badge, Button, FormControl, Modal, @@ -19,6 +18,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { SigningAlgorithm, TCmek, useCmekVerify } from "@app/hooks/api/cmeks"; import { isBase64 } from "@app/lib/fn/base64"; @@ -112,30 +112,22 @@ const VerifyForm = ({ cmek }: FormProps) => {
    Signature Status: - - - {signatureValid ? ( -
    -

    Valid

    -
    - ) : ( -
    -

    Invalid

    -
    - )} -
    -
    + + + {signatureValid ? "Valid" : "Invalid"} + +
    Signing Algorithm: - {signingAlgorithm} + {signingAlgorithm}
    Signature:{" "} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index 753ebd93c..88cbc17f0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -5,6 +5,7 @@ import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { OrgPermissionGroupActions, OrgPermissionSubjects, useSubscription } from "@app/context"; import { useDeleteGroup } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -27,7 +28,8 @@ export const OrgGroupsSection = () => { if (!subscription?.groups) { handlePopUpOpen("upgradePlan", { description: - "You can manage users more efficiently with groups if you upgrade your Infisical plan to an Enterprise license." + "You can manage users more efficiently with groups if you upgrade your Infisical plan to an Enterprise license.", + isEnterpriseFeature: true }); } else { handlePopUpOpen("group"); @@ -57,7 +59,10 @@ export const OrgGroupsSection = () => { return (
    -

    Groups

    +
    +

    Groups

    + +
    {(isAllowed) => (
    diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 57663f441..752ab1a34 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -4,7 +4,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { Badge, FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { FormControl, Select, SelectItem, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { IdentityAuthMethod } from "@app/hooks/api/identities"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -277,7 +278,7 @@ export const IdentityAuthMethodModalContent = ({ > {label}{" "} {alreadyConfigured && !isSelectedAuthAlreadyConfigured && ( - Configured + Configured )} @@ -291,7 +292,8 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." + text={`You can use ${popUp.upgradePlan.data?.featureName ?? "IP allowlisting"} if you switch to Infisical's ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> ); 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 3cfe1c355..9b8624990 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 @@ -22,11 +22,12 @@ import { TextArea, Tooltip } from "@app/components/v2"; -import { useOrganization, useSubscription } from "@app/context"; +import { useOrganization, useOrgPermission, useSubscription } from "@app/context"; import { OrgGatewayPermissionActions, OrgPermissionSubjects } from "@app/context/OrgPermissionContext/types"; +import { OrgMembershipRole } from "@app/helpers/roles"; import { gatewaysQueryKeys, useAddIdentityKubernetesAuth, @@ -129,6 +130,8 @@ export const IdentityKubernetesAuthForm = ({ ] as const); const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs(); const hasVaultConnection = vaultConfigs.some((config) => config.connectionId); + const { hasOrgRole } = useOrgPermission(); + const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin); const { control, @@ -409,20 +412,29 @@ export const IdentityKubernetesAuthForm = ({ Load values from HashiCorp Vault
    - + +
    )}
    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 878e65cfc..b2096fa06 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 @@ -147,7 +147,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { isEnterpriseFeature?: boolean; featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -304,7 +307,10 @@ export const IdentityLdapAuthForm = ({ useEffect(() => { if (!subscription?.ldap) { - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true, + featureName: "LDAP authentication" + }); handlePopUpToggle("identityAuthMethod", false); } }, [subscription, handlePopUpOpen, handlePopUpToggle]); 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 5c15a0cdd..355dec803 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,15 +1,11 @@ -import { - faArrowUpRightFromSquare, - faBookOpen, - faLink, - faPlus -} from "@fortawesome/free-solid-svg-icons"; +import { faLink, 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 { OrgPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal, Modal, ModalContent } from "@app/components/v2"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, @@ -113,22 +109,9 @@ export const IdentitySection = withPermission(
    -
    +

    Identities

    - -
    - - Docs - -
    -
    +
    {isSubOrganization && (
    -
    +

    Identity Auth Templates

    - -
    - - Docs - -
    -
    +
    } onClick={() => { if (subscription && !subscription.machineIdentityAuthTemplates) { - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true, + description: + "You can use Identity Auth Templates if you switch to Infisical's Enterprise plan." + }); return; } handlePopUpOpen("createTemplate"); @@ -279,7 +253,8 @@ export const IdentitySection = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text="You can use Identity Auth Templates if you switch to Infisical's Enterprise plan." + text={popUp.upgradePlan.data?.description} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} />
    ); diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index a8f5ea059..adb729d75 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; -import { faBan, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { BanIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { - Badge, Button, DeleteActionModal, EmailServiceSetupModal, @@ -15,6 +15,7 @@ import { ModalContent, Tooltip } from "@app/components/v2"; +import { Badge, DocumentationLinkBadge } from "@app/components/v3"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -75,7 +76,7 @@ export const OrgMembersSection = () => { if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { - description: "You can add more members if you upgrade your Infisical plan." + description: "You can add more members if you switch to Infisical's Pro plan." }); return; } @@ -207,7 +208,10 @@ export const OrgMembersSection = () => {
    -

    Users

    +
    +

    Users

    + +
    {(isAllowed) => (
    diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index 8e5d223ca..68a4e795a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -11,19 +11,18 @@ import { faMagnifyingGlass, faSearch, faUsers, - faUserShield, faUserSlash, faUserXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; +import { UserCogIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { LastLoginSection } from "@app/components/organization/LastLoginSection"; import { OrgPermissionCan } from "@app/components/permissions"; import { - Badge, Button, Checkbox, DropdownMenu, @@ -50,6 +49,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { OrgPermissionActions, OrgPermissionSubjects, @@ -133,7 +133,8 @@ export const OrgMembersTable = ({ if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { - description: "You can assign custom roles to members if you upgrade your Infisical plan." + description: + "You can assign custom roles to members if you switch to Infisical's Pro plan." }); return; } @@ -519,17 +520,17 @@ export const OrgMembersTable = ({
    -
    +

    {name ?? Not Set}

    {u.superAdmin && ( - - Server Admin - - - - + + + + Server Admin + + )} {lastLoginAuthMethod && lastLoginTime && ( { if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { description: - "You can set the default org role to a custom role if you upgrade your Infisical plan." + "You can set the default org role to a custom role if you switch to Infisical's Pro plan." }); return; } @@ -200,9 +201,12 @@ export const OrgRoleTable = () => { return (
    -

    - {isSubOrganization ? "Sub-" : ""}Organization Roles -

    +
    +

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

    + +
    {(isAllowed) => (
    -
    +

    {name}

    {isDefaultOrgRole && ( -
    - - Default - -
    + Default
    )}
    @@ -314,8 +314,18 @@ export const OrgRoleTable = () => { {slug}
    - - {isCustomProjectRole(slug) ? "Custom" : "Default"} + + {isCustomOrgRole(slug) ? ( + <> + + Custom + + ) : ( + <> + + Platform + + )} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index a44c4746b..c09a58960 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -36,6 +36,7 @@ import { LdapConnectionForm } from "./LdapConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { MySqlConnectionForm } from "./MySqlConnectionForm"; import { NetlifyConnectionForm } from "./NetlifyConnectionForm"; +import { NorthflankConnectionForm } from "./NorthflankConnectionForm"; import { OCIConnectionForm } from "./OCIConnectionForm"; import { OktaConnectionForm } from "./OktaConnectionForm"; import { OracleDBConnectionForm } from "./OracleDBConnectionForm"; @@ -96,7 +97,7 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { case AppConnection.AWS: return ; case AppConnection.GitHub: - return ; + return ; case AppConnection.GitHubRadar: return ; case AppConnection.GCP: @@ -169,6 +170,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Netlify: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: @@ -213,7 +216,11 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.GitHub: return ( - + ); case AppConnection.GitHubRadar: return ( @@ -326,6 +333,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.DigitalOcean: return ; + case AppConnection.Northflank: + return ; case AppConnection.Okta: return ; case AppConnection.Redis: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx index 5faa7d5b0..5d7092c03 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -16,6 +16,7 @@ import { FormControl, Input, ModalClose, + SecretInput, Select, SelectItem, Tooltip @@ -48,28 +49,58 @@ import { type Props = { appConnection?: TGitHubConnection; projectId: string | undefined | null; + onSubmit: (formData: PatSchemaForm) => Promise; }; -const formSchema = genericAppConnectionFieldsSchema.extend({ +const rootSchema = genericAppConnectionFieldsSchema.extend({ app: z.literal(AppConnection.GitHub), - method: z.nativeEnum(GitHubConnectionMethod), - credentials: z - .union([ - z.object({ - instanceType: z.literal("cloud").optional(), - host: z.string().optional() - }), - z.object({ - instanceType: z.literal("server"), - host: z.string().min(1, "Required") - }) - ]) - .optional() + method: z.nativeEnum(GitHubConnectionMethod) }); +const baseCredentialsSchema = z.union([ + z.object({ + instanceType: z.literal("server"), + host: z.string().min(1, "Host is required for server instance type") + }), + z.object({ + instanceType: z.literal("cloud").optional(), + host: z.string().optional() + }) +]); + +const appSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.App), + credentials: baseCredentialsSchema +}); + +const oauthSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.OAuth), + credentials: baseCredentialsSchema +}); + +const patSchema = rootSchema.extend({ + method: z.literal(GitHubConnectionMethod.Pat), + credentials: z.union([ + z.object({ + instanceType: z.literal("server"), + host: z.string().min(1, "Host is required for server instance type"), + personalAccessToken: z.string().min(1, "Personal Access Token is required") + }), + z.object({ + instanceType: z.literal("cloud").optional(), + host: z.string().optional(), + personalAccessToken: z.string().min(1, "Personal Access Token is required") + }) + ]) +}); + +type PatSchemaForm = z.infer; + +const formSchema = z.discriminatedUnion("method", [appSchema, oauthSchema, patSchema]); + type FormData = z.infer; -export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { +export const GitHubConnectionForm = ({ appConnection, projectId, onSubmit }: Props) => { const isUpdate = Boolean(appConnection); const [isRedirecting, setIsRedirecting] = useState(false); @@ -106,7 +137,12 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { const returnUrl = useGetAppConnectionOauthReturnUrl(); - const onSubmit = (formData: FormData) => { + const submitHandler = async (formData: FormData) => { + if (formData.method === GitHubConnectionMethod.Pat) { + await onSubmit(formData); + return; + } + setIsRedirecting(true); const state = crypto.randomBytes(16).toString("hex"); localStorage.setItem("latestCSRFToken", state); @@ -151,15 +187,26 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { case GitHubConnectionMethod.App: isMissingConfig = !appClientSlug; break; + case GitHubConnectionMethod.Pat: + isMissingConfig = false; + break; default: throw new Error(`Unhandled GitHub Connection method: ${selectedMethod}`); } const methodDetails = getAppConnectionMethodDetails(selectedMethod); + const getButtonText = () => { + if (selectedMethod === GitHubConnectionMethod.Pat) { + return isUpdate ? "Update Connection" : "Create Connection"; + } + + return isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"; + }; + return ( -
    + {!isUpdate && } { )} /> + {selectedMethod === GitHubConnectionMethod.Pat && ( + ( + + onChange(e.target.value)} + /> + + )} + /> + )} @@ -310,7 +377,7 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { isLoading={isSubmitting || isRedirecting} isDisabled={isSubmitting || (!isUpdate && !isDirty) || isMissingConfig || isRedirecting} > - {isUpdate ? "Reconnect to GitHub" : "Connect to GitHub"} + {getButtonText()} + + + + + +
    + ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx index f77157945..6c4e0bed0 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionHeader.tsx @@ -1,6 +1,6 @@ -import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; @@ -29,23 +29,11 @@ export const AppConnectionHeader = ({ app, isConnected, onBack }: Props) => { )}
    -
    +
    {appDetails.name} - -
    - - Docs - -
    -
    + />

    {isConnected ? `${appDetails.name} Connection` : `Connect to ${appDetails.name}`} diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx index 8cd28ea91..9b8fdddf1 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx @@ -72,10 +72,13 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => { return (

    ); diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx index 514900ee0..8a9f1f775 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionRow.tsx @@ -8,18 +8,17 @@ import { faEdit, faEllipsisV, faInfoCircle, - faServer, faTable, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link } from "@tanstack/react-router"; +import { ServerIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { VariablePermissionCan } from "@app/components/permissions"; import { - Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -29,6 +28,7 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { OrgPermissionSubjects, ProjectPermissionSub } from "@app/context"; import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; @@ -157,9 +157,9 @@ export const AppConnectionRow = ({ {isPlatformManagedCredentials && (
    - - - Platform Managed Credentials + + + Platform Managed Credentials
    diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx index 491a4d7d7..635377f2e 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionsTable.tsx @@ -2,8 +2,6 @@ import { useMemo, useState } from "react"; import { faArrowDown, faArrowUp, - faArrowUpRightFromSquare, - faBookOpen, faCheckCircle, faFilter, faMagnifyingGlass, @@ -34,6 +32,7 @@ import { THead, Tr } from "@app/components/v2"; +import { DocumentationLinkBadge } from "@app/components/v3"; import { OrgPermissionSubjects, ProjectPermissionSub } from "@app/context"; import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; @@ -212,22 +211,9 @@ export const AppConnectionsTable = ({ projectId, projectType }: Props) => {
    -
    +

    App Connections

    - -
    - - Docs - -
    -
    +

    Create and configure connections with third-party apps for re-use across your project diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index c53c38984..72561b90d 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -8,7 +8,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { twMerge } from "tailwind-merge"; import { - Badge, Button, DropdownMenu, DropdownMenuContent, @@ -20,6 +19,7 @@ import { Select, SelectItem } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { useOrganization } from "@app/context"; import { useGetUserProjects } from "@app/hooks/api"; import { @@ -124,7 +124,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => {

    @@ -257,6 +255,10 @@ export const GatewayTab = withPermission( deleteKey="confirm" onDeleteApproved={() => handleDeleteGateway()} /> + handlePopUpToggle("deployGateway", isOpen)} + />

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

    {errors.name}

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

    {errors.relay}

    } + + + setInstanceDomain(e.target.value)} + placeholder="https://app.infisical.com" + isError={Boolean(errors.instanceDomain)} + /> + {errors.instanceDomain &&

    {errors.instanceDomain}

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

    {errors.identity}

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

    {errors.identityToken}

    } + + )} + + {canCreateToken && ( +
    + { + setAutogenerateToken(Boolean(e)); + }} + id="autogenerate-token" + className="mr-2" + > +
    + Automatically enable token auth and generate a token for identity + + Token authentication will be automatically enabled for the selected identity if + it isn't already configured. By default, it will be configured to allow all + IP addresses with a token TTL of 30 days. You can manage these settings in + Access Control. +
    +
    A token will automatically be generated to be used with the CLI command. + + } + > + +
    +
    +
    +
    + )} + +
    + + + + +
    + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx new file mode 100644 index 000000000..170ea2eab --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { GatewayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect"; + +import { GatewayCliDeploymentMethod } from "./GatewayCliDeploymentMethod"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const GatewayDeploymentInfoMap = { + cli: { name: "CLI", image: "SSH.png", component: GatewayCliDeploymentMethod } +} as const; + +export type GatewayDeploymentMethod = keyof typeof GatewayDeploymentInfoMap; + +const Content = () => { + const [selectedMethod, setSelectedMethod] = useState(null); + + if (selectedMethod) { + const ComponentToRender = GatewayDeploymentInfoMap[selectedMethod]?.component; + if (ComponentToRender) { + return ; + } + } + + return ; +}; + +export const GatewayDeployModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect.tsx new file mode 100644 index 000000000..20a169c24 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeploymentMethodSelect.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; + +import { + GatewayDeploymentInfoMap, + GatewayDeploymentMethod +} from "@app/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayDeployModal"; + +type Props = { + onSelect: (method: GatewayDeploymentMethod) => void; +}; + +export const GatewayDeploymentMethodSelect = ({ onSelect }: Props) => { + const deploymentOptions = useMemo( + () => + (Object.keys(GatewayDeploymentInfoMap) as GatewayDeploymentMethod[]).map((method) => ({ + method, + name: GatewayDeploymentInfoMap[method].name, + image: GatewayDeploymentInfoMap[method].image + })), + [] + ); + + const handleResourceSelect = (method: GatewayDeploymentMethod) => { + onSelect(method); + }; + + return ( +
    + {deploymentOptions.map((option) => { + const { image, name, method } = option; + + return ( + + ); + })} +
    + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/RelayOption.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/RelayOption.tsx new file mode 100644 index 000000000..90c9a7ebd --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/RelayOption.tsx @@ -0,0 +1,32 @@ +import { components, OptionProps } from "react-select"; +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export const RelayOption = ({ + isSelected, + children, + ...props +}: OptionProps<{ id: string; name: string }>) => { + const isCreateOption = props.data.id === "_create"; + + return ( + +
    + {isCreateOption ? ( +
    + + Deploy New Relay +
    + ) : ( + <> +

    {children}

    + {isSelected && ( + + )} + + )} +
    +
    + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx b/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx index 5aca54a00..b48b8b113 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/NetworkingTabGroup/NetworkingTabGroup.tsx @@ -1,15 +1,20 @@ -import { useState } from "react"; -import { useSearch } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; import { useOrganization } from "@app/context"; import { GatewayTab } from "../GatewayTab/GatewayTab"; import { RelayTab } from "../RelayTab/RelayTab"; export const NetworkingTabGroup = () => { - const search = useSearch({ - from: "/_authenticate/_inject-org-details/_org-layout/organization/networking/" + const navigate = useNavigate({ + from: ROUTE_PATHS.Organization.NetworkingPage.path + }); + const selectedTab = useSearch({ + from: ROUTE_PATHS.Organization.NetworkingPage.id, + select: (el) => el.selectedTab, + structuralSharing: true }); const tabs = [ @@ -17,12 +22,16 @@ export const NetworkingTabGroup = () => { { name: "Relays", key: "relays", component: RelayTab } ]; - const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key); + const handleTabChange = (tab: string) => { + navigate({ + search: { selectedTab: tab } + }); + }; const { isSubOrganization } = useOrganization(); return ( - + {tabs.map((tab) => ( diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx index e525b765d..d90b542bc 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/RelayTab.tsx @@ -1,21 +1,22 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { - faArrowUpRightFromSquare, - faBookOpen, faCopy, faDoorClosed, faEllipsisV, faInfoCircle, faMagnifyingGlass, + faPlus, faSearch, faTrash } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { formatRelative } from "date-fns"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Button, DeleteActionModal, DropdownMenu, DropdownMenuContent, @@ -34,6 +35,8 @@ import { Tooltip, Tr } from "@app/components/v2"; +import { DocumentationLinkBadge } from "@app/components/v3"; +import { ROUTE_PATHS } from "@app/const/routes"; import { OrgPermissionSubjects, OrgRelayPermissionActions @@ -42,6 +45,8 @@ import { withPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; import { useDeleteRelayById, useGetRelays } from "@app/hooks/api/relays"; +import { RelayDeployModal } from "./components/RelayDeployModal"; + const RelayHealthStatus = ({ heartbeat }: { heartbeat?: string }) => { const heartbeatDate = heartbeat ? new Date(heartbeat) : null; const now = new Date(); @@ -66,7 +71,29 @@ export const RelayTab = withPermission( const [search, setSearch] = useState(""); const { data: relays, isPending: isRelaysLoading } = useGetRelays(); - const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["deleteRelay"] as const); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([ + "deleteRelay", + "deployRelay" + ] as const); + + const action = useSearch({ + from: ROUTE_PATHS.Organization.NetworkingPage.id, + select: (s) => s.action + }); + + const navigate = useNavigate({ + from: ROUTE_PATHS.Organization.NetworkingPage.path + }); + + useEffect(() => { + if (action === "deploy-relay") { + handlePopUpOpen("deployRelay"); + navigate({ + search: (prev) => ({ ...prev, action: undefined }), + replace: true + }); + } + }, [action, handlePopUpOpen, navigate]); const deleteRelayById = useDeleteRelayById(); @@ -87,23 +114,18 @@ export const RelayTab = withPermission( return (
    -
    -
    +
    +

    @@ -222,6 +244,10 @@ export const RelayTab = withPermission( deleteKey="confirm" onDeleteApproved={() => handleDeleteRelay()} /> + handlePopUpToggle("deployRelay", isOpen)} + />

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

    {errors.name}

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

    {errors.host}

    } + + + setInstanceDomain(e.target.value)} + placeholder="https://app.infisical.com" + isError={Boolean(errors.instanceDomain)} + /> + {errors.instanceDomain &&

    {errors.instanceDomain}

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

    {errors.identity}

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

    {errors.identityToken}

    } + + )} + + {canCreateToken && ( +
    + { + setAutogenerateToken(Boolean(e)); + }} + id="autogenerate-token" + className="mr-2" + > +
    + Automatically enable token auth and generate a token for identity + + Token authentication will be automatically enabled for the selected identity if + it isn't already configured. By default, it will be configured to allow all + IP addresses with a token TTL of 30 days. You can manage these settings in + Access Control. +
    +
    A token will automatically be generated to be used with the CLI command. + + } + > + +
    +
    +
    +
    + )} + +
    + + + + +
    + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx new file mode 100644 index 000000000..496ecb762 --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { RelayDeploymentMethodSelect } from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect"; + +import { RelayCliDeploymentMethod } from "./RelayCliDeploymentMethod"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const RelayDeploymentInfoMap = { + cli: { name: "CLI", image: "SSH.png", component: RelayCliDeploymentMethod } +} as const; + +export type RelayDeploymentMethod = keyof typeof RelayDeploymentInfoMap; + +const Content = () => { + const [selectedMethod, setSelectedMethod] = useState(null); + + if (selectedMethod) { + const ComponentToRender = RelayDeploymentInfoMap[selectedMethod]?.component; + if (ComponentToRender) { + return ; + } + } + + return ; +}; + +export const RelayDeployModal = ({ isOpen, onOpenChange }: Props) => { + return ( + + + + + + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx new file mode 100644 index 000000000..cf7861a8b --- /dev/null +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeploymentMethodSelect.tsx @@ -0,0 +1,54 @@ +import { useMemo } from "react"; + +import { + RelayDeploymentInfoMap, + RelayDeploymentMethod +} from "@app/pages/organization/NetworkingPage/components/RelayTab/components/RelayDeployModal"; + +type Props = { + onSelect: (method: RelayDeploymentMethod) => void; +}; + +export const RelayDeploymentMethodSelect = ({ onSelect }: Props) => { + const deploymentOptions = useMemo( + () => + (Object.keys(RelayDeploymentInfoMap) as RelayDeploymentMethod[]).map((method) => ({ + method, + name: RelayDeploymentInfoMap[method].name, + image: RelayDeploymentInfoMap[method].image + })), + [] + ); + + const handleResourceSelect = (method: RelayDeploymentMethod) => { + onSelect(method); + }; + + return ( +
    + {deploymentOptions.map((option) => { + const { image, name, method } = option; + + return ( + + ); + })} +
    + ); +}; diff --git a/frontend/src/pages/organization/NetworkingPage/route.tsx b/frontend/src/pages/organization/NetworkingPage/route.tsx index 906b12806..fb81e3725 100644 --- a/frontend/src/pages/organization/NetworkingPage/route.tsx +++ b/frontend/src/pages/organization/NetworkingPage/route.tsx @@ -5,16 +5,17 @@ import { z } from "zod"; import { NetworkingPage } from "./NetworkingPage"; const NetworkingPageQueryParams = z.object({ - selectedTab: z.string().catch("") + selectedTab: z.string().catch("gateways"), + action: z.string().optional() }); export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/organization/networking/" + "/_authenticate/_inject-org-details/_org-layout/organization/networking" )({ component: NetworkingPage, validateSearch: zodValidator(NetworkingPageQueryParams), search: { - middlewares: [stripSearchParams({ selectedTab: "" })] + middlewares: [stripSearchParams({ selectedTab: "gateways" })] }, context: () => ({ breadcrumbs: [ diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index 8a8f28a79..b916c7867 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -92,7 +92,7 @@ export const ProjectsPage = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." + text="You have exceeded the number of projects allowed on the free plan. You can upgrade to Infisical's Pro plan to add more projects." />
    ); diff --git a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx index d7dcfcb3e..cf60ffa59 100644 --- a/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx +++ b/frontend/src/pages/organization/ProjectsPage/components/AllProjectView.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { faArrowDownAZ, faBorderAll, - faCheck, faCheckCircle, faFolderOpen, faList, @@ -11,13 +10,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate } from "@tanstack/react-router"; +import { CheckIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { RequestProjectAccessModal } from "@app/components/projects/RequestProjectAccessModal"; import { - Badge, Button, DropdownMenu, DropdownMenuContent, @@ -31,6 +30,7 @@ import { Skeleton, Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v3"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; import { OrgPermissionAdminConsoleAction } from "@app/context/OrgPermissionContext/types"; import { getProjectHomePage, getProjectLottieIcon, getProjectTitle } from "@app/helpers/project"; @@ -308,9 +308,9 @@ export const AllProjectView = ({
    {workspace.isMember ? ( - - - Joined + + + Joined ) : ( Expired ) : ( - + {row.encryptedSecret ? "Secret Provided" : "Pending Secret"} )}
    - - -
    - {row.accessType === SecretSharingAccessType.Anyone - ? "Anyone" - : "Organization Members"} -
    -
    -
    + + + {row.accessType === SecretSharingAccessType.Anyone ? "Anyone" : "Organization Members"} + +
    {`${format(new Date(row.createdAt), "yyyy-MM-dd - HH:mm a")}`} {row.expiresAt ? format(new Date(row.expiresAt), "yyyy-MM-dd - HH:mm a") : "-"}