diff --git a/.env.example b/.env.example index bdb3e536d..67110d69a 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ CLIENT_SECRET_GITHUB_LOGIN= CLIENT_ID_GITLAB_LOGIN= CLIENT_SECRET_GITLAB_LOGIN= + +CAPTCHA_SECRET= + +NEXT_PUBLIC_CAPTCHA_SITE_KEY= diff --git a/.github/workflows/check-api-for-breaking-changes.yml b/.github/workflows/check-api-for-breaking-changes.yml index dadd6c860..b6d698af4 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -35,7 +35,7 @@ jobs: echo "SECRET_SCANNING_GIT_APP_ID=793712" >> .env echo "SECRET_SCANNING_PRIVATE_KEY=some-random" >> .env echo "SECRET_SCANNING_WEBHOOK_SECRET=some-random" >> .env - docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" + docker run --name infisical-api -d -p 4000:4000 -e DB_CONNECTION_URI=$DB_CONNECTION_URI -e REDIS_URL=$REDIS_URL -e JWT_AUTH_SECRET=$JWT_AUTH_SECRET -e ENCRYPTION_KEY=$ENCRYPTION_KEY --env-file .env --entrypoint '/bin/sh' infisical-api -c "npm run migration:latest && ls && node dist/main.mjs" env: REDIS_URL: redis://172.17.0.1:6379 DB_CONNECTION_URI: postgres://infisical:infisical@172.17.0.1:5432/infisical?sslmode=disable @@ -47,7 +47,7 @@ jobs: - name: Wait for container to be stable and check logs run: | SECONDS=0 - r HEALTHY=0 + HEALTHY=0 while [ $SECONDS -lt 60 ]; do if docker ps | grep infisical-api | grep -q healthy; then echo "Container is healthy." diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index e4a5945e0..02c349237 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -22,6 +22,9 @@ jobs: CLI_TESTS_SERVICE_TOKEN: ${{ secrets.CLI_TESTS_SERVICE_TOKEN }} CLI_TESTS_PROJECT_ID: ${{ secrets.CLI_TESTS_PROJECT_ID }} CLI_TESTS_ENV_SLUG: ${{ secrets.CLI_TESTS_ENV_SLUG }} + CLI_TESTS_USER_EMAIL: ${{ secrets.CLI_TESTS_USER_EMAIL }} + CLI_TESTS_USER_PASSWORD: ${{ secrets.CLI_TESTS_USER_PASSWORD }} + CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE: ${{ secrets.CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE }} goreleaser: runs-on: ubuntu-20.04 @@ -56,7 +59,7 @@ jobs: - uses: goreleaser/goreleaser-action@v4 with: distribution: goreleaser-pro - version: latest + version: v1.26.2-pro args: release --clean env: GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} diff --git a/.github/workflows/run-cli-tests.yml b/.github/workflows/run-cli-tests.yml index e814f9143..f8e9d7797 100644 --- a/.github/workflows/run-cli-tests.yml +++ b/.github/workflows/run-cli-tests.yml @@ -20,7 +20,12 @@ on: required: true CLI_TESTS_ENV_SLUG: required: true - + CLI_TESTS_USER_EMAIL: + required: true + CLI_TESTS_USER_PASSWORD: + required: true + CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE: + required: true jobs: test: defaults: @@ -43,5 +48,8 @@ jobs: CLI_TESTS_SERVICE_TOKEN: ${{ secrets.CLI_TESTS_SERVICE_TOKEN }} CLI_TESTS_PROJECT_ID: ${{ secrets.CLI_TESTS_PROJECT_ID }} CLI_TESTS_ENV_SLUG: ${{ secrets.CLI_TESTS_ENV_SLUG }} + CLI_TESTS_USER_EMAIL: ${{ secrets.CLI_TESTS_USER_EMAIL }} + CLI_TESTS_USER_PASSWORD: ${{ secrets.CLI_TESTS_USER_PASSWORD }} + INFISICAL_VAULT_FILE_PASSPHRASE: ${{ secrets.CLI_TESTS_INFISICAL_VAULT_FILE_PASSPHRASE }} run: go test -v -count=1 ./test diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 0fb2a6671..8ffe7e3de 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,6 +1,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id +ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:20-alpine AS base @@ -34,7 +35,9 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION -ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION +ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -110,6 +113,9 @@ ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ ARG INTERCOM_ID=intercom-id ENV NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID \ BAKED_NEXT_PUBLIC_INTERCOM_ID=$INTERCOM_ID +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ + BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY WORKDIR / diff --git a/README.md b/README.md index 80f754c02..a5ed5c621 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,13 @@ To set up and run Infisical locally, make sure you have Git and Docker installed Linux/macOS: ```console -git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker-compose -f docker-compose.prod.yml up +git clone https://github.com/Infisical/infisical && cd "$(basename $_ .git)" && cp .env.example .env && docker compose -f docker-compose.prod.yml up ``` Windows Command Prompt: ```console -git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker-compose -f docker-compose.prod.yml up +git clone https://github.com/Infisical/infisical && cd infisical && copy .env.example .env && docker compose -f docker-compose.prod.yml up ``` Create an account at `http://localhost:80` diff --git a/backend/package-lock.json b/backend/package-lock.json index b6f9a37c1..389d16475 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -25,6 +25,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", @@ -36,6 +38,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", @@ -2458,9 +2461,9 @@ } }, "node_modules/@fastify/session": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.7.0.tgz", - "integrity": "sha512-ECA75gnyaxcyIukgyO2NGT3XdbLReNl/pTKrrkRfDc6pVqNtdptwwfx9KXrIMOfsO4B3m84eF3wZ9GgnebiZ4w==", + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-10.9.0.tgz", + "integrity": "sha512-u/c42RuAaxCeEuRCAwK2+/SfGqKOd0NSyRzEvDwFBWySQoKUZQyb9OmmJSWJBbOP1OfaU2OsDrjbPbghE1l/YQ==", "dependencies": { "fastify-plugin": "^4.0.0", "safe-stable-stringify": "^2.3.1" @@ -3298,6 +3301,149 @@ "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.1.0.tgz", "integrity": "sha512-y92CpG4kFFtBBjni8LHoV12IegJ+KFxLgKRengrVjKmGE5XMeCuGvlfRe75lTRrgXaG6XIWJlFpIDTlkoJsU8w==" }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.3.8.tgz", + "integrity": "sha512-Wtk9R7yQxGaIaawHorWKP2OOOm/RZzamOmSWwaqGphIuU6TcKYih0slL6asZlSSZtVoYTrBfrddSOD/jTu9vuQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.3.8.tgz", + "integrity": "sha512-ZmAaP2hfzgIGdMLcot8gHTykzoI+X/S53x1xoGbTmratETIaAbSWMiPGvZmXRA0SNEIydpMkzYtq4fQBxN1u1w==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.8.tgz", + "integrity": "sha512-Ah/Q15y3A/CtxbPibiLM/LKcMbnLTdUdLHUgdpB5f60sSvGkXzxJCu5ezGTFHogZXWNX3KSmYqilCrfdmBc6pQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.3.8.tgz", + "integrity": "sha512-XhdnCVznMmSmgy68B9pVxiZ1XkKoE1BjO4Hv+eUGiY1pM14msLsFZ3N7K46SoITIVZLq92kKkXpGiTfRjlNLyg==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.3.8.tgz", + "integrity": "sha512-rL8k2x59v8lZiwLRqdMMmOJ30GHt6yuHISFIuuWivWjAJjnxzZBVzMTQ72sknX5MeTSSvGwPmEFk2/N8+UztFQ==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.3.8.tgz", + "integrity": "sha512-+nONq5tcK7vm3qdY7ZKoSQGQjhJYMJbwJGbXLFOhmqsFIxEWyQPHyV99+wshOjpOjg0wUSSkEEzX2hx5P6EKeQ==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-pfx": "^2.3.8", + "@peculiar/asn1-pkcs8": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@peculiar/asn1-x509-attr": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.8.tgz", + "integrity": "sha512-ES/RVEHu8VMYXgrg3gjb1m/XG0KJWnV4qyZZ7mAg7rrF3VTmRbLxO8mk+uy0Hme7geSMebp+Wvi2U6RLLEs12Q==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.8.tgz", + "integrity": "sha512-ULB1XqHKx1WBU/tTFIA+uARuRoBVZ4pNdOA878RDrRbBfBGcSzi5HBkdScC6ZbHn8z7L8gmKCgPC1LHRrP46tA==", + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.8.tgz", + "integrity": "sha512-voKxGfDU1c6r9mKiN5ZUsZWh3Dy1BABvTM3cimf0tztNwyMJPhiXY94eRTgsMQe6ViLfT6EoXxkWVzcm3mFAFw==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.3.8.tgz", + "integrity": "sha512-4Z8mSN95MOuX04Aku9BUyMdsMKtVQUqWnr627IheiWnwFoheUhX3R4Y2zh23M7m80r4/WG8MOAckRKc77IRv6g==", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.10.0.tgz", + "integrity": "sha512-gdH6H8gWjAYoM4Yr6wPnRbzU77nU7xq/jipqYyyv5/AHTrulN2Z5DlnOSq9jjKrB+Ya0D6YJ2cGGtwkWDK75jA==", + "dependencies": { + "@peculiar/asn1-cms": "^2.3.8", + "@peculiar/asn1-csr": "^2.3.8", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-pkcs9": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "pvtsutils": "^1.3.5", + "reflect-metadata": "^0.2.2", + "tslib": "^2.6.2", + "tsyringe": "^4.8.0" + } + }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -4806,6 +4952,11 @@ "long": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5948,6 +6099,19 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -6689,6 +6853,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -11702,6 +11875,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -11883,6 +12072,11 @@ "node": ">=4" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", @@ -13666,6 +13860,22 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsyringe": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.8.0.tgz", + "integrity": "sha512-YB1FG+axdxADa3ncEtRnQCFq/M0lALGLxSZeVNbTU8NqhOVc51nnv2CISTcvc1kyv6EGPtXVr0v6lWeDxiijOA==", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 3d1937964..f2a8582d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -86,6 +86,8 @@ "@node-saml/passport-saml": "^4.0.4", "@octokit/rest": "^20.0.2", "@octokit/webhooks-types": "^7.3.1", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/x509": "^1.10.0", "@serdnam/pino-cloudwatch-transport": "^1.0.4", "@sindresorhus/slugify": "^2.2.1", "@ucast/mongo2js": "^1.3.4", @@ -97,6 +99,7 @@ "bcrypt": "^5.1.1", "bullmq": "^5.4.2", "cassandra-driver": "^4.7.2", + "cron": "^3.1.7", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 3f1ca94e9..e5cf3f79f 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -6,6 +6,7 @@ import { TAccessApprovalRequestServiceFactory } from "@app/ee/services/access-ap import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { TAuditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { TCertificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { TDynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { TDynamicSecretLeaseServiceFactory } from "@app/ee/services/dynamic-secret-lease/dynamic-secret-lease-service"; import { TGroupServiceFactory } from "@app/ee/services/group/group-service"; @@ -14,6 +15,7 @@ import { TLdapConfigServiceFactory } from "@app/ee/services/ldap-config/ldap-con import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TProjectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; +import { TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { TSamlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -29,6 +31,8 @@ import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TGroupProjectServiceFactory } from "@app/services/group-project/group-project-service"; import { TIdentityServiceFactory } from "@app/services/identity/identity-service"; import { TIdentityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; @@ -137,6 +141,9 @@ declare module "fastify" { ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; + certificate: TCertificateServiceFactory; + certificateAuthority: TCertificateAuthorityServiceFactory; + certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; @@ -147,6 +154,7 @@ declare module "fastify" { projectUserAdditionalPrivilege: TProjectUserAdditionalPrivilegeServiceFactory; identityProjectAdditionalPrivilege: TIdentityProjectAdditionalPrivilegeServiceFactory; secretSharing: TSecretSharingServiceFactory; + rateLimit: TRateLimitServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 117a74e76..79342ea27 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -32,6 +32,27 @@ import { TBackupPrivateKey, TBackupPrivateKeyInsert, TBackupPrivateKeyUpdate, + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate, + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate, + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate, + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate, + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate, + TCertificates, + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate, + TCertificatesInsert, + TCertificatesUpdate, TDynamicSecretLeases, TDynamicSecretLeasesInsert, TDynamicSecretLeasesUpdate, @@ -149,6 +170,9 @@ import { TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, + TRateLimit, + TRateLimitInsert, + TRateLimitUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -257,6 +281,37 @@ declare module "knex/types/tables" { interface Tables { [TableName.Users]: Knex.CompositeTableType; [TableName.Groups]: Knex.CompositeTableType; + [TableName.CertificateAuthority]: Knex.CompositeTableType< + TCertificateAuthorities, + TCertificateAuthoritiesInsert, + TCertificateAuthoritiesUpdate + >; + [TableName.CertificateAuthorityCert]: Knex.CompositeTableType< + TCertificateAuthorityCerts, + TCertificateAuthorityCertsInsert, + TCertificateAuthorityCertsUpdate + >; + [TableName.CertificateAuthoritySecret]: Knex.CompositeTableType< + TCertificateAuthoritySecret, + TCertificateAuthoritySecretInsert, + TCertificateAuthoritySecretUpdate + >; + [TableName.CertificateAuthorityCrl]: Knex.CompositeTableType< + TCertificateAuthorityCrl, + TCertificateAuthorityCrlInsert, + TCertificateAuthorityCrlUpdate + >; + [TableName.Certificate]: Knex.CompositeTableType; + [TableName.CertificateBody]: Knex.CompositeTableType< + TCertificateBodies, + TCertificateBodiesInsert, + TCertificateBodiesUpdate + >; + [TableName.CertificateSecret]: Knex.CompositeTableType< + TCertificateSecrets, + TCertificateSecretsInsert, + TCertificateSecretsUpdate + >; [TableName.UserGroupMembership]: Knex.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, @@ -343,6 +398,7 @@ declare module "knex/types/tables" { TSecretFolderVersionsUpdate >; [TableName.SecretSharing]: Knex.CompositeTableType; + [TableName.RateLimit]: Knex.CompositeTableType; [TableName.SecretTag]: Knex.CompositeTableType; [TableName.SecretImport]: Knex.CompositeTableType; [TableName.Integration]: Knex.CompositeTableType; diff --git a/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts b/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts new file mode 100644 index 000000000..66fa03182 --- /dev/null +++ b/backend/src/db/migrations/20240610181521_add-consecutive-failed-password-attempts-user.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (!hasConsecutiveFailedPasswordAttempts) { + tb.integer("consecutiveFailedPasswordAttempts").defaultTo(0); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedPasswordAttempts = await knex.schema.hasColumn( + TableName.Users, + "consecutiveFailedPasswordAttempts" + ); + + await knex.schema.alterTable(TableName.Users, (tb) => { + if (hasConsecutiveFailedPasswordAttempts) { + tb.dropColumn("consecutiveFailedPasswordAttempts"); + } + }); +} diff --git a/backend/src/db/migrations/20240612200518_add-pit-version-limit.ts b/backend/src/db/migrations/20240612200518_add-pit-version-limit.ts new file mode 100644 index 000000000..e37c24e2c --- /dev/null +++ b/backend/src/db/migrations/20240612200518_add-pit-version-limit.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPitVersionLimitColumn = await knex.schema.hasColumn(TableName.Project, "pitVersionLimit"); + await knex.schema.alterTable(TableName.Project, (tb) => { + if (!hasPitVersionLimitColumn) { + tb.integer("pitVersionLimit").notNullable().defaultTo(10); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasPitVersionLimitColumn = await knex.schema.hasColumn(TableName.Project, "pitVersionLimit"); + await knex.schema.alterTable(TableName.Project, (tb) => { + if (hasPitVersionLimitColumn) { + tb.dropColumn("pitVersionLimit"); + } + }); +} diff --git a/backend/src/db/migrations/20240614010847_custom-rate-limits-for-self-hosting.ts b/backend/src/db/migrations/20240614010847_custom-rate-limits-for-self-hosting.ts new file mode 100644 index 000000000..c34b2d196 --- /dev/null +++ b/backend/src/db/migrations/20240614010847_custom-rate-limits-for-self-hosting.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.RateLimit))) { + await knex.schema.createTable(TableName.RateLimit, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.integer("readRateLimit").defaultTo(600).notNullable(); + t.integer("writeRateLimit").defaultTo(200).notNullable(); + t.integer("secretsRateLimit").defaultTo(60).notNullable(); + t.integer("authRateLimit").defaultTo(60).notNullable(); + t.integer("inviteUserRateLimit").defaultTo(30).notNullable(); + t.integer("mfaRateLimit").defaultTo(20).notNullable(); + t.integer("creationLimit").defaultTo(30).notNullable(); + t.integer("publicEndpointLimit").defaultTo(30).notNullable(); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.RateLimit); + + // create init rate limit entry with defaults + await knex(TableName.RateLimit).insert({}); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.RateLimit); + await dropOnUpdateTrigger(knex, TableName.RateLimit); +} diff --git a/backend/src/db/migrations/20240614115952_tag-machine-identity.ts b/backend/src/db/migrations/20240614115952_tag-machine-identity.ts new file mode 100644 index 000000000..fd11928b6 --- /dev/null +++ b/backend/src/db/migrations/20240614115952_tag-machine-identity.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { ActorType } from "@app/services/auth/auth-type"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasCreatedByActorType = await knex.schema.hasColumn(TableName.SecretTag, "createdByActorType"); + await knex.schema.alterTable(TableName.SecretTag, (tb) => { + if (!hasCreatedByActorType) { + tb.string("createdByActorType").notNullable().defaultTo(ActorType.USER); + tb.dropForeign("createdBy"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasCreatedByActorType = await knex.schema.hasColumn(TableName.SecretTag, "createdByActorType"); + await knex.schema.alterTable(TableName.SecretTag, (tb) => { + if (hasCreatedByActorType) { + tb.dropColumn("createdByActorType"); + tb.foreign("createdBy").references("id").inTable(TableName.Users).onDelete("SET NULL"); + } + }); +} diff --git a/backend/src/db/migrations/20240614154212_certificate-mgmt.ts b/backend/src/db/migrations/20240614154212_certificate-mgmt.ts new file mode 100644 index 000000000..a738a6b64 --- /dev/null +++ b/backend/src/db/migrations/20240614154212_certificate-mgmt.ts @@ -0,0 +1,137 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (!doesProjectCertificateKeyIdExist) { + t.uuid("kmsCertificateKeyId").nullable(); + t.foreign("kmsCertificateKeyId").references("id").inTable(TableName.KmsKey); + } + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthority))) { + await knex.schema.createTable(TableName.CertificateAuthority, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("parentCaId").nullable(); + t.foreign("parentCaId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("type").notNullable(); // root / intermediate + t.string("status").notNullable(); // active / pending-certificate + t.string("friendlyName").notNullable(); + t.string("organization").notNullable(); + t.string("ou").notNullable(); + t.string("country").notNullable(); + t.string("province").notNullable(); + t.string("locality").notNullable(); + t.string("commonName").notNullable(); + t.string("dn").notNullable(); + t.string("serialNumber").nullable().unique(); + t.integer("maxPathLength").nullable(); + t.string("keyAlgorithm").notNullable(); + t.datetime("notBefore").nullable(); + t.datetime("notAfter").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCert))) { + // table to keep track of certificates belonging to CA + await knex.schema.createTable(TableName.CertificateAuthorityCert, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + t.binary("encryptedCertificateChain").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthoritySecret))) { + await knex.schema.createTable(TableName.CertificateAuthoritySecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateAuthorityCrl))) { + await knex.schema.createTable(TableName.CertificateAuthorityCrl, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable().unique(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.binary("encryptedCrl").notNullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.Certificate))) { + await knex.schema.createTable(TableName.Certificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("CASCADE"); + t.string("status").notNullable(); // active / pending-certificate + t.string("serialNumber").notNullable().unique(); + t.string("friendlyName").notNullable(); + t.string("commonName").notNullable(); + t.datetime("notBefore").notNullable(); + t.datetime("notAfter").notNullable(); + t.datetime("revokedAt").nullable(); + t.integer("revocationReason").nullable(); // integer based on crl reason in RFC 5280 + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateBody))) { + await knex.schema.createTable(TableName.CertificateBody, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedCertificate").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.CertificateAuthority); + await createOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + await createOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + await createOnUpdateTrigger(knex, TableName.Certificate); + await createOnUpdateTrigger(knex, TableName.CertificateBody); +} + +export async function down(knex: Knex): Promise { + // project + if (await knex.schema.hasTable(TableName.Project)) { + const doesProjectCertificateKeyIdExist = await knex.schema.hasColumn(TableName.Project, "kmsCertificateKeyId"); + await knex.schema.alterTable(TableName.Project, (t) => { + if (doesProjectCertificateKeyIdExist) t.dropColumn("kmsCertificateKeyId"); + }); + } + + // certificates + await knex.schema.dropTableIfExists(TableName.CertificateBody); + await dropOnUpdateTrigger(knex, TableName.CertificateBody); + + await knex.schema.dropTableIfExists(TableName.Certificate); + await dropOnUpdateTrigger(knex, TableName.Certificate); + + // certificate authorities + await knex.schema.dropTableIfExists(TableName.CertificateAuthoritySecret); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthoritySecret); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCrl); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCrl); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthorityCert); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthorityCert); + + await knex.schema.dropTableIfExists(TableName.CertificateAuthority); + await dropOnUpdateTrigger(knex, TableName.CertificateAuthority); +} diff --git a/backend/src/db/schemas/certificate-authorities.ts b/backend/src/db/schemas/certificate-authorities.ts new file mode 100644 index 000000000..16f303b5c --- /dev/null +++ b/backend/src/db/schemas/certificate-authorities.ts @@ -0,0 +1,37 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthoritiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + parentCaId: z.string().uuid().nullable().optional(), + projectId: z.string(), + type: z.string(), + status: z.string(), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + dn: z.string(), + serialNumber: z.string().nullable().optional(), + maxPathLength: z.number().nullable().optional(), + keyAlgorithm: z.string(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional() +}); + +export type TCertificateAuthorities = z.infer; +export type TCertificateAuthoritiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthoritiesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-certs.ts b/backend/src/db/schemas/certificate-authority-certs.ts new file mode 100644 index 000000000..96ad54f00 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-certs.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCertsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer +}); + +export type TCertificateAuthorityCerts = z.infer; +export type TCertificateAuthorityCertsInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCertsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-crl.ts b/backend/src/db/schemas/certificate-authority-crl.ts new file mode 100644 index 000000000..204a0c60c --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-crl.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthorityCrlSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedCrl: zodBuffer +}); + +export type TCertificateAuthorityCrl = z.infer; +export type TCertificateAuthorityCrlInsert = Omit, TImmutableDBKeys>; +export type TCertificateAuthorityCrlUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-authority-secret.ts b/backend/src/db/schemas/certificate-authority-secret.ts new file mode 100644 index 000000000..36ab1c506 --- /dev/null +++ b/backend/src/db/schemas/certificate-authority-secret.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateAuthoritySecretSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + encryptedPrivateKey: zodBuffer +}); + +export type TCertificateAuthoritySecret = z.infer; +export type TCertificateAuthoritySecretInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TCertificateAuthoritySecretUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts new file mode 100644 index 000000000..75afbddbd --- /dev/null +++ b/backend/src/db/schemas/certificate-bodies.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateBodiesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + encryptedCertificate: zodBuffer +}); + +export type TCertificateBodies = z.infer; +export type TCertificateBodiesInsert = Omit, TImmutableDBKeys>; +export type TCertificateBodiesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts new file mode 100644 index 000000000..f8cad74f1 --- /dev/null +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -0,0 +1,21 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateSecretsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + certId: z.string().uuid(), + pk: z.string(), + sk: z.string() +}); + +export type TCertificateSecrets = z.infer; +export type TCertificateSecretsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSecretsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts new file mode 100644 index 000000000..b635420d5 --- /dev/null +++ b/backend/src/db/schemas/certificates.ts @@ -0,0 +1,27 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificatesSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + caId: z.string().uuid(), + status: z.string(), + serialNumber: z.string(), + friendlyName: z.string(), + commonName: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + revocationReason: z.number().nullable().optional() +}); + +export type TCertificates = z.infer; +export type TCertificatesInsert = Omit, TImmutableDBKeys>; +export type TCertificatesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 1eaa86c87..df126b7f0 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -8,6 +8,13 @@ export * from "./audit-logs"; export * from "./auth-token-sessions"; export * from "./auth-tokens"; export * from "./backup-private-key"; +export * from "./certificate-authorities"; +export * from "./certificate-authority-certs"; +export * from "./certificate-authority-crl"; +export * from "./certificate-authority-secret"; +export * from "./certificate-bodies"; +export * from "./certificate-secrets"; +export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; export * from "./git-app-install-sessions"; @@ -48,6 +55,7 @@ export * from "./project-roles"; export * from "./project-user-additional-privilege"; export * from "./project-user-membership-roles"; export * from "./projects"; +export * from "./rate-limit"; export * from "./saml-configs"; export * from "./scim-tokens"; export * from "./secret-approval-policies"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index f9c8436df..d7a5e6de1 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -2,6 +2,13 @@ import { z } from "zod"; export enum TableName { Users = "users", + CertificateAuthority = "certificate_authorities", + CertificateAuthorityCert = "certificate_authority_certs", + CertificateAuthoritySecret = "certificate_authority_secret", + CertificateAuthorityCrl = "certificate_authority_crl", + Certificate = "certificates", + CertificateBody = "certificate_bodies", + CertificateSecret = "certificate_secrets", Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", @@ -18,6 +25,7 @@ export enum TableName { IncidentContact = "incident_contacts", UserAction = "user_actions", SuperAdmin = "super_admin", + RateLimit = "rate_limit", ApiKey = "api_keys", Project = "projects", ProjectBot = "project_bots", diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 3965e24c0..91035ab8e 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -16,7 +16,9 @@ export const ProjectsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), version: z.number().default(1), - upgradeStatus: z.string().nullable().optional() + upgradeStatus: z.string().nullable().optional(), + kmsCertificateKeyId: z.string().uuid().nullable().optional(), + pitVersionLimit: z.number().default(10) }); export type TProjects = z.infer; diff --git a/backend/src/db/schemas/rate-limit.ts b/backend/src/db/schemas/rate-limit.ts new file mode 100644 index 000000000..86b8776cc --- /dev/null +++ b/backend/src/db/schemas/rate-limit.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const RateLimitSchema = z.object({ + id: z.string().uuid(), + readRateLimit: z.number().default(600), + writeRateLimit: z.number().default(200), + secretsRateLimit: z.number().default(60), + authRateLimit: z.number().default(60), + inviteUserRateLimit: z.number().default(30), + mfaRateLimit: z.number().default(20), + creationLimit: z.number().default(30), + publicEndpointLimit: z.number().default(30), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TRateLimit = z.infer; +export type TRateLimitInsert = Omit, TImmutableDBKeys>; +export type TRateLimitUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/secret-tags.ts b/backend/src/db/schemas/secret-tags.ts index f94e1e262..04bb7b752 100644 --- a/backend/src/db/schemas/secret-tags.ts +++ b/backend/src/db/schemas/secret-tags.ts @@ -15,7 +15,8 @@ export const SecretTagsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), createdBy: z.string().uuid().nullable().optional(), - projectId: z.string() + projectId: z.string(), + createdByActorType: z.string().default("user") }); export type TSecretTags = z.infer; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index 9e0b9a3b5..5134f3ee6 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -25,7 +25,8 @@ export const UsersSchema = z.object({ isEmailVerified: z.boolean().default(false).nullable().optional(), consecutiveFailedMfaAttempts: z.number().default(0).nullable().optional(), isLocked: z.boolean().default(false).nullable().optional(), - temporaryLockDateEnd: z.date().nullable().optional() + temporaryLockDateEnd: z.date().nullable().optional(), + consecutiveFailedPasswordAttempts: z.number().default(0).nullable().optional() }); export type TUsers = z.infer; diff --git a/backend/src/ee/routes/v1/certificate-authority-crl-router.ts b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts new file mode 100644 index 000000000..10792f508 --- /dev/null +++ b/backend/src/ee/routes/v1/certificate-authority-crl-router.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerCaCrlRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:caId/crl", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CRL of the CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CRL.caId) + }), + response: { + 200: z.object({ + crl: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CRL.crl) + }) + } + }, + handler: async (req) => { + const { crl, ca } = await server.services.certificateAuthorityCrl.getCaCrl({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CRL, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + crl + }; + } + }); + + // server.route({ + // method: "GET", + // url: "/:caId/crl/rotate", + // config: { + // rateLimit: writeLimit + // }, + // onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + // schema: { + // description: "Rotate CRL of the CA", + // params: z.object({ + // caId: z.string().trim() + // }), + // response: { + // 200: z.object({ + // message: z.string() + // }) + // } + // }, + // handler: async (req) => { + // await server.services.certificateAuthority.rotateCaCrl({ + // caId: req.params.caId, + // actor: req.permission.type, + // actorId: req.permission.id, + // actorAuthMethod: req.permission.authMethod, + // actorOrgId: req.permission.orgId + // }); + // return { + // message: "Successfully rotated CA CRL" + // }; + // } + // }); +}; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 16e23eb88..d04bd86fd 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,6 +1,7 @@ import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; import { registerAuditLogStreamRouter } from "./audit-log-stream-router"; +import { registerCaCrlRouter } from "./certificate-authority-crl-router"; import { registerDynamicSecretLeaseRouter } from "./dynamic-secret-lease-router"; import { registerDynamicSecretRouter } from "./dynamic-secret-router"; import { registerGroupRouter } from "./group-router"; @@ -10,6 +11,7 @@ import { registerLicenseRouter } from "./license-router"; import { registerOrgRoleRouter } from "./org-role-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; +import { registerRateLimitRouter } from "./rate-limit-router"; import { registerSamlRouter } from "./saml-router"; import { registerScimRouter } from "./scim-router"; import { registerSecretApprovalPolicyRouter } from "./secret-approval-policy-router"; @@ -45,6 +47,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register(registerAccessApprovalPolicyRouter, { prefix: "/access-approvals/policies" }); await server.register(registerAccessApprovalRequestRouter, { prefix: "/access-approvals/requests" }); + await server.register(registerRateLimitRouter, { prefix: "/rate-limit" }); await server.register( async (dynamicSecretRouter) => { @@ -54,6 +57,13 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { { prefix: "/dynamic-secrets" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaCrlRouter, { prefix: "/ca" }); + }, + { prefix: "/pki" } + ); + await server.register(registerSamlRouter, { prefix: "/sso" }); await server.register(registerScimRouter, { prefix: "/scim" }); await server.register(registerLdapRouter, { prefix: "/ldap" }); diff --git a/backend/src/ee/routes/v1/rate-limit-router.ts b/backend/src/ee/routes/v1/rate-limit-router.ts new file mode 100644 index 000000000..2b08a0c32 --- /dev/null +++ b/backend/src/ee/routes/v1/rate-limit-router.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { RateLimitSchema } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerRateLimitRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + rateLimit: RateLimitSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async () => { + const rateLimit = await server.services.rateLimit.getRateLimits(); + if (!rateLimit) { + throw new BadRequestError({ + name: "Get Rate Limit Error", + message: "Rate limit configuration does not exist." + }); + } + return { rateLimit }; + } + }); + + server.route({ + method: "PUT", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + + schema: { + body: z.object({ + readRateLimit: z.number(), + writeRateLimit: z.number(), + secretsRateLimit: z.number(), + authRateLimit: z.number(), + inviteUserRateLimit: z.number(), + mfaRateLimit: z.number(), + creationLimit: z.number(), + publicEndpointLimit: z.number() + }), + response: { + 200: z.object({ + rateLimit: RateLimitSchema + }) + } + }, + handler: async (req) => { + const rateLimit = await server.services.rateLimit.updateRateLimit(req.body); + return { rateLimit }; + } + }); +}; diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 8965c28f3..0a45486ef 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -362,6 +362,7 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { const groups = await req.server.services.scim.listScimGroups({ orgId: req.permission.orgId, startIndex: req.query.startIndex, + filter: req.query.filter, limit: req.query.count }); diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index f6a955625..b09b58e26 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -1,6 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; +import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; @@ -19,7 +20,11 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi workspaceId: z.string(), name: z.string().optional(), environment: z.string(), - secretPath: z.string().optional().nullable(), + secretPath: z + .string() + .optional() + .nullable() + .transform((val) => (val ? removeTrailingSlash(val) : val)), approvers: z.string().array().min(1), approvals: z.number().min(1).default(1) }) @@ -63,7 +68,11 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi name: z.string().optional(), approvers: z.string().array().min(1), approvals: z.number().min(1).default(1), - secretPath: z.string().optional().nullable() + secretPath: z + .string() + .optional() + .nullable() + .transform((val) => (val ? removeTrailingSlash(val) : val)) }) .refine((data) => data.approvals <= data.approvers.length, { path: ["approvals"], @@ -157,7 +166,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi querystring: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), - secretPath: z.string().trim() + secretPath: z.string().trim().transform(removeTrailingSlash) }), response: { 200: z.object({ 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 415814998..8a0d2aef9 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1,5 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; export type TListProjectAuditLogDTO = { @@ -104,7 +105,21 @@ export enum EventType { SECRET_APPROVAL_MERGED = "secret-approval-merged", SECRET_APPROVAL_REQUEST = "secret-approval-request", SECRET_APPROVAL_CLOSED = "secret-approval-closed", - SECRET_APPROVAL_REOPENED = "secret-approval-reopened" + SECRET_APPROVAL_REOPENED = "secret-approval-reopened", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } interface UserActorMetadata { @@ -843,6 +858,125 @@ interface SecretApprovalRequest { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -910,4 +1044,18 @@ export type Event = | SecretApprovalMerge | SecretApprovalClosed | SecretApprovalRequest - | SecretApprovalReopened; + | SecretApprovalReopened + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts new file mode 100644 index 000000000..d367e1616 --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCrlDALFactory = ReturnType; + +export const certificateAuthorityCrlDALFactory = (db: TDbClient) => { + const caCrlOrm = ormify(db, TableName.CertificateAuthorityCrl); + return caCrlOrm; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts new file mode 100644 index 000000000..c8b56561e --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-service.ts @@ -0,0 +1,172 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TGetCrl } from "./certificate-authority-crl-types"; + +type TCertificateAuthorityCrlServiceFactoryDep = { + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TCertificateAuthorityCrlServiceFactory = ReturnType; + +export const certificateAuthorityCrlServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService +}: TCertificateAuthorityCrlServiceFactoryDep) => { + /** + * Return the Certificate Revocation List (CRL) for CA with id [caId] + */ + const getCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCrl) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.caCrl) + throw new BadRequestError({ + message: + "Failed to get CA certificate revocation list (CRL) due to plan restriction. Upgrade plan to get the CA CRL." + }); + + const caCrl = await certificateAuthorityCrlDAL.findOne({ caId: ca.id }); + if (!caCrl) throw new BadRequestError({ message: "CRL not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCrl = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCrl.encryptedCrl + }); + + const crl = new x509.X509Crl(decryptedCrl); + + const base64crl = crl.toString("base64"); + const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + return { + crl: crlPem, + ca + }; + }; + + // const rotateCaCrl = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TRotateCrlDTO) => { + // const ca = await certificateAuthorityDAL.findById(caId); + // if (!ca) throw new BadRequestError({ message: "CA not found" }); + + // const { permission } = await permissionService.getProjectPermission( + // actor, + // actorId, + // ca.projectId, + // actorAuthMethod, + // actorOrgId + // ); + + // ForbiddenError.from(permission).throwUnlessCan( + // ProjectPermissionActions.Read, + // ProjectPermissionSub.CertificateAuthorities + // ); + + // const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + // const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + // const keyId = await getProjectKmsCertificateKeyId({ + // projectId: ca.projectId, + // projectDAL, + // kmsService + // }); + + // const privateKey = await kmsService.decrypt({ + // kmsId: keyId, + // cipherTextBlob: caSecret.encryptedPrivateKey + // }); + + // const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + // const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + // "sign" + // ]); + + // const revokedCerts = await certificateDAL.find({ + // caId: ca.id, + // status: CertStatus.REVOKED + // }); + + // const crl = await x509.X509CrlGenerator.create({ + // issuer: ca.dn, + // thisUpdate: new Date(), + // nextUpdate: new Date("2025/12/12"), + // entries: revokedCerts.map((revokedCert) => { + // return { + // serialNumber: revokedCert.serialNumber, + // revocationDate: new Date(revokedCert.revokedAt as Date), + // reason: revokedCert.revocationReason as number, + // invalidity: new Date("2022/01/01"), + // issuer: ca.dn + // }; + // }), + // signingAlgorithm: alg, + // signingKey: sk + // }); + + // const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + // kmsId: keyId, + // plainText: Buffer.from(new Uint8Array(crl.rawData)) + // }); + + // await certificateAuthorityCrlDAL.update( + // { + // caId: ca.id + // }, + // { + // encryptedCrl + // } + // ); + + // const base64crl = crl.toString("base64"); + // const crlPem = `-----BEGIN X509 CRL-----\n${base64crl.match(/.{1,64}/g)?.join("\n")}\n-----END X509 CRL-----`; + + // return { + // crl: crlPem + // }; + // }; + + return { + getCaCrl + // rotateCaCrl + }; +}; diff --git a/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts new file mode 100644 index 000000000..fc31e9eef --- /dev/null +++ b/backend/src/ee/services/certificate-authority-crl/certificate-authority-crl-types.ts @@ -0,0 +1,5 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TGetCrl = { + caId: string; +} & Omit; diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 6773c9486..dd49bd0ae 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -77,7 +77,7 @@ type TLdapConfigServiceFactoryDep = { >; userAliasDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; }; export type TLdapConfigServiceFactory = ReturnType; @@ -510,6 +510,7 @@ export const ldapConfigServiceFactory = ({ return newUserAlias; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const user = await userDAL.transaction(async (tx) => { const newUser = await userDAL.findOne({ id: userAlias.userId }, tx); diff --git a/backend/src/ee/services/license/__mocks__/licence-fns.ts b/backend/src/ee/services/license/__mocks__/licence-fns.ts index b5cbf103e..ddbffba45 100644 --- a/backend/src/ee/services/license/__mocks__/licence-fns.ts +++ b/backend/src/ee/services/license/__mocks__/licence-fns.ts @@ -25,6 +25,7 @@ export const getDefaultOnPremFeatures = () => { trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }; }; diff --git a/backend/src/ee/services/license/licence-fns.ts b/backend/src/ee/services/license/licence-fns.ts index 189a3c4e0..9d2c5a472 100644 --- a/backend/src/ee/services/license/licence-fns.ts +++ b/backend/src/ee/services/license/licence-fns.ts @@ -34,7 +34,8 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true + secretRotation: true, + caCrl: false }); export const setupLicenceRequestWithStore = (baseURL: string, refreshUrl: string, licenseKey: string) => { diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 47b46d010..46931468f 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -575,6 +575,9 @@ export const licenseServiceFactory = ({ getInstanceType() { return instanceType; }, + get onPremFeatures() { + return onPremFeatures; + }, getPlan, updateSubscriptionOrgMemberCount, refreshPlan, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 0c8fdc197..e23ff2c84 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -52,6 +52,7 @@ export type TFeatureSet = { has_used_trial: true; secretApproval: false; secretRotation: true; + caCrl: false; }; export type TOrgPlansTableDTO = { diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b24024bd4..4853faf61 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -26,7 +26,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -53,6 +55,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] | [ProjectPermissionActions, ProjectPermissionSub.Identity] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -139,6 +143,17 @@ const buildAdminPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Delete, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Create, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Project); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Project); @@ -205,6 +220,14 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + // double check if all CRUD are needed for CA and Certificates + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + return rules; }; @@ -229,6 +252,8 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); + can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); return rules; }; diff --git a/backend/src/ee/services/rate-limit/rate-limit-dal.ts b/backend/src/ee/services/rate-limit/rate-limit-dal.ts new file mode 100644 index 000000000..7279ff8ea --- /dev/null +++ b/backend/src/ee/services/rate-limit/rate-limit-dal.ts @@ -0,0 +1,7 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TRateLimitDALFactory = ReturnType; + +export const rateLimitDALFactory = (db: TDbClient) => ormify(db, TableName.RateLimit, {}); diff --git a/backend/src/ee/services/rate-limit/rate-limit-service.ts b/backend/src/ee/services/rate-limit/rate-limit-service.ts new file mode 100644 index 000000000..df90ca03f --- /dev/null +++ b/backend/src/ee/services/rate-limit/rate-limit-service.ts @@ -0,0 +1,106 @@ +import { CronJob } from "cron"; + +import { logger } from "@app/lib/logger"; + +import { TLicenseServiceFactory } from "../license/license-service"; +import { TRateLimitDALFactory } from "./rate-limit-dal"; +import { TRateLimit, TRateLimitUpdateDTO } from "./rate-limit-types"; + +let rateLimitMaxConfiguration = { + readLimit: 60, + publicEndpointLimit: 30, + writeLimit: 200, + secretsLimit: 60, + authRateLimit: 60, + inviteUserRateLimit: 30, + mfaRateLimit: 20, + creationLimit: 30 +}; + +Object.freeze(rateLimitMaxConfiguration); + +export const getRateLimiterConfig = () => { + return rateLimitMaxConfiguration; +}; + +type TRateLimitServiceFactoryDep = { + rateLimitDAL: TRateLimitDALFactory; + licenseService: Pick; +}; + +export type TRateLimitServiceFactory = ReturnType; + +export const rateLimitServiceFactory = ({ rateLimitDAL, licenseService }: TRateLimitServiceFactoryDep) => { + const DEFAULT_RATE_LIMIT_CONFIG_ID = "00000000-0000-0000-0000-000000000000"; + + const getRateLimits = async (): Promise => { + let rateLimit: TRateLimit; + + try { + rateLimit = await rateLimitDAL.findOne({ id: DEFAULT_RATE_LIMIT_CONFIG_ID }); + if (!rateLimit) { + // rate limit might not exist + rateLimit = await rateLimitDAL.create({ + // @ts-expect-error id is kept as fixed because there should only be one rate limit config per instance + id: DEFAULT_RATE_LIMIT_CONFIG_ID + }); + } + return rateLimit; + } catch (err) { + logger.error("Error fetching rate limits %o", err); + return undefined; + } + }; + + const updateRateLimit = async (updates: TRateLimitUpdateDTO): Promise => { + return rateLimitDAL.updateById(DEFAULT_RATE_LIMIT_CONFIG_ID, updates); + }; + + const syncRateLimitConfiguration = async () => { + try { + const rateLimit = await getRateLimits(); + if (rateLimit) { + const newRateLimitMaxConfiguration: typeof rateLimitMaxConfiguration = { + readLimit: rateLimit.readRateLimit, + publicEndpointLimit: rateLimit.publicEndpointLimit, + writeLimit: rateLimit.writeRateLimit, + secretsLimit: rateLimit.secretsRateLimit, + authRateLimit: rateLimit.authRateLimit, + inviteUserRateLimit: rateLimit.inviteUserRateLimit, + mfaRateLimit: rateLimit.mfaRateLimit, + creationLimit: rateLimit.creationLimit + }; + + logger.info(`syncRateLimitConfiguration: rate limit configuration: %o`, newRateLimitMaxConfiguration); + Object.freeze(newRateLimitMaxConfiguration); + rateLimitMaxConfiguration = newRateLimitMaxConfiguration; + } + } catch (error) { + logger.error(`Error syncing rate limit configurations: %o`, error); + } + }; + + const initializeBackgroundSync = async () => { + if (!licenseService.onPremFeatures.customRateLimits) { + logger.info("Current license does not support custom rate limit configuration"); + return; + } + + logger.info("Setting up background sync process for rate limits"); + // initial sync upon startup + await syncRateLimitConfiguration(); + + // sync rate limits configuration every 10 minutes + const job = new CronJob("*/10 * * * *", syncRateLimitConfiguration); + job.start(); + + return job; + }; + + return { + getRateLimits, + updateRateLimit, + initializeBackgroundSync, + syncRateLimitConfiguration + }; +}; diff --git a/backend/src/ee/services/rate-limit/rate-limit-types.ts b/backend/src/ee/services/rate-limit/rate-limit-types.ts new file mode 100644 index 000000000..19519aafb --- /dev/null +++ b/backend/src/ee/services/rate-limit/rate-limit-types.ts @@ -0,0 +1,16 @@ +export type TRateLimitUpdateDTO = { + readRateLimit: number; + writeRateLimit: number; + secretsRateLimit: number; + authRateLimit: number; + inviteUserRateLimit: number; + mfaRateLimit: number; + creationLimit: number; + publicEndpointLimit: number; +}; + +export type TRateLimit = { + id: string; + createdAt: Date; + updatedAt: Date; +} & TRateLimitUpdateDTO; 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 7dfd211e1..5d7b7ec3b 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -50,7 +50,7 @@ type TSamlConfigServiceFactoryDep = { orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; tokenService: Pick; smtpService: Pick; }; @@ -449,6 +449,7 @@ export const samlConfigServiceFactory = ({ return newUser; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const isUserCompleted = Boolean(user.isAccepted); const providerAuthToken = jwt.sign( diff --git a/backend/src/ee/services/scim/scim-fns.ts b/backend/src/ee/services/scim/scim-fns.ts index ec54a4d1f..08b652185 100644 --- a/backend/src/ee/services/scim/scim-fns.ts +++ b/backend/src/ee/services/scim/scim-fns.ts @@ -18,6 +18,20 @@ export const buildScimUserList = ({ }; }; +export const parseScimFilter = (filterToParse: string | undefined) => { + if (!filterToParse) return {}; + const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim()); + + let attributeName = parsedName; + if (parsedName === "userName") { + attributeName = "email"; + } else if (parsedName === "displayName") { + attributeName = "name"; + } + + return { [attributeName]: parsedValue.replace(/"/g, "") }; +}; + export const buildScimUser = ({ orgMembershipId, username, diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index 9a084c6d7..dc175f15b 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -30,7 +30,7 @@ import { UserAliasType } from "@app/services/user-alias/user-alias-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList } from "./scim-fns"; +import { buildScimGroup, buildScimGroupList, buildScimUser, buildScimUserList, parseScimFilter } from "./scim-fns"; import { TCreateScimGroupDTO, TCreateScimTokenDTO, @@ -184,18 +184,6 @@ export const scimServiceFactory = ({ status: 403 }); - const parseFilter = (filterToParse: string | undefined) => { - if (!filterToParse) return {}; - const [parsedName, parsedValue] = filterToParse.split("eq").map((s) => s.trim()); - - let attributeName = parsedName; - if (parsedName === "userName") { - attributeName = "email"; - } - - return { [attributeName]: parsedValue.replace(/"/g, "") }; - }; - const findOpts = { ...(startIndex && { offset: startIndex - 1 }), ...(limit && { limit }) @@ -204,7 +192,7 @@ export const scimServiceFactory = ({ const users = await orgDAL.findMembership( { [`${TableName.OrgMembership}.orgId` as "id"]: orgId, - ...parseFilter(filter) + ...parseScimFilter(filter) }, findOpts ); @@ -391,7 +379,7 @@ export const scimServiceFactory = ({ ); } } - + await licenseService.updateSubscriptionOrgMemberCount(org.id); return { user, orgMembership }; }); @@ -557,7 +545,7 @@ export const scimServiceFactory = ({ return {}; // intentionally return empty object upon success }; - const listScimGroups = async ({ orgId, startIndex, limit }: TListScimGroupsDTO) => { + const listScimGroups = async ({ orgId, startIndex, limit, filter }: TListScimGroupsDTO) => { const plan = await licenseService.getPlan(orgId); if (!plan.groups) throw new BadRequestError({ @@ -580,7 +568,8 @@ export const scimServiceFactory = ({ const groups = await groupDAL.findGroups( { - orgId + orgId, + ...(filter && parseScimFilter(filter)) }, { offset: startIndex - 1, diff --git a/backend/src/ee/services/scim/scim-types.ts b/backend/src/ee/services/scim/scim-types.ts index 46ab90b8f..cffc80407 100644 --- a/backend/src/ee/services/scim/scim-types.ts +++ b/backend/src/ee/services/scim/scim-types.ts @@ -66,6 +66,7 @@ export type TDeleteScimUserDTO = { export type TListScimGroupsDTO = { startIndex: number; + filter?: string; limit: number; orgId: string; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 8ddadb9bf..f99384de6 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -4,6 +4,7 @@ import picomatch from "picomatch"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -207,7 +208,8 @@ export const secretApprovalPolicyServiceFactory = ({ return sapPolicies; }; - const getSecretApprovalPolicy = async (projectId: string, environment: string, secretPath: string) => { + const getSecretApprovalPolicy = async (projectId: string, environment: string, path: string) => { + const secretPath = removeTrailingSlash(path); const env = await projectEnvDAL.findOne({ slug: environment, projectId }); if (!env) throw new BadRequestError({ message: "Environment not found" }); diff --git a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts index bd8750577..3e1142969 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -81,8 +81,7 @@ export const secretSnapshotServiceFactory = ({ const folder = await folderDAL.findBySecretPath(projectId, environment, path); if (!folder) throw new BadRequestError({ message: "Folder not found" }); - const count = await snapshotDAL.countOfSnapshotsByFolderId(folder.id); - return count; + return snapshotDAL.countOfSnapshotsByFolderId(folder.id); }; const listSnapshots = async ({ diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index cdd5a999b..92c6b611d 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { Knex } from "knex"; import { TDbClient } from "@app/db"; @@ -11,6 +12,7 @@ import { } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { logger } from "@app/lib/logger"; export type TSnapshotDALFactory = ReturnType; @@ -325,12 +327,152 @@ export const snapshotDALFactory = (db: TDbClient) => { } }; + /** + * Prunes excess snapshots from the database to ensure only a specified number of recent snapshots are retained for each folder. + * + * This function operates in three main steps: + * 1. Pruning snapshots from root/non-versioned folders. + * 2. Pruning snapshots from versioned folders. + * 3. Removing orphaned snapshots that do not belong to any existing folder or folder version. + * + * The function processes snapshots in batches, determined by the `PRUNE_FOLDER_BATCH_SIZE` constant, + * to manage the large datasets without overwhelming the DB. + * + * Steps: + * - Fetch a batch of folder IDs. + * - For each batch, use a Common Table Expression (CTE) to rank snapshots within each folder by their creation date. + * - Identify and delete snapshots that exceed the project's point-in-time version limit (`pitVersionLimit`). + * - Repeat the process for versioned folders. + * - Finally, delete orphaned snapshots that do not have an associated folder. + */ + const pruneExcessSnapshots = async () => { + const PRUNE_FOLDER_BATCH_SIZE = 10000; + + try { + let uuidOffset = "00000000-0000-0000-0000-000000000000"; + // cleanup snapshots from root/non-versioned folders + // eslint-disable-next-line no-constant-condition, no-unreachable-loop + while (true) { + const folderBatch = await db(TableName.SecretFolder) + .where("id", ">", uuidOffset) + .where("isReserved", false) + .orderBy("id", "asc") + .limit(PRUNE_FOLDER_BATCH_SIZE) + .select("id"); + + const batchEntries = folderBatch.map((folder) => folder.id); + + if (folderBatch.length) { + try { + logger.info(`Pruning snapshots in [range=${batchEntries[0]}:${batchEntries[batchEntries.length - 1]}]`); + await db(TableName.Snapshot) + .with("snapshot_cte", (qb) => { + void qb + .from(TableName.Snapshot) + .whereIn(`${TableName.Snapshot}.folderId`, batchEntries) + .select( + "folderId", + `${TableName.Snapshot}.id as id`, + db.raw( + `ROW_NUMBER() OVER (PARTITION BY ${TableName.Snapshot}."folderId" ORDER BY ${TableName.Snapshot}."createdAt" DESC) AS row_num` + ) + ); + }) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.Snapshot}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) + .join("snapshot_cte", "snapshot_cte.id", `${TableName.Snapshot}.id`) + .whereNull(`${TableName.SecretFolder}.parentId`) + .whereRaw(`snapshot_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (err) { + logger.error( + `Failed to prune snapshots from root/non-versioned folders in range ${batchEntries[0]}:${ + batchEntries[batchEntries.length - 1] + }` + ); + } finally { + uuidOffset = batchEntries[batchEntries.length - 1]; + } + } else { + break; + } + } + + // cleanup snapshots from versioned folders + uuidOffset = "00000000-0000-0000-0000-000000000000"; + // eslint-disable-next-line no-constant-condition + while (true) { + const folderBatch = await db(TableName.SecretFolderVersion) + .select("folderId") + .distinct("folderId") + .where("folderId", ">", uuidOffset) + .orderBy("folderId", "asc") + .limit(PRUNE_FOLDER_BATCH_SIZE); + + const batchEntries = folderBatch.map((folder) => folder.folderId); + + if (folderBatch.length) { + try { + logger.info(`Pruning snapshots in range ${batchEntries[0]}:${batchEntries[batchEntries.length - 1]}`); + await db(TableName.Snapshot) + .with("snapshot_cte", (qb) => { + void qb + .from(TableName.Snapshot) + .whereIn(`${TableName.Snapshot}.folderId`, batchEntries) + .select( + "folderId", + `${TableName.Snapshot}.id as id`, + db.raw( + `ROW_NUMBER() OVER (PARTITION BY ${TableName.Snapshot}."folderId" ORDER BY ${TableName.Snapshot}."createdAt" DESC) AS row_num` + ) + ); + }) + .join( + TableName.SecretFolderVersion, + `${TableName.SecretFolderVersion}.folderId`, + `${TableName.Snapshot}.folderId` + ) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolderVersion}.envId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) + .join("snapshot_cte", "snapshot_cte.id", `${TableName.Snapshot}.id`) + .whereRaw(`snapshot_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (err) { + logger.error( + `Failed to prune snapshots from versioned folders in range ${batchEntries[0]}:${ + batchEntries[batchEntries.length - 1] + }` + ); + } finally { + uuidOffset = batchEntries[batchEntries.length - 1]; + } + } else { + break; + } + } + + // cleanup orphaned snapshots (those that don't belong to an existing folder and folder version) + await db(TableName.Snapshot) + .whereNotIn("folderId", (qb) => { + void qb + .select("folderId") + .from(TableName.SecretFolderVersion) + .union((qb1) => void qb1.select("id").from(TableName.SecretFolder)); + }) + .delete(); + } catch (error) { + throw new DatabaseError({ error, name: "SnapshotPrune" }); + } + }; + return { ...secretSnapshotOrm, findById, findLatestSnapshotByFolderId, findRecursivelySnapshots, countOfSnapshotsByFolderId, - findSecretSnapshotDataById + findSecretSnapshotDataById, + pruneExcessSnapshots }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index da82016f1..7cbfd88a0 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -343,7 +343,8 @@ export const RAW_SECRETS = { secretValue: "The value of the secret to create.", skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to create.", - workspaceId: "The ID of the project to create the secret in." + workspaceId: "The ID of the project to create the secret in.", + tagIds: "The ID of the tags to be attached to the created secret." }, GET: { secretName: "The name of the secret to get.", @@ -364,7 +365,8 @@ export const RAW_SECRETS = { skipMultilineEncoding: "Skip multiline encoding for the secret value.", type: "The type of the secret to update.", projectSlug: "The slug of the project to update the secret in.", - workspaceId: "The ID of the project to update the secret in." + workspaceId: "The ID of the project to update the secret in.", + tagIds: "The ID of the tags to be attached to the updated secret." }, DELETE: { secretName: "The name of the secret to delete.", @@ -386,6 +388,8 @@ export const SECRET_IMPORTS = { environment: "The slug of the environment to import into.", path: "The path to import into.", workspaceId: "The ID of the project you are working in.", + isReplication: + "When true, secrets from the source will be automatically sent to the destination. If approval policies exist at the destination, the secrets will be sent as approval requests instead of being applied immediately.", import: { environment: "The slug of the environment to import from.", path: "The path to import from." @@ -674,7 +678,10 @@ export const INTEGRATION = { secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", kmsKeyId: "The ID of the encryption key from AWS KMS.", - shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store." + shouldDisableDelete: "The flag to disable deletion of secrets in AWS Parameter Store.", + shouldMaskSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Masked'.", + shouldProtectSecrets: "Specifies if the secrets synced from Infisical to Gitlab should be marked as 'Protected'.", + shouldEnableDelete: "The flag to enable deletion of secrets" } }, UPDATE: { @@ -723,6 +730,102 @@ export const AUDIT_LOG_STREAMS = { } }; +export const CERTIFICATE_AUTHORITIES = { + CREATE: { + projectSlug: "Slug of the project to create the CA in.", + type: "The type of CA to create", + friendlyName: "A friendly name for the CA", + organization: "The organization (O) for the CA", + ou: "The organization unit (OU) for the CA", + country: "The country name (C) for the CA", + province: "The state of province name for the CA", + locality: "The locality name for the CA", + commonName: "The common name (CN) for the CA", + notBefore: "The date and time when the CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + keyAlgorithm: + "The type of public key algorithm and size, in bits, of the key pair for the CA; when you create an intermediate CA, you must use a key algorithm supported by the parent CA." + }, + GET: { + caId: "The ID of the CA to get" + }, + UPDATE: { + caId: "The ID of the CA to update", + status: "The status of the CA to update to. This can be one of active or disabled" + }, + DELETE: { + caId: "The ID of the CA to delete" + }, + GET_CSR: { + caId: "The ID of the CA to generate CSR from", + csr: "The generated CSR from the CA" + }, + GET_CERT: { + caId: "The ID of the CA to get the certificate body and certificate chain from", + certificate: "The certificate body of the CA", + certificateChain: "The certificate chain of the CA", + serialNumber: "The serial number of the CA certificate" + }, + SIGN_INTERMEDIATE: { + caId: "The ID of the CA to sign the intermediate certificate with", + csr: "The CSR to sign with the CA", + notBefore: "The date and time when the intermediate CA becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the intermediate CA expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + maxPathLength: + "The maximum number of intermediate CAs that may follow this CA in the certificate / CA chain. A maxPathLength of -1 implies no path limit on the chain.", + certificate: "The signed intermediate certificate", + certificateChain: "The certificate chain of the intermediate certificate", + issuingCaCertificate: "The certificate of the issuing CA", + serialNumber: "The serial number of the intermediate certificate" + }, + IMPORT_CERT: { + caId: "The ID of the CA to import the certificate for", + certificate: "The certificate body to import", + certificateChain: "The certificate chain to import" + }, + ISSUE_CERT: { + caId: "The ID of the CA to issue the certificate from", + friendlyName: "A friendly name for the certificate", + commonName: "The common name (CN) for the certificate", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, 1y, ...", + notBefore: "The date and time when the certificate becomes valid in YYYY-MM-DDTHH:mm:ss.sssZ format", + notAfter: "The date and time when the certificate expires in YYYY-MM-DDTHH:mm:ss.sssZ format", + certificate: "The issued certificate", + issuingCaCertificate: "The certificate of the issuing CA", + certificateChain: "The certificate chain of the issued certificate", + privateKey: "The private key of the issued certificate", + serialNumber: "The serial number of the issued certificate" + }, + GET_CRL: { + caId: "The ID of the CA to get the certificate revocation list (CRL) for", + crl: "The certificate revocation list (CRL) of the CA" + } +}; + +export const CERTIFICATES = { + GET: { + serialNumber: "The serial number of the certificate to get" + }, + REVOKE: { + serialNumber: + "The serial number of the certificate to revoke. The revoked certificate will be added to the certificate revocation list (CRL) of the CA.", + revocationReason: "The reason for revoking the certificate.", + revokedAt: "The date and time when the certificate was revoked", + serialNumberRes: "The serial number of the revoked certificate." + }, + DELETE: { + serialNumber: "The serial number of the certificate to delete" + }, + GET_CERT: { + serialNumber: "The serial number of the certificate to get the certificate body and certificate chain for", + certificate: "The certificate body of the certificate", + certificateChain: "The certificate chain of the certificate", + serialNumberRes: "The serial number of the certificate" + } +}; + export const PROJECT_ROLE = { CREATE: { projectSlug: "Slug of the project to create the role for.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 2caae9ec5..80a2111fc 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -39,7 +39,9 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, // smtp options SMTP_HOST: zpStr(z.string().optional()), - SMTP_SECURE: zodStrBool, + SMTP_IGNORE_TLS: zodStrBool.default("false"), + SMTP_REQUIRE_TLS: zodStrBool.default("true"), + SMTP_TLS_REJECT_UNAUTHORIZED: zodStrBool.default("true"), SMTP_PORT: z.coerce.number().default(587), SMTP_USERNAME: zpStr(z.string().optional()), SMTP_PASSWORD: zpStr(z.string().optional()), @@ -120,7 +122,8 @@ const envSchema = z .transform((val) => val === "true") .optional(), INFISICAL_CLOUD: zodStrBool.default("false"), - MAINTENANCE_MODE: zodStrBool.default("false") + MAINTENANCE_MODE: zodStrBool.default("false"), + CAPTCHA_SECRET: zpStr(z.string().optional()) }) .transform((data) => ({ ...data, @@ -152,13 +155,20 @@ export const initEnvConfig = (logger: Logger) => { return envCfg; }; -export const formatSmtpConfig = () => ({ - host: envCfg.SMTP_HOST, - port: envCfg.SMTP_PORT, - auth: - envCfg.SMTP_USERNAME && envCfg.SMTP_PASSWORD - ? { user: envCfg.SMTP_USERNAME, pass: envCfg.SMTP_PASSWORD } - : undefined, - secure: envCfg.SMTP_SECURE, - from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>` -}); +export const formatSmtpConfig = () => { + return { + host: envCfg.SMTP_HOST, + port: envCfg.SMTP_PORT, + auth: + envCfg.SMTP_USERNAME && envCfg.SMTP_PASSWORD + ? { user: envCfg.SMTP_USERNAME, pass: envCfg.SMTP_PASSWORD } + : undefined, + secure: envCfg.SMTP_PORT === 465, + from: `"${envCfg.SMTP_FROM_NAME}" <${envCfg.SMTP_FROM_ADDRESS}>`, + ignoreTLS: envCfg.SMTP_IGNORE_TLS, + requireTLS: envCfg.SMTP_REQUIRE_TLS, + tls: { + rejectUnauthorized: envCfg.SMTP_TLS_REJECT_UNAUTHORIZED + } + }; +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7046058b7..d51a8e683 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -23,6 +23,7 @@ export enum QueueName { SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", + CaCrlRotation = "ca-crl-rotation", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -41,6 +42,7 @@ export enum QueueJobs { UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", DynamicSecretPruning = "dynamic-secret-pruning", + CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } @@ -55,7 +57,6 @@ export type TQueueJobTypes = { }; name: QueueJobs.SecretReminder; }; - [QueueName.SecretRotation]: { payload: { rotationId: string }; name: QueueJobs.SecretRotation; @@ -121,6 +122,12 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.CaCrlRotation]: { + name: QueueJobs.CaCrlRotation; + payload: { + caId: string; + }; + }; [QueueName.SecretReplication]: { name: QueueJobs.SecretReplication; payload: TSyncSecretsDTO; diff --git a/backend/src/server/app.ts b/backend/src/server/app.ts index 51cef185a..863162c2b 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -71,6 +71,7 @@ export const main = async ({ db, smtp, logger, queue, keyStore }: TMain) => { if (appCfg.isProductionMode) { await server.register(ratelimiter, globalRateLimiterCfg()); } + await server.register(helmet, { contentSecurityPolicy: false }); await server.register(maintenanceMode); diff --git a/backend/src/server/boot-strap-check.ts b/backend/src/server/boot-strap-check.ts index 381e575ef..ceaef59e7 100644 --- a/backend/src/server/boot-strap-check.ts +++ b/backend/src/server/boot-strap-check.ts @@ -5,7 +5,6 @@ import { createTransport } from "nodemailer"; import { formatSmtpConfig, getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; -import { getTlsOption } from "@app/services/smtp/smtp-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; type BootstrapOpt = { @@ -44,7 +43,7 @@ export const bootstrapCheck = async ({ db }: BootstrapOpt) => { console.info("Testing smtp connection"); const smtpCfg = formatSmtpConfig(); - await createTransport({ ...smtpCfg, ...getTlsOption(smtpCfg.host, smtpCfg.secure) }) + await createTransport(smtpCfg) .verify() .then(async () => { console.info("SMTP successfully connected"); diff --git a/backend/src/server/config/rateLimiter.ts b/backend/src/server/config/rateLimiter.ts index 0e40f286f..ad54a151a 100644 --- a/backend/src/server/config/rateLimiter.ts +++ b/backend/src/server/config/rateLimiter.ts @@ -1,6 +1,7 @@ import type { RateLimitOptions, RateLimitPluginOptions } from "@fastify/rate-limit"; import { Redis } from "ioredis"; +import { getRateLimiterConfig } from "@app/ee/services/rate-limit/rate-limit-service"; import { getConfig } from "@app/lib/config/env"; export const globalRateLimiterCfg = (): RateLimitPluginOptions => { @@ -21,14 +22,14 @@ export const globalRateLimiterCfg = (): RateLimitPluginOptions => { // GET endpoints export const readLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 600, + max: () => getRateLimiterConfig().readLimit, keyGenerator: (req) => req.realIp }; // POST, PATCH, PUT, DELETE endpoints export const writeLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 200, // (too low, FA having issues so increasing it - maidul) + max: () => getRateLimiterConfig().writeLimit, keyGenerator: (req) => req.realIp }; @@ -36,25 +37,25 @@ export const writeLimit: RateLimitOptions = { export const secretsLimit: RateLimitOptions = { // secrets, folders, secret imports timeWindow: 60 * 1000, - max: 60, + max: () => getRateLimiterConfig().secretsLimit, keyGenerator: (req) => req.realIp }; export const authRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 60, + max: () => getRateLimiterConfig().authRateLimit, keyGenerator: (req) => req.realIp }; export const inviteUserRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 30, + max: () => getRateLimiterConfig().inviteUserRateLimit, keyGenerator: (req) => req.realIp }; export const mfaRateLimit: RateLimitOptions = { timeWindow: 60 * 1000, - max: 20, + max: () => getRateLimiterConfig().mfaRateLimit, keyGenerator: (req) => { return req.headers.authorization?.split(" ")[1] || req.realIp; } @@ -63,7 +64,7 @@ export const mfaRateLimit: RateLimitOptions = { export const creationLimit: RateLimitOptions = { // identity, project, org timeWindow: 60 * 1000, - max: 30, + max: () => getRateLimiterConfig().creationLimit, keyGenerator: (req) => req.realIp }; @@ -71,7 +72,7 @@ export const creationLimit: RateLimitOptions = { export const publicEndpointLimit: RateLimitOptions = { // Read Shared Secrets timeWindow: 60 * 1000, - max: 30, + max: () => getRateLimiterConfig().publicEndpointLimit, keyGenerator: (req) => req.realIp }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 00590386a..326fbafa6 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1,3 +1,4 @@ +import { CronJob } from "cron"; import { Knex } from "knex"; import { z } from "zod"; @@ -13,6 +14,8 @@ import { auditLogQueueServiceFactory } from "@app/ee/services/audit-log/audit-lo import { auditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; import { auditLogStreamDALFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-dal"; import { auditLogStreamServiceFactory } from "@app/ee/services/audit-log-stream/audit-log-stream-service"; +import { certificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { certificateAuthorityCrlServiceFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-service"; import { dynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { dynamicSecretServiceFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-service"; import { buildDynamicSecretProviders } from "@app/ee/services/dynamic-secret/providers"; @@ -33,6 +36,8 @@ import { permissionDALFactory } from "@app/ee/services/permission/permission-dal import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectUserAdditionalPrivilegeDALFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-dal"; import { projectUserAdditionalPrivilegeServiceFactory } from "@app/ee/services/project-user-additional-privilege/project-user-additional-privilege-service"; +import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; +import { rateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-service"; import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-dal"; import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; @@ -71,6 +76,14 @@ import { authPaswordServiceFactory } from "@app/services/auth/auth-password-serv import { authSignupServiceFactory } from "@app/services/auth/auth-signup-service"; import { tokenDALFactory } from "@app/services/auth-token/auth-token-dal"; import { tokenServiceFactory } from "@app/services/auth-token/auth-token-service"; +import { certificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { certificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { certificateServiceFactory } from "@app/services/certificate/certificate-service"; +import { certificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { certificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue"; +import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { groupProjectDALFactory } from "@app/services/group-project/group-project-dal"; import { groupProjectMembershipRoleDALFactory } from "@app/services/group-project/group-project-membership-role-dal"; import { groupProjectServiceFactory } from "@app/services/group-project/group-project-service"; @@ -185,6 +198,7 @@ export const registerRoutes = async ( const incidentContactDAL = incidentContactDALFactory(db); const orgRoleDAL = orgRoleDALFactory(db); const superAdminDAL = superAdminDALFactory(db); + const rateLimitDAL = rateLimitDALFactory(db); const apiKeyDAL = apiKeyDALFactory(db); const projectDAL = projectDALFactory(db); @@ -444,6 +458,10 @@ export const registerRoutes = async ( orgService, keyStore }); + const rateLimitService = rateLimitServiceFactory({ + rateLimitDAL, + licenseService + }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); const secretScanningQueue = secretScanningQueueFactory({ @@ -506,6 +524,58 @@ export const registerRoutes = async ( projectUserMembershipRoleDAL }); + const certificateAuthorityDAL = certificateAuthorityDALFactory(db); + const certificateAuthorityCertDAL = certificateAuthorityCertDALFactory(db); + const certificateAuthoritySecretDAL = certificateAuthoritySecretDALFactory(db); + const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); + + const certificateDAL = certificateDALFactory(db); + const certificateBodyDAL = certificateBodyDALFactory(db); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityQueue = certificateAuthorityQueueFactory({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService + }); + + const certificateAuthorityService = certificateAuthorityServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateAuthorityQueue, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService + }); + + const certificateAuthorityCrlService = certificateAuthorityCrlServiceFactory({ + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + projectDAL, + kmsService, + permissionService, + licenseService + }); + const projectService = projectServiceFactory({ permissionService, projectDAL, @@ -522,6 +592,8 @@ export const registerRoutes = async ( projectMembershipDAL, folderDAL, licenseService, + certificateAuthorityDAL, + certificateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore @@ -824,6 +896,9 @@ export const registerRoutes = async ( const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ auditLogDAL, queueService, + secretVersionDAL, + secretFolderVersionDAL: folderVersionDAL, + snapshotDAL, identityAccessTokenDAL, secretSharingDAL }); @@ -859,6 +934,7 @@ export const registerRoutes = async ( secret: secretService, secretReplication: secretReplicationService, secretTag: secretTagService, + rateLimit: rateLimitService, folder: folderService, secretImport: secretImportService, projectBot: projectBotService, @@ -886,6 +962,9 @@ export const registerRoutes = async ( ldap: ldapService, auditLog: auditLogService, auditLogStream: auditLogStreamService, + certificate: certificateService, + certificateAuthority: certificateAuthorityService, + certificateAuthorityCrl: certificateAuthorityCrlService, secretScanning: secretScanningService, license: licenseService, trustedIp: trustedIpService, @@ -897,6 +976,14 @@ export const registerRoutes = async ( secretSharing: secretSharingService }); + const cronJobs: CronJob[] = []; + if (appCfg.isProductionMode) { + const rateLimitSyncJob = await rateLimitService.initializeBackgroundSync(); + if (rateLimitSyncJob) { + cronJobs.push(rateLimitSyncJob); + } + } + server.decorate("store", { user: userDAL }); @@ -951,6 +1038,7 @@ export const registerRoutes = async ( await server.register(registerV3Routes, { prefix: "/api/v3" }); server.addHook("onClose", async () => { + cronJobs.forEach((job) => job.stop()); await telemetryService.flushAll(); }); }; diff --git a/backend/src/server/routes/v1/certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-router.ts new file mode 100644 index 000000000..7573c0bd2 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-router.ts @@ -0,0 +1,515 @@ +import ms from "ms"; +import { z } from "zod"; + +import { CertificateAuthoritiesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; + +export const registerCaRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create CA", + body: z + .object({ + projectSlug: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.projectSlug), + type: z.nativeEnum(CaType).describe(CERTIFICATE_AUTHORITIES.CREATE.type), + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.CREATE.friendlyName), + commonName: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.commonName), + organization: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.organization), + ou: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.ou), + country: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.country), + province: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.province), + locality: z.string().trim().describe(CERTIFICATE_AUTHORITIES.CREATE.locality), + // format: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.CREATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.CREATE.maxPathLength), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .default(CertKeyAlgorithm.RSA_2048) + .describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm) + }) + .refine( + (data) => { + // Check that at least one of the specified fields is non-empty + return [data.commonName, data.organization, data.ou, data.country, data.province, data.locality].some( + (field) => field !== "" + ); + }, + { + message: + "At least one of the fields commonName, organization, ou, country, province, or locality must be non-empty", + path: [] + } + ), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.createCa({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.CREATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.getCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "PATCH", + url: "/:caId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.UPDATE.caId) + }), + body: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]).optional().describe(CERTIFICATE_AUTHORITIES.UPDATE.status) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.updateCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.UPDATE_CA, + metadata: { + caId: ca.id, + dn: ca.dn, + status: ca.status as CaStatus + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:caId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.DELETE.caId) + }), + response: { + 200: z.object({ + ca: CertificateAuthoritiesSchema + }) + } + }, + handler: async (req) => { + const ca = await server.services.certificateAuthority.deleteCaById({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CA, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + ca + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/csr", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get CA CSR", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CSR.caId) + }), + response: { + 200: z.object({ + csr: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CSR.csr) + }) + } + }, + handler: async (req) => { + const { ca, csr } = await server.services.certificateAuthority.getCaCsr({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CSR, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + csr + }; + } + }); + + server.route({ + method: "GET", + url: "/:caId/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get cert and cert chain of a CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.GET_CERT.caId) + }), + response: { + 200: z.object({ + certificate: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificate), + certificateChain: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.certificateChain), + serialNumber: z.string().describe(CERTIFICATE_AUTHORITIES.GET_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, ca } = await server.services.certificateAuthority.getCaCert({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/sign-intermediate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create intermediate CA certificate from parent CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.caId) + }), + body: z.object({ + csr: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.csr), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notBefore), + notAfter: validateCaDateField.describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.notAfter), + maxPathLength: z.number().min(-1).default(-1).describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.maxPathLength) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.certificateChain), + issuingCaCertificate: z + .string() + .trim() + .describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.issuingCaCertificate), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.SIGN_INTERMEDIATE.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, serialNumber, ca } = + await server.services.certificateAuthority.signIntermediate({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.SIGN_INTERMEDIATE, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Import certificate and chain to CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.caId) + }), + body: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.IMPORT_CERT.certificateChain) + }), + response: { + 200: z.object({ + message: z.string().trim(), + caId: z.string().trim() + }) + } + }, + handler: async (req) => { + const { ca } = await server.services.certificateAuthority.importCertToCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.IMPORT_CA_CERT, + metadata: { + caId: ca.id, + dn: ca.dn + } + } + }); + + return { + message: "Successfully imported certificate to CA", + caId: req.params.caId + }; + } + }); + + server.route({ + method: "POST", + url: "/:caId/issue-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Issue certificate from CA", + params: z.object({ + caId: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.caId) + }), + body: z + .object({ + friendlyName: z.string().optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.friendlyName), + commonName: z.string().trim().min(1).describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.commonName), + ttl: z + .string() + .refine((val) => ms(val) > 0, "TTL must be a positive number") + .describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.ttl), + notBefore: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notBefore), + notAfter: validateCaDateField.optional().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.notAfter) + }) + .refine( + (data) => { + const { ttl, notAfter } = data; + return (ttl !== undefined && notAfter === undefined) || (ttl === undefined && notAfter !== undefined); + }, + { + message: "Either ttl or notAfter must be present, but not both", + path: ["ttl", "notAfter"] + } + ), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificate), + issuingCaCertificate: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.issuingCaCertificate), + certificateChain: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.certificateChain), + privateKey: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATE_AUTHORITIES.ISSUE_CERT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber, ca } = + await server.services.certificateAuthority.issueCertFromCa({ + caId: req.params.caId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.ISSUE_CERT, + metadata: { + caId: ca.id, + dn: ca.dn, + serialNumber + } + } + }); + + return { + certificate, + certificateChain, + issuingCaCertificate, + privateKey, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts new file mode 100644 index 000000000..938fbf7fe --- /dev/null +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -0,0 +1,207 @@ +import { z } from "zod"; + +import { CertificatesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { CERTIFICATES } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CrlReason } from "@app/services/certificate/certificate-types"; + +export const registerCertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/:serialNumber", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { cert, ca } = await server.services.certificate.getCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.GET_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate: cert + }; + } + }); + + server.route({ + method: "POST", + url: "/:serialNumber/revoke", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Revoke", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumber) + }), + body: z.object({ + revocationReason: z.nativeEnum(CrlReason).describe(CERTIFICATES.REVOKE.revocationReason) + }), + response: { + 200: z.object({ + message: z.string().trim(), + serialNumber: z.string().trim().describe(CERTIFICATES.REVOKE.serialNumberRes), + revokedAt: z.date().describe(CERTIFICATES.REVOKE.revokedAt) + }) + } + }, + handler: async (req) => { + const { revokedAt, cert, ca } = await server.services.certificate.revokeCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.REVOKE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + message: "Successfully revoked certificate", + serialNumber: req.params.serialNumber, + revokedAt + }; + } + }); + + server.route({ + method: "DELETE", + url: "/:serialNumber", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.DELETE.serialNumber) + }), + response: { + 200: z.object({ + certificate: CertificatesSchema + }) + } + }, + handler: async (req) => { + const { deletedCert, ca } = await server.services.certificate.deleteCert({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: deletedCert.id, + cn: deletedCert.commonName, + serialNumber: deletedCert.serialNumber + } + } + }); + + return { + certificate: deletedCert + }; + } + }); + + server.route({ + method: "GET", + url: "/:serialNumber/certificate", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get certificate body of certificate", + params: z.object({ + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumber) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.GET_CERT.certificate), + certificateChain: z.string().trim().describe(CERTIFICATES.GET_CERT.certificateChain), + serialNumber: z.string().trim().describe(CERTIFICATES.GET_CERT.serialNumberRes) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, serialNumber, cert, ca } = await server.services.certificate.getCertBody({ + serialNumber: req.params.serialNumber, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: ca.projectId, + event: { + type: EventType.DELETE_CERT, + metadata: { + certId: cert.id, + cn: cert.commonName, + serialNumber: cert.serialNumber + } + } + }); + + return { + certificate, + certificateChain, + serialNumber + }; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index cbf67ce79..eee7dac65 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,6 +1,8 @@ import { registerAdminRouter } from "./admin-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; +import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertRouter } from "./certificate-router"; import { registerIdentityAccessTokenRouter } from "./identity-access-token-router"; import { registerIdentityAwsAuthRouter } from "./identity-aws-iam-auth-router"; import { registerIdentityAzureAuthRouter } from "./identity-azure-auth-router"; @@ -61,6 +63,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { { prefix: "/workspace" } ); + await server.register( + async (pkiRouter) => { + await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); + await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); + }, + { prefix: "/pki" } + ); + await server.register(registerProjectBotRouter, { prefix: "/bot" }); await server.register(registerIntegrationRouter, { prefix: "/integration" }); await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index bdb58aa8b..97a7f4d7a 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -8,7 +8,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; +import { IntegrationMetadataSchema } from "@app/services/integration/integration-schema"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { @@ -46,36 +46,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { path: z.string().trim().optional().describe(INTEGRATION.CREATE.path), region: z.string().trim().optional().describe(INTEGRATION.CREATE.region), scope: z.string().trim().optional().describe(INTEGRATION.CREATE.scope), - metadata: z - .object({ - secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), - secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), - mappingBehavior: z - .nativeEnum(IntegrationMappingBehavior) - .optional() - .describe(INTEGRATION.CREATE.metadata.mappingBehavior), - shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), - secretGCPLabel: z - .object({ - labelName: z.string(), - labelValue: z.string() - }) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), - secretAWSTag: z - .array( - z.object({ - key: z.string(), - value: z.string() - }) - ) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), - shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) - }) - .default({}) + metadata: IntegrationMetadataSchema.default({}) }), response: { 200: z.object({ @@ -161,33 +132,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetEnvironment: z.string().trim().describe(INTEGRATION.UPDATE.targetEnvironment), owner: z.string().trim().describe(INTEGRATION.UPDATE.owner), environment: z.string().trim().describe(INTEGRATION.UPDATE.environment), - metadata: z - .object({ - secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), - secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), - initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), - mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), - shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), - secretGCPLabel: z - .object({ - labelName: z.string(), - labelValue: z.string() - }) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), - secretAWSTag: z - .array( - z.object({ - key: z.string(), - value: z.string() - }) - ) - .optional() - .describe(INTEGRATION.CREATE.metadata.secretAWSTag), - kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), - shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete) - }) - .optional() + metadata: IntegrationMetadataSchema.optional() }), 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 1cf655a97..0984b66f6 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -334,6 +334,44 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "PUT", + url: "/:workspaceSlug/version-limit", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceSlug: z.string().trim() + }), + body: z.object({ + pitVersionLimit: z.number().min(1).max(100) + }), + response: { + 200: z.object({ + message: z.string(), + workspace: ProjectsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const workspace = await server.services.project.updateVersionLimit({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + pitVersionLimit: req.body.pitVersionLimit, + workspaceSlug: req.params.workspaceSlug + }); + + return { + message: "Successfully changed workspace version limit", + workspace + }; + } + }); + server.route({ method: "GET", url: "/:workspaceId/integrations", diff --git a/backend/src/server/routes/v1/secret-import-router.ts b/backend/src/server/routes/v1/secret-import-router.ts index 50311273c..ca604e738 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -30,7 +30,7 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => environment: z.string().trim().describe(SECRET_IMPORTS.CREATE.import.environment), path: z.string().trim().transform(removeTrailingSlash).describe(SECRET_IMPORTS.CREATE.import.path) }), - isReplication: z.boolean().default(false) + isReplication: z.boolean().default(false).describe(SECRET_IMPORTS.CREATE.isReplication) }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 1715aa3c3..ccbb4572d 100644 --- a/backend/src/server/routes/v1/secret-tag-router.ts +++ b/backend/src/server/routes/v1/secret-tag-router.ts @@ -23,7 +23,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTags = await server.services.secretTag.getProjectTags({ actor: req.permission.type, @@ -57,7 +57,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTag = await server.services.secretTag.createTag({ actor: req.permission.type, @@ -88,7 +88,7 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const workspaceTag = await server.services.secretTag.deleteTag({ actor: req.permission.type, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index a199cf0d4..e1c7c2e69 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,13 +1,14 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; +import { CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema, ProjectsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-types"; import { ProjectFilterType } from "@app/services/project/project-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; @@ -307,4 +308,80 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return project; } }); + + server.route({ + method: "GET", + url: "/:slug/cas", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list CAs.") + }), + querystring: z.object({ + status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional() + }), + response: { + 200: z.object({ + cas: z.array(CertificateAuthoritiesSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const cas = await server.services.project.listProjectCas({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + status: req.query.status, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + return { cas }; + } + }); + + server.route({ + method: "GET", + url: "/:slug/certificates", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + slug: slugSchema.describe("The slug of the project to list certificates.") + }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0), + limit: z.coerce.number().min(1).max(100).default(25) + }), + response: { + 200: z.object({ + certificates: z.array(CertificatesSchema), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { certificates, totalCount } = await server.services.project.listProjectCertificates({ + filter: { + slug: req.params.slug, + orgId: req.permission.orgId, + type: ProjectFilterType.SLUG + }, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type, + ...req.query + }); + return { certificates, totalCount }; + } + }); }; diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 900ad56d2..4c7df5612 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -80,7 +80,8 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { body: z.object({ email: z.string().trim(), providerAuthToken: z.string().trim().optional(), - clientProof: z.string().trim() + clientProof: z.string().trim(), + captchaToken: z.string().trim().optional() }), response: { 200: z.discriminatedUnion("mfaEnabled", [ @@ -106,6 +107,7 @@ export const registerLoginRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); const data = await server.services.login.loginExchangeClientProof({ + captchaToken: req.body.captchaToken, email: req.body.email, ip: req.realIp, userAgent, diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 05db617b9..d620e1fac 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -306,7 +306,16 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - secret: secretRawSchema + secret: secretRawSchema.extend({ + tags: SecretTagsSchema.pick({ + id: true, + slug: true, + name: true, + color: true + }) + .array() + .optional() + }) }) } }, @@ -404,6 +413,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) .describe(RAW_SECRETS.CREATE.secretValue), secretComment: z.string().trim().optional().default("").describe(RAW_SECRETS.CREATE.secretComment), + tagIds: z.string().array().optional().describe(RAW_SECRETS.CREATE.tagIds), skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.CREATE.skipMultilineEncoding), type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.CREATE.type) }), @@ -427,7 +437,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { type: req.body.type, secretValue: req.body.secretValue, skipMultilineEncoding: req.body.skipMultilineEncoding, - secretComment: req.body.secretComment + secretComment: req.body.secretComment, + tagIds: req.body.tagIds }); await server.services.auditLog.createAuditLog({ @@ -492,7 +503,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .transform(removeTrailingSlash) .describe(RAW_SECRETS.UPDATE.secretPath), skipMultilineEncoding: z.boolean().optional().describe(RAW_SECRETS.UPDATE.skipMultilineEncoding), - type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type) + type: z.nativeEnum(SecretType).default(SecretType.Shared).describe(RAW_SECRETS.UPDATE.type), + tagIds: z.string().array().optional().describe(RAW_SECRETS.UPDATE.tagIds) }), response: { 200: z.object({ @@ -513,7 +525,8 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: req.params.secretName, type: req.body.type, secretValue: req.body.secretValue, - skipMultilineEncoding: req.body.skipMultilineEncoding + skipMultilineEncoding: req.body.skipMultilineEncoding, + tagIds: req.body.tagIds }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index cbf43b245..a136508e7 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -3,6 +3,7 @@ import jwt from "jsonwebtoken"; import { TUsers, UserDeviceSchema } from "@app/db/schemas"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; +import { request } from "@app/lib/config/request"; import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; @@ -176,12 +177,16 @@ export const authLoginServiceFactory = ({ clientProof, ip, userAgent, - providerAuthToken + providerAuthToken, + captchaToken }: TLoginClientProofDTO) => { + const appCfg = getConfig(); + const userEnc = await userDAL.findUserEncKeyByUsername({ username: email }); if (!userEnc) throw new Error("Failed to find user"); + const user = await userDAL.findById(userEnc.userId); const cfg = getConfig(); let authMethod = AuthMethod.EMAIL; @@ -196,6 +201,31 @@ export const authLoginServiceFactory = ({ } } + if ( + user.consecutiveFailedPasswordAttempts && + user.consecutiveFailedPasswordAttempts >= 10 && + Boolean(appCfg.CAPTCHA_SECRET) + ) { + if (!captchaToken) { + throw new BadRequestError({ + name: "Captcha Required", + message: "Accomplish the required captcha by logging in via Web" + }); + } + + // validate captcha token + const response = await request.postForm<{ success: boolean }>("https://api.hcaptcha.com/siteverify", { + response: captchaToken, + secret: appCfg.CAPTCHA_SECRET + }); + + if (!response.data.success) { + throw new BadRequestError({ + name: "Invalid Captcha" + }); + } + } + if (!userEnc.serverPrivateKey || !userEnc.clientPublicKey) throw new Error("Failed to authenticate. Try again?"); const isValidClientProof = await srpCheckClientProof( userEnc.salt, @@ -204,15 +234,31 @@ export const authLoginServiceFactory = ({ userEnc.clientPublicKey, clientProof ); - if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); + + if (!isValidClientProof) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new Error("Failed to authenticate. Try again?"); + } await userDAL.updateUserEncryptionByUserId(userEnc.userId, { serverPrivateKey: null, clientPublicKey: null }); + + await userDAL.updateById(userEnc.userId, { + consecutiveFailedPasswordAttempts: 0 + }); + // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { - const user = await userDAL.findById(userEnc.userId); enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); const mfaToken = jwt.sign( diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 37b90f548..4f73ec996 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -12,6 +12,7 @@ export type TLoginClientProofDTO = { providerAuthToken?: string; ip: string; userAgent: string; + captchaToken?: string; }; export type TVerifyMfaTokenDTO = { diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index be7f5777d..528cb44fa 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -231,7 +231,7 @@ export const authSignupServiceFactory = ({ const accessToken = jwt.sign( { - authMethod: AuthMethod.EMAIL, + authMethod: authMethod || AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, @@ -244,7 +244,7 @@ export const authSignupServiceFactory = ({ const refreshToken = jwt.sign( { - authMethod: AuthMethod.EMAIL, + authMethod: authMethod || AuthMethod.EMAIL, authTokenType: AuthTokenType.REFRESH_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, diff --git a/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts new file mode 100644 index 000000000..763240986 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-cert-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityCertDALFactory = ReturnType; + +export const certificateAuthorityCertDALFactory = (db: TDbClient) => { + const caCertOrm = ormify(db, TableName.CertificateAuthorityCert); + return caCertOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-dal.ts b/backend/src/services/certificate-authority/certificate-authority-dal.ts new file mode 100644 index 000000000..1b4b30e73 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-dal.ts @@ -0,0 +1,48 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthorityDALFactory = ReturnType; + +export const certificateAuthorityDALFactory = (db: TDbClient) => { + const caOrm = ormify(db, TableName.CertificateAuthority); + + // note: not used + const buildCertificateChain = async (caId: string) => { + try { + const result: { + caId: string; + parentCaId?: string; + encryptedCertificate: Buffer; + }[] = await db + .withRecursive("cte", (cte) => { + void cte + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .where("ca.id", caId) + .unionAll((builder) => { + void builder + .select("ca.id as caId", "ca.parentCaId", "cert.encryptedCertificate") + .from({ ca: TableName.CertificateAuthority }) + .leftJoin({ cert: TableName.CertificateAuthorityCert }, "ca.id", "cert.caId") + .innerJoin("cte", "cte.parentCaId", "ca.id"); + }); + }) + .select("*") + .from("cte"); + + // Extract certificates and reverse the order to have the root CA at the end + const certChain: Buffer[] = result.map((row) => row.encryptedCertificate); + return certChain; + } catch (error) { + throw new DatabaseError({ error, name: "BuildCertificateChain" }); + } + }; + + return { + ...caOrm, + buildCertificateChain + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts new file mode 100644 index 000000000..cf42a058e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -0,0 +1,216 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { BadRequestError } from "@app/lib/errors"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TDNParts, TGetCaCertChainDTO, TGetCaCredentialsDTO, TRebuildCaCrlDTO } from "./certificate-authority-types"; + +export const createDistinguishedName = (parts: TDNParts) => { + const dnParts = []; + if (parts.country) dnParts.push(`C=${parts.country}`); + if (parts.organization) dnParts.push(`O=${parts.organization}`); + if (parts.ou) dnParts.push(`OU=${parts.ou}`); + if (parts.province) dnParts.push(`ST=${parts.province}`); + if (parts.commonName) dnParts.push(`CN=${parts.commonName}`); + if (parts.locality) dnParts.push(`L=${parts.locality}`); + return dnParts.join(", "); +}; + +export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { + switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_4096: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }; + case CertKeyAlgorithm.ECDSA_P256: + return { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }; + case CertKeyAlgorithm.ECDSA_P384: + return { + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }; + default: { + // RSA_2048 + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }; + } + } +}; + +/** + * Return the public and private key of CA with id [caId] + * Note: credentials are returned as crypto.webcrypto.CryptoKey + * suitable for use with @peculiar/x509 module + */ +export const getCaCredentials = async ({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService +}: TGetCaCredentialsDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId }); + if (!caSecret) throw new BadRequestError({ message: "CA secret not found" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedPrivateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const skObj = crypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); + const caPrivateKey = await crypto.subtle.importKey( + "pkcs8", + skObj.export({ format: "der", type: "pkcs8" }), + alg, + true, + ["sign"] + ); + + const pkObj = crypto.createPublicKey(skObj); + const caPublicKey = await crypto.subtle.importKey("spki", pkObj.export({ format: "der", type: "spki" }), alg, true, [ + "verify" + ]); + + return { + caPrivateKey, + caPublicKey + }; +}; + +/** + * Return the decrypted pem-encoded certificate and certificate chain + * for CA with id [caId]. + */ +export const getCaCertChain = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService +}: TGetCaCertChainDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const decryptedChain = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificateChain + }); + + return { + caCert: caCertObj.toString("pem"), + caCertChain: decryptedChain.toString("utf-8"), + serialNumber: caCertObj.serialNumber + }; +}; + +/** + * Rebuilds the certificate revocation list (CRL) + * for CA with id [caId] + */ +export const rebuildCaCrl = async ({ + caId, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService +}: TRebuildCaCrlDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts new file mode 100644 index 000000000..384f45c09 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -0,0 +1,145 @@ +import * as x509 from "@peculiar/x509"; +import crypto from "crypto"; + +import { getConfig } from "@app/lib/config/env"; +import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { CertKeyAlgorithm, CertStatus } from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { TRotateCaCrlTriggerDTO } from "./certificate-authority-types"; + +type TCertificateAuthorityQueueFactoryDep = { + // TODO: Pick + certificateAuthorityDAL: TCertificateAuthorityDALFactory; + certificateAuthorityCrlDAL: TCertificateAuthorityCrlDALFactory; + certificateAuthoritySecretDAL: TCertificateAuthoritySecretDALFactory; + certificateDAL: TCertificateDALFactory; + projectDAL: Pick; + kmsService: Pick; + queueService: TQueueServiceFactory; +}; +export type TCertificateAuthorityQueueFactory = ReturnType; + +export const certificateAuthorityQueueFactory = ({ + certificateAuthorityCrlDAL, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + certificateDAL, + projectDAL, + kmsService, + queueService +}: TCertificateAuthorityQueueFactoryDep) => { + // TODO 1: auto-periodic rotation + // TODO 2: manual rotation + + const setCaCrlRotationInterval = async ({ caId, rotationIntervalDays }: TRotateCaCrlTriggerDTO) => { + const appCfg = getConfig(); + + // query for config + // const caCrl = await certificateAuthorityCrlDAL.findOne({ + // caId + // }); + + await queueService.queue( + // TODO: clarify queue + job naming + QueueName.CaCrlRotation, + QueueJobs.CaCrlRotation, + { + caId + }, + { + jobId: `ca-crl-rotation-${caId}`, + repeat: { + // on prod it this will be in days, in development this will be second + every: + appCfg.NODE_ENV === "development" + ? secondsToMillis(rotationIntervalDays) + : daysToMillisecond(rotationIntervalDays), + immediately: true + } + } + ); + }; + + queueService.start(QueueName.CaCrlRotation, async (job) => { + const { caId } = job.data; + logger.info(`secretReminderQueue.process: [secretDocument=${caId}]`); + + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const caSecret = await certificateAuthoritySecretDAL.findOne({ caId: ca.id }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const privateKey = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caSecret.encryptedPrivateKey + }); + + const skObj = crypto.createPrivateKey({ key: privateKey, format: "der", type: "pkcs8" }); + const sk = await crypto.subtle.importKey("pkcs8", skObj.export({ format: "der", type: "pkcs8" }), alg, true, [ + "sign" + ]); + + const revokedCerts = await certificateDAL.find({ + caId: ca.id, + status: CertStatus.REVOKED + }); + + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: depends on configured rebuild interval + entries: revokedCerts.map((revokedCert) => { + return { + serialNumber: revokedCert.serialNumber, + revocationDate: new Date(revokedCert.revokedAt as Date), + reason: revokedCert.revocationReason as number, + invalidity: new Date("2022/01/01"), + issuer: ca.dn + }; + }), + signingAlgorithm: alg, + signingKey: sk + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.update( + { + caId: ca.id + }, + { + encryptedCrl + } + ); + }); + + queueService.listen(QueueName.CaCrlRotation, "failed", (job, err) => { + logger.error(err, "Failed to rotate CA CRL %s", job?.id); + }); + + return { + setCaCrlRotationInterval + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts new file mode 100644 index 000000000..2ade72e7e --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-secret-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateAuthoritySecretDALFactory = ReturnType; + +export const certificateAuthoritySecretDALFactory = (db: TDbClient) => { + const caSecretOrm = ormify(db, TableName.CertificateAuthoritySecret); + return caSecretOrm; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts new file mode 100644 index 000000000..2345180a3 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -0,0 +1,821 @@ +/* eslint-disable no-bitwise */ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; +import crypto, { KeyObject } from "crypto"; +import ms from "ms"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError } from "@app/lib/errors"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm, CertStatus } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { + createDistinguishedName, + getCaCertChain, + getCaCredentials, + keyAlgorithmToAlgCfg +} from "./certificate-authority-fns"; +import { TCertificateAuthorityQueueFactory } from "./certificate-authority-queue"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; +import { + CaStatus, + CaType, + TCreateCaDTO, + TDeleteCaDTO, + TGetCaCertDTO, + TGetCaCsrDTO, + TGetCaDTO, + TImportCertToCaDTO, + TIssueCertFromCaDTO, + TSignIntermediateDTO, + TUpdateCaDTO +} from "./certificate-authority-types"; + +type TCertificateAuthorityServiceFactoryDep = { + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" + >; + certificateAuthorityCertDAL: Pick; + certificateAuthoritySecretDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthorityQueue: TCertificateAuthorityQueueFactory; // TODO: Pick + certificateDAL: Pick; + certificateBodyDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateAuthorityServiceFactory = ReturnType; + +export const certificateAuthorityServiceFactory = ({ + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthoritySecretDAL, + certificateAuthorityCrlDAL, + certificateDAL, + certificateBodyDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateAuthorityServiceFactoryDep) => { + /** + * Generates new root or intermediate CA + */ + const createCa = async ({ + projectSlug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notBefore, + notAfter, + maxPathLength, + keyAlgorithm, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateCaDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const dn = createDistinguishedName({ + commonName, + organization, + ou, + country, + province, + locality + }); + + const alg = keyAlgorithmToAlgCfg(keyAlgorithm); + const keys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const newCa = await certificateAuthorityDAL.transaction(async (tx) => { + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + // if undefined, set [notAfterDate] to 10 years from now + const notAfterDate = notAfter + ? new Date(notAfter) + : new Date(new Date().setFullYear(new Date().getFullYear() + 10)); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + + const ca = await certificateAuthorityDAL.create( + { + projectId: project.id, + type, + organization, + ou, + country, + province, + locality, + friendlyName: friendlyName || dn, + commonName, + status: type === CaType.ROOT ? CaStatus.ACTIVE : CaStatus.PENDING_CERTIFICATE, + dn, + keyAlgorithm, + ...(type === CaType.ROOT && { + maxPathLength, + notBefore: notBeforeDate, + notAfter: notAfterDate, + serialNumber + }) + }, + tx + ); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: project.id, + projectDAL, + kmsService + }); + + if (type === CaType.ROOT) { + // note: create self-signed cert only applicable for root CA + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: alg, + keys, + extensions: [ + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + new x509.ExtendedKeyUsageExtension(["1.2.3.4.5.6.7", "2.3.4.5.6.7.8"], true), + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true), + await x509.SubjectKeyIdentifierExtension.create(keys.publicKey) + ] + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(cert.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.alloc(0) + }); + + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + } + + // create empty CRL + const crl = await x509.X509CrlGenerator.create({ + issuer: ca.dn, + thisUpdate: new Date(), + nextUpdate: new Date("2025/12/12"), // TODO: change + entries: [], + signingAlgorithm: alg, + signingKey: keys.privateKey + }); + + const { cipherTextBlob: encryptedCrl } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(crl.rawData)) + }); + + await certificateAuthorityCrlDAL.create( + { + caId: ca.id, + encryptedCrl + }, + tx + ); + + // https://nodejs.org/api/crypto.html#static-method-keyobjectfromkey + const skObj = KeyObject.from(keys.privateKey); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsService.encrypt({ + kmsId: keyId, + plainText: skObj.export({ + type: "pkcs8", + format: "der" + }) + }); + + await certificateAuthoritySecretDAL.create( + { + caId: ca.id, + encryptedPrivateKey + }, + tx + ); + + return ca; + }); + + return newCa; + }; + + /** + * Return CA with id [caId] + */ + const getCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return ca; + }; + + /** + * Update CA with id [caId]. + * Note: Used to enable/disable CA + */ + const updateCaById = async ({ caId, status, actorId, actorAuthMethod, actor, actorOrgId }: TUpdateCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + ProjectPermissionSub.CertificateAuthorities + ); + + const updatedCa = await certificateAuthorityDAL.updateById(caId, { status }); + + return updatedCa; + }; + + /** + * Delete CA with id [caId] + */ + const deleteCaById = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Delete, + ProjectPermissionSub.CertificateAuthorities + ); + + const deletedCa = await certificateAuthorityDAL.deleteById(caId); + + return deletedCa; + }; + + /** + * Return certificate signing request (CSR) made with CA with id [caId] + */ + const getCaCsr = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCsrDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.type === CaType.ROOT) throw new BadRequestError({ message: "Root CA cannot generate CSR" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA already has a certificate installed" }); + + const { caPrivateKey, caPublicKey } = await getCaCredentials({ + caId, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: ca.dn, + keys: { + privateKey: caPrivateKey, + publicKey: caPublicKey + }, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment + ) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + return { + csr: csrObj.toString("pem"), + ca + }; + }; + + /** + * Return certificate and certificate chain for CA + */ + const getCaCert = async ({ caId, actorId, actorAuthMethod, actor, actorOrgId }: TGetCaCertDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const { caCert, caCertChain, serialNumber } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: caCert, + certificateChain: caCertChain, + serialNumber, + ca + }; + }; + + /** + * Issue certificate to be imported back in for intermediate CA + */ + const signIntermediate = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + csr, + notBefore, + notAfter, + maxPathLength + }: TSignIntermediateDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // check path length constraint + const caPathLength = caCertObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + if (caPathLength !== undefined) { + if (caPathLength === 0) + throw new BadRequestError({ + message: "Failed to issue intermediate certificate due to CA path length constraint" + }); + if (maxPathLength >= caPathLength || (maxPathLength === -1 && caPathLength !== -1)) + throw new BadRequestError({ + message: "The requested path length constraint exceeds the CA's allowed path length" + }); + } + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + const notAfterDate = new Date(notAfter); + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const intermediateCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension( + x509.KeyUsageFlags.keyCertSign | + x509.KeyUsageFlags.cRLSign | + x509.KeyUsageFlags.digitalSignature | + x509.KeyUsageFlags.keyEncipherment, + true + ), + new x509.BasicConstraintsExtension(true, maxPathLength === -1 ? undefined : maxPathLength, true), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: intermediateCert.toString("pem"), + issuingCaCertificate, + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + serialNumber: intermediateCert.serialNumber, + ca + }; + }; + + /** + * Import certificate for (un-installed) CA with id [caId]. + * Note: Can be used to import an external certificate and certificate chain + * to be installed into the CA. + */ + const importCertToCa = async ({ + caId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + certificate, + certificateChain + }: TImportCertToCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (caCert) throw new BadRequestError({ message: "CA has already imported a certificate" }); + + const certObj = new x509.X509Certificate(certificate); + const maxPathLength = certObj.getExtension(x509.BasicConstraintsExtension)?.pathLength; + + // validate imported certificate and certificate chain + const certificates = certificateChain + .match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) + ?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates) throw new BadRequestError({ message: "Failed to parse certificate chain" }); + + const chain = new x509.X509ChainBuilder({ + certificates + }); + + const chainItems = await chain.build(certObj); + + // chain.build() implicitly verifies the chain + if (chainItems.length !== certificates.length + 1) + throw new BadRequestError({ message: "Invalid certificate chain" }); + + const parentCertObj = chainItems[1]; + const parentCertSubject = parentCertObj.subject; + + const parentCa = await certificateAuthorityDAL.findOne({ + projectId: ca.projectId, + dn: parentCertSubject + }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const { cipherTextBlob: encryptedCertificateChain } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(certificateChain) + }); + + await certificateAuthorityCertDAL.transaction(async (tx) => { + await certificateAuthorityCertDAL.create( + { + caId: ca.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateAuthorityDAL.updateById( + ca.id, + { + status: CaStatus.ACTIVE, + maxPathLength: maxPathLength === undefined ? -1 : maxPathLength, + notBefore: new Date(certObj.notBefore), + notAfter: new Date(certObj.notAfter), + serialNumber: certObj.serialNumber, + parentCaId: parentCa?.id + }, + tx + ); + }); + + return { ca }; + }; + + /** + * Return new leaf certificate issued by CA with id [caId] + */ + const issueCertFromCa = async ({ + caId, + friendlyName, + commonName, + ttl, + notBefore, + notAfter, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TIssueCertFromCaDTO) => { + const ca = await certificateAuthorityDAL.findById(caId); + if (!ca) throw new BadRequestError({ message: "CA not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + if (ca.status === CaStatus.DISABLED) throw new BadRequestError({ message: "CA is disabled" }); + + const caCert = await certificateAuthorityCertDAL.findOne({ caId: ca.id }); + if (!caCert) throw new BadRequestError({ message: "CA does not have a certificate installed" }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCaCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: caCert.encryptedCertificate + }); + + const caCertObj = new x509.X509Certificate(decryptedCaCert); + + const notBeforeDate = notBefore ? new Date(notBefore) : new Date(); + + let notAfterDate = new Date(new Date().setFullYear(new Date().getFullYear() + 1)); + if (notAfter) { + notAfterDate = new Date(notAfter); + } else if (ttl) { + notAfterDate = new Date(new Date().getTime() + ms(ttl)); + } + + const caCertNotBeforeDate = new Date(caCertObj.notBefore); + const caCertNotAfterDate = new Date(caCertObj.notAfter); + + // check not before constraint + if (notBeforeDate < caCertNotBeforeDate) { + throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" }); + } + + if (notBeforeDate > notAfterDate) throw new BadRequestError({ message: "notBefore date is after notAfter date" }); + + // check not after constraint + if (notAfterDate > caCertNotAfterDate) { + throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); + } + + const alg = keyAlgorithmToAlgCfg(ca.keyAlgorithm as CertKeyAlgorithm); + const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys: leafKeys, + signingAlgorithm: alg, + extensions: [ + // eslint-disable-next-line no-bitwise + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) + ], + attributes: [new x509.ChallengePasswordAttribute("password")] + }); + + const { caPrivateKey } = await getCaCredentials({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService + }); + + const serialNumber = crypto.randomBytes(32).toString("hex"); + const leafCert = await x509.X509CertificateGenerator.create({ + serialNumber, + subject: csrObj.subject, + issuer: caCertObj.subject, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingKey: caPrivateKey, + publicKey: csrObj.publicKey, + signingAlgorithm: alg, + extensions: [ + new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true), + new x509.BasicConstraintsExtension(false), + await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false), + await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey) + ] + }); + + const skLeafObj = KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const { cipherTextBlob: encryptedCertificate } = await kmsService.encrypt({ + kmsId: keyId, + plainText: Buffer.from(new Uint8Array(leafCert.rawData)) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate + }, + tx + ); + + return cert; + }); + + const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: leafCert.toString("pem"), + certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), + issuingCaCertificate, + privateKey: skLeaf, + serialNumber, + ca + }; + }; + + return { + createCa, + getCaById, + updateCaById, + deleteCaById, + getCaCsr, + getCaCert, + signIntermediate, + importCertToCa, + issueCertFromCa + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts new file mode 100644 index 000000000..3ba7624c0 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -0,0 +1,121 @@ +import { TProjectPermission } from "@app/lib/types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; + +import { TCertificateAuthorityCrlDALFactory } from "../../ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { CertKeyAlgorithm } from "../certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "./certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "./certificate-authority-secret-dal"; + +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} + +export type TCreateCaDTO = { + projectSlug: string; + type: CaType; + friendlyName?: string; + commonName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + notBefore?: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +} & Omit; + +export type TGetCaDTO = { + caId: string; +} & Omit; + +export type TUpdateCaDTO = { + caId: string; + status?: CaStatus; +} & Omit; + +export type TDeleteCaDTO = { + caId: string; +} & Omit; + +export type TGetCaCsrDTO = { + caId: string; +} & Omit; + +export type TGetCaCertDTO = { + caId: string; +} & Omit; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + notBefore?: string; + notAfter: string; + maxPathLength: number; +} & Omit; + +export type TImportCertToCaDTO = { + caId: string; + certificate: string; + certificateChain: string; +} & Omit; + +export type TIssueCertFromCaDTO = { + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; + notBefore?: string; + notAfter?: string; +} & Omit; + +export type TDNParts = { + commonName?: string; + organization?: string; + ou?: string; + country?: string; + province?: string; + locality?: string; +}; + +export type TGetCaCredentialsDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TGetCaCertChainDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; +}; + +export type TRebuildCaCrlDTO = { + caId: string; + certificateAuthorityDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + certificateDAL: Pick; + kmsService: Pick; +}; + +export type TRotateCaCrlTriggerDTO = { + caId: string; + rotationIntervalDays: number; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-validators.ts b/backend/src/services/certificate-authority/certificate-authority-validators.ts new file mode 100644 index 000000000..77bf9ad2f --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-validators.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +export const validateCaDateField = z.string().trim().refine(isValidDate, { message: "Invalid date format" }); diff --git a/backend/src/services/certificate/certificate-body-dal.ts b/backend/src/services/certificate/certificate-body-dal.ts new file mode 100644 index 000000000..9ddc98966 --- /dev/null +++ b/backend/src/services/certificate/certificate-body-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateBodyDALFactory = ReturnType; + +export const certificateBodyDALFactory = (db: TDbClient) => { + const certificateBodyOrm = ormify(db, TableName.CertificateBody); + return certificateBodyOrm; +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts new file mode 100644 index 000000000..415bfabf9 --- /dev/null +++ b/backend/src/services/certificate/certificate-dal.ts @@ -0,0 +1,34 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TCertificateDALFactory = ReturnType; + +export const certificateDALFactory = (db: TDbClient) => { + const certificateOrm = ormify(db, TableName.Certificate); + + const countCertificatesInProject = async (projectId: string) => { + try { + interface CountResult { + count: string; + } + + const count = await db(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .count("*") + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count all project certificates" }); + } + }; + + return { + ...certificateOrm, + countCertificatesInProject + }; +}; diff --git a/backend/src/services/certificate/certificate-fns.ts b/backend/src/services/certificate/certificate-fns.ts new file mode 100644 index 000000000..dfbd50551 --- /dev/null +++ b/backend/src/services/certificate/certificate-fns.ts @@ -0,0 +1,26 @@ +import * as x509 from "@peculiar/x509"; + +import { CrlReason } from "./certificate-types"; + +export const revocationReasonToCrlCode = (crlReason: CrlReason) => { + switch (crlReason) { + case CrlReason.KEY_COMPROMISE: + return x509.X509CrlReason.keyCompromise; + case CrlReason.CA_COMPROMISE: + return x509.X509CrlReason.cACompromise; + case CrlReason.AFFILIATION_CHANGED: + return x509.X509CrlReason.affiliationChanged; + case CrlReason.SUPERSEDED: + return x509.X509CrlReason.superseded; + case CrlReason.CESSATION_OF_OPERATION: + return x509.X509CrlReason.cessationOfOperation; + case CrlReason.CERTIFICATE_HOLD: + return x509.X509CrlReason.certificateHold; + case CrlReason.PRIVILEGE_WITHDRAWN: + return x509.X509CrlReason.privilegeWithdrawn; + case CrlReason.A_A_COMPROMISE: + return x509.X509CrlReason.aACompromise; + default: + return x509.X509CrlReason.unspecified; + } +}; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts new file mode 100644 index 000000000..ba865caa1 --- /dev/null +++ b/backend/src/services/certificate/certificate-service.ts @@ -0,0 +1,203 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; +import { revocationReasonToCrlCode } from "./certificate-fns"; +import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; + +type TCertificateServiceFactoryDep = { + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + certificateAuthorityCrlDAL: Pick; + certificateAuthoritySecretDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + permissionService: Pick; +}; + +export type TCertificateServiceFactory = ReturnType; + +export const certificateServiceFactory = ({ + certificateDAL, + certificateBodyDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService +}: TCertificateServiceFactoryDep) => { + /** + * Return details for certificate with serial number [serialNumber] + */ + const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + return { + cert, + ca + }; + }; + + /** + * Delete certificate with serial number [serialNumber] + */ + const deleteCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + const deletedCert = await certificateDAL.deleteById(cert.id); + + return { + deletedCert, + ca + }; + }; + + /** + * Revoke certificate with serial number [serialNumber]. + * Note: Revoking a certificate adds it to the certificate revocation list (CRL) + * of its issuing CA + */ + const revokeCert = async ({ + serialNumber, + revocationReason, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TRevokeCertDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + + if (cert.status === CertStatus.REVOKED) throw new Error("Certificate already revoked"); + + const revokedAt = new Date(); + await certificateDAL.update( + { + id: cert.id + }, + { + status: CertStatus.REVOKED, + revokedAt, + revocationReason: revocationReasonToCrlCode(revocationReason) + } + ); + + // rebuild CRL (TODO: move to interval-based cron job) + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + + return { revokedAt, cert, ca }; + }; + + /** + * Return certificate body and certificate chain for certificate with + * serial number [serialNumber] + */ + const getCertBody = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertBodyDTO) => { + const cert = await certificateDAL.findOne({ serialNumber }); + const ca = await certificateAuthorityDAL.findById(cert.caId); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + ca.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const keyId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const decryptedCert = await kmsService.decrypt({ + kmsId: keyId, + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + + const { caCert, caCertChain } = await getCaCertChain({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + return { + certificate: certObj.toString("pem"), + certificateChain: `${caCert}\n${caCertChain}`.trim(), + serialNumber: certObj.serialNumber, + cert, + ca + }; + }; + + return { + getCert, + deleteCert, + revokeCert, + getCertBody + }; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts new file mode 100644 index 000000000..93f72afe3 --- /dev/null +++ b/backend/src/services/certificate/certificate-types.ts @@ -0,0 +1,43 @@ +import { TProjectPermission } from "@app/lib/types"; + +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} + +export type TGetCertDTO = { + serialNumber: string; +} & Omit; + +export type TDeleteCertDTO = { + serialNumber: string; +} & Omit; + +export type TRevokeCertDTO = { + serialNumber: string; + revocationReason: CrlReason; +} & Omit; + +export type TGetCertBodyDTO = { + serialNumber: string; +} & Omit; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 0ae0a0275..6351b4d82 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -31,6 +31,7 @@ import { logger } from "@app/lib/logger"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; +import { IntegrationMetadataSchema } from "../integration/integration-schema"; import { IntegrationInitialSyncBehavior, IntegrationMappingBehavior, @@ -1363,38 +1364,41 @@ const syncSecretsGitHub = async ({ } } - for await (const encryptedSecret of encryptedSecrets) { - if ( - !(encryptedSecret.name in secrets) && - !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && - !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) - ) { - switch (integration.scope) { - case GithubScope.Org: { - await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { - org: integration.owner as string, - secret_name: encryptedSecret.name - }); - break; - } - case GithubScope.Env: { - await octokit.request( - "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", - { - repository_id: Number(integration.appId), - environment_name: integration.targetEnvironmentId as string, + const metadata = IntegrationMetadataSchema.parse(integration.metadata); + if (metadata.shouldEnableDelete) { + for await (const encryptedSecret of encryptedSecrets) { + if ( + !(encryptedSecret.name in secrets) && + !(appendices?.prefix !== undefined && !encryptedSecret.name.startsWith(appendices?.prefix)) && + !(appendices?.suffix !== undefined && !encryptedSecret.name.endsWith(appendices?.suffix)) + ) { + switch (integration.scope) { + case GithubScope.Org: { + await octokit.request("DELETE /orgs/{org}/actions/secrets/{secret_name}", { + org: integration.owner as string, secret_name: encryptedSecret.name - } - ); - break; - } - default: { - await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { - owner: integration.owner as string, - repo: integration.app as string, - secret_name: encryptedSecret.name - }); - break; + }); + break; + } + case GithubScope.Env: { + await octokit.request( + "DELETE /repositories/{repository_id}/environments/{environment_name}/secrets/{secret_name}", + { + repository_id: Number(integration.appId), + environment_name: integration.targetEnvironmentId as string, + secret_name: encryptedSecret.name + } + ); + break; + } + default: { + await octokit.request("DELETE /repos/{owner}/{repo}/actions/secrets/{secret_name}", { + owner: integration.owner as string, + repo: integration.app as string, + secret_name: encryptedSecret.name + }); + break; + } } } } @@ -1917,13 +1921,13 @@ const syncSecretsGitLab = async ({ return allEnvVariables; }; + const metadata = IntegrationMetadataSchema.parse(integration.metadata); const allEnvVariables = await getAllEnvVariables(integration?.appId as string, accessToken); const getSecretsRes: GitLabSecret[] = allEnvVariables .filter((secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment) .filter((gitLabSecret) => { let isValid = true; - const metadata = z.record(z.any()).parse(integration.metadata); if (metadata.secretPrefix && !gitLabSecret.key.startsWith(metadata.secretPrefix)) { isValid = false; } @@ -1943,8 +1947,8 @@ const syncSecretsGitLab = async ({ { key, value: secrets[key].value, - protected: false, - masked: false, + protected: Boolean(metadata.shouldProtectSecrets), + masked: Boolean(metadata.shouldMaskSecrets), raw: false, environment_scope: integration.targetEnvironment }, @@ -1961,7 +1965,9 @@ const syncSecretsGitLab = async ({ `${gitLabApiUrl}/v4/projects/${integration?.appId}/variables/${existingSecret.key}?filter[environment_scope]=${integration.targetEnvironment}`, { ...existingSecret, - value: secrets[existingSecret.key].value + value: secrets[existingSecret.key].value, + protected: Boolean(metadata.shouldProtectSecrets), + masked: Boolean(metadata.shouldMaskSecrets) }, { headers: { diff --git a/backend/src/services/integration/integration-schema.ts b/backend/src/services/integration/integration-schema.ts new file mode 100644 index 000000000..1ea01e56a --- /dev/null +++ b/backend/src/services/integration/integration-schema.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { INTEGRATION } from "@app/lib/api-docs"; + +import { IntegrationMappingBehavior } from "../integration-auth/integration-list"; + +export const IntegrationMetadataSchema = z.object({ + secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), + secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), + initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), + shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), + secretGCPLabel: z + .object({ + labelName: z.string(), + labelValue: z.string() + }) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), + secretAWSTag: z + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) + .optional() + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId), + shouldDisableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldDisableDelete), + shouldEnableDelete: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldEnableDelete), + shouldMaskSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldMaskSecrets), + shouldProtectSecrets: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldProtectSecrets) +}); diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 9c75cad2d..abbccbe90 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -29,6 +29,9 @@ export type TCreateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; @@ -54,6 +57,7 @@ export type TUpdateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 97d2b29d6..63aba8939 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -29,19 +29,22 @@ export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsSe let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); // this is used symmetric encryption - const generateKmsKey = async ({ scopeId, scopeType, isReserved = true }: TGenerateKMSDTO) => { + const generateKmsKey = async ({ scopeId, scopeType, isReserved = true, tx }: TGenerateKMSDTO) => { const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); const kmsKeyMaterial = randomSecureBytes(32); const encryptedKeyMaterial = cipher.encrypt(kmsKeyMaterial, ROOT_ENCRYPTION_KEY); - const { encryptedKey, ...doc } = await kmsDAL.create({ - version: 1, - encryptedKey: encryptedKeyMaterial, - encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, - isReserved, - orgId: scopeType === "org" ? scopeId : undefined, - projectId: scopeType === "project" ? scopeId : undefined - }); + const { encryptedKey, ...doc } = await kmsDAL.create( + { + version: 1, + encryptedKey: encryptedKeyMaterial, + encryptionAlgorithm: SymmetricEncryption.AES_GCM_256, + isReserved, + orgId: scopeType === "org" ? scopeId : undefined, + projectId: scopeType === "project" ? scopeId : undefined + }, + tx + ); return doc; }; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index 96ad25f6e..63fdaf484 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -1,7 +1,10 @@ +import { Knex } from "knex"; + export type TGenerateKMSDTO = { scopeType: "project" | "org"; scopeId: string; isReserved?: boolean; + tx?: Knex; }; export type TEncryptWithKmsDTO = { diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 60ddc5230..68d2b8cda 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -336,6 +336,7 @@ export const orgServiceFactory = ({ return org; }); + await licenseService.updateSubscriptionOrgMemberCount(organization.id); return organization; }; diff --git a/backend/src/services/project/project-fns.ts b/backend/src/services/project/project-fns.ts index 3ac75248d..78c7b442f 100644 --- a/backend/src/services/project/project-fns.ts +++ b/backend/src/services/project/project-fns.ts @@ -1,6 +1,9 @@ import crypto from "crypto"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { AddUserToWsDTO } from "./project-types"; @@ -49,3 +52,44 @@ export const createProjectKey = ({ publicKey, privateKey, plainProjectKey }: TCr return { key: encryptedProjectKey, iv: encryptedProjectKeyIv }; }; + +export const getProjectKmsCertificateKeyId = async ({ + projectId, + projectDAL, + kmsService +}: { + projectId: string; + projectDAL: Pick; + kmsService: Pick; +}) => { + const keyId = await projectDAL.transaction(async (tx) => { + const project = await projectDAL.findOne({ id: projectId }, tx); + if (!project) { + throw new BadRequestError({ message: "Project not found" }); + } + + if (!project.kmsCertificateKeyId) { + // create default kms key for certificate service + const key = await kmsService.generateKmsKey({ + scopeId: projectId, + scopeType: "project", + isReserved: true, + tx + }); + + await projectDAL.updateById( + projectId, + { + kmsCertificateKeyId: key.id + }, + tx + ); + + return key.id; + } + + return project.kmsCertificateKeyId; + }); + + return keyId; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index f58fd7788..1a8e65a41 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -16,6 +16,8 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; import { ActorType } from "../auth/auth-type"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityProjectDALFactory } from "../identity-project/identity-project-dal"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; @@ -36,9 +38,12 @@ import { TCreateProjectDTO, TDeleteProjectDTO, TGetProjectDTO, + TListProjectCasDTO, + TListProjectCertsDTO, TToggleProjectAutoCapitalizationDTO, TUpdateProjectDTO, TUpdateProjectNameDTO, + TUpdateProjectVersionLimitDTO, TUpgradeProjectDTO } from "./project-types"; @@ -49,6 +54,7 @@ export const DEFAULT_PROJECT_ENVS = [ ]; type TProjectServiceFactoryDep = { + // TODO: Pick projectDAL: TProjectDALFactory; projectQueue: TProjectQueueFactory; userDAL: TUserDALFactory; @@ -62,6 +68,8 @@ type TProjectServiceFactoryDep = { projectMembershipDAL: Pick; projectUserMembershipRoleDAL: Pick; secretBlindIndexDAL: Pick; + certificateAuthorityDAL: Pick; + certificateDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -89,6 +97,8 @@ export const projectServiceFactory = ({ licenseService, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, + certificateAuthorityDAL, + certificateDAL, keyStore }: TProjectServiceFactoryDep) => { /* @@ -133,7 +143,8 @@ export const projectServiceFactory = ({ name: workspaceName, orgId: organization.id, slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), - version: ProjectVersion.V2 + version: ProjectVersion.V2, + pitVersionLimit: 10 }, tx ); @@ -406,6 +417,35 @@ export const projectServiceFactory = ({ return updatedProject; }; + const updateVersionLimit = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + pitVersionLimit, + workspaceSlug + }: TUpdateProjectVersionLimitDTO) => { + const project = await projectDAL.findProjectBySlug(workspaceSlug, actorOrgId); + if (!project) { + throw new BadRequestError({ + message: "Project not found" + }); + } + + const { hasRole } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + if (!hasRole(ProjectMembershipRole.Admin)) + throw new BadRequestError({ message: "Only admins are allowed to take this action" }); + + return projectDAL.updateById(project.id, { pitVersionLimit }); + }; + const updateName = async ({ projectId, actor, @@ -492,6 +532,83 @@ export const projectServiceFactory = ({ return project.upgradeStatus || null; }; + /** + * Return list of CAs for project + */ + const listProjectCas = async ({ + status, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCasDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + const cas = await certificateAuthorityDAL.find({ + projectId: project.id, + ...(status && { status }) + }); + + return cas; + }; + + /** + * Return list of certificates for project + */ + const listProjectCertificates = async ({ + offset, + limit, + actorId, + actorOrgId, + actorAuthMethod, + filter, + actor + }: TListProjectCertsDTO) => { + const project = await projectDAL.findProjectByFilter(filter); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Certificates); + + const cas = await certificateAuthorityDAL.find({ projectId: project.id }); + + const certificates = await certificateDAL.find( + { + $in: { + caId: cas.map((ca) => ca.id) + } + }, + { offset, limit, sort: [["updatedAt", "desc"]] } + ); + + const count = await certificateDAL.countCertificatesInProject(project.id); + + return { + certificates, + totalCount: count + }; + }; + return { createProject, deleteProject, @@ -501,6 +618,9 @@ export const projectServiceFactory = ({ getAProject, toggleAutoCapitalization, updateName, - upgradeProject + upgradeProject, + listProjectCas, + listProjectCertificates, + updateVersionLimit }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index dcd424e18..e2d145d3d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -2,6 +2,7 @@ import { ProjectMembershipRole, TProjectKeys } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CaStatus } from "../certificate-authority/certificate-authority-types"; export enum ProjectFilterType { ID = "id", @@ -43,6 +44,11 @@ export type TToggleProjectAutoCapitalizationDTO = { autoCapitalization: boolean; } & TProjectPermission; +export type TUpdateProjectVersionLimitDTO = { + pitVersionLimit: number; + workspaceSlug: string; +} & Omit; + export type TUpdateProjectNameDTO = { name: string; } & TProjectPermission; @@ -75,3 +81,14 @@ export type AddUserToWsDTO = { userPublicKey: string; }[]; }; + +export type TListProjectCasDTO = { + status?: CaStatus; + filter: Filter; +} & Omit; + +export type TListProjectCertsDTO = { + filter: Filter; + offset: number; + limit: number; +} & Omit; diff --git a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts index afae2677f..2e01e3549 100644 --- a/backend/src/services/resource-cleanup/resource-cleanup-queue.ts +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -1,13 +1,19 @@ import { TAuditLogDALFactory } from "@app/ee/services/audit-log/audit-log-dal"; +import { TSnapshotDALFactory } from "@app/ee/services/secret-snapshot/snapshot-dal"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TSecretVersionDALFactory } from "../secret/secret-version-dal"; +import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal"; import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal"; type TDailyResourceCleanUpQueueServiceFactoryDep = { auditLogDAL: Pick; identityAccessTokenDAL: Pick; + secretVersionDAL: Pick; + secretFolderVersionDAL: Pick; + snapshotDAL: Pick; secretSharingDAL: Pick; queueService: TQueueServiceFactory; }; @@ -17,6 +23,9 @@ export type TDailyResourceCleanUpQueueServiceFactory = ReturnType { @@ -25,6 +34,9 @@ export const dailyResourceCleanUpQueueServiceFactory = ({ await auditLogDAL.pruneAuditLog(); await identityAccessTokenDAL.removeExpiredTokens(); await secretSharingDAL.pruneExpiredSharedSecrets(); + await snapshotDAL.pruneExcessSnapshots(); + await secretVersionDAL.pruneExcessVersions(); + await secretFolderVersionDAL.pruneExcessVersions(); logger.info(`${QueueName.DailyResourceCleanUp}: queue task completed`); }); diff --git a/backend/src/services/secret-folder/secret-folder-version-dal.ts b/backend/src/services/secret-folder/secret-folder-version-dal.ts index 73b536b48..fb68ce801 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -62,5 +62,32 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { } }; - return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId }; + const pruneExcessVersions = async () => { + try { + await db(TableName.SecretFolderVersion) + .with("folder_cte", (qb) => { + void qb + .from(TableName.SecretFolderVersion) + .select( + "id", + "folderId", + db.raw( + `ROW_NUMBER() OVER (PARTITION BY ${TableName.SecretFolderVersion}."folderId" ORDER BY ${TableName.SecretFolderVersion}."createdAt" DESC) AS row_num` + ) + ); + }) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolderVersion}.envId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) + .join("folder_cte", "folder_cte.id", `${TableName.SecretFolderVersion}.id`) + .whereRaw(`folder_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (error) { + throw new DatabaseError({ + error, + name: "Secret Folder Version Prune" + }); + } + }; + + return { ...secretFolderVerOrm, findLatestFolderVersions, findLatestVersionByFolderId, pruneExcessVersions }; }; diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index ed8f5fec7..916e812e6 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -42,7 +42,8 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe name, slug, color, - createdBy: actorId + createdBy: actorId, + createdByActorType: actor }); return newTag; }; diff --git a/backend/src/services/secret/secret-dal.ts b/backend/src/services/secret/secret-dal.ts index 1a2e414dd..790b403dd 100644 --- a/backend/src/services/secret/secret-dal.ts +++ b/backend/src/services/secret/secret-dal.ts @@ -311,6 +311,40 @@ export const secretDALFactory = (db: TDbClient) => { } }; + const findOneWithTags = async (filter: Partial, tx?: Knex) => { + try { + const rawDocs = await (tx || db)(TableName.Secret) + .where(filter) + .leftJoin(TableName.JnSecretTag, `${TableName.Secret}.id`, `${TableName.JnSecretTag}.${TableName.Secret}Id`) + .leftJoin(TableName.SecretTag, `${TableName.JnSecretTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id`) + .select(selectAllTableCols(TableName.Secret)) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("name").withSchema(TableName.SecretTag).as("tagName")); + const docs = sqlNestRelationships({ + data: rawDocs, + key: "id", + parentMapper: (el) => ({ _id: el.id, ...SecretsSchema.parse(el) }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug, tagName: name }) => ({ + id, + color, + slug, + name + }) + } + ] + }); + return docs?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindOneWIthTags" }); + } + }; + return { ...secretOrm, update, @@ -318,6 +352,7 @@ export const secretDALFactory = (db: TDbClient) => { deleteMany, bulkUpdateNoVersionIncrement, getSecretTags, + findOneWithTags, findByFolderId, findByFolderIds, findByBlindIndexes, diff --git a/backend/src/services/secret/secret-fns.ts b/backend/src/services/secret/secret-fns.ts index 3cd6c4e6e..aa112e6b0 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -309,7 +309,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; const expandSecrets = async ( - secrets: Record + secrets: Record ) => { const expandedSec: Record = {}; const interpolatedSec: Record = {}; @@ -329,8 +329,8 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD // should not do multi line encoding if user has set it to skip // eslint-disable-next-line secrets[key].value = secrets[key].skipMultilineEncoding - ? expandedSec[key] - : formatMultiValueEnv(expandedSec[key]); + ? formatMultiValueEnv(expandedSec[key]) + : expandedSec[key]; // eslint-disable-next-line continue; } @@ -347,7 +347,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD ); // eslint-disable-next-line - secrets[key].value = secrets[key].skipMultilineEncoding ? expandedVal : formatMultiValueEnv(expandedVal); + secrets[key].value = secrets[key].skipMultilineEncoding ? formatMultiValueEnv(expandedVal) : expandedVal; } return secrets; @@ -356,7 +356,17 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; export const decryptSecretRaw = ( - secret: TSecrets & { workspace: string; environment: string; secretPath: string }, + secret: TSecrets & { + workspace: string; + environment: string; + secretPath: string; + tags?: { + id: string; + slug: string; + color?: string | null; + name: string; + }[]; + }, key: string ) => { const secretKey = decryptSymmetric128BitHexKeyUTF8({ @@ -395,7 +405,9 @@ export const decryptSecretRaw = ( type: secret.type, _id: secret.id, id: secret.id, - user: secret.userId + user: secret.userId, + tags: secret.tags, + skipMultilineEncoding: secret.skipMultilineEncoding }; }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index d40a18e5e..42e13b445 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -1,4 +1,6 @@ /* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + import { getConfig } from "@app/lib/config/env"; import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; import { daysToMillisecond, secondsToMillis } from "@app/lib/dates"; @@ -67,7 +69,10 @@ const MAX_SYNC_SECRET_DEPTH = 5; export const uniqueSecretQueueKey = (environment: string, secretPath: string) => `secret-queue-dedupe-${environment}-${secretPath}`; -type TIntegrationSecret = Record; +type TIntegrationSecret = Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined } +>; export const secretQueueFactory = ({ queueService, integrationDAL, @@ -567,11 +572,14 @@ export const secretQueueFactory = ({ isSynced: true }); } catch (err: unknown) { - logger.info("Secret integration sync error:", err); + logger.info("Secret integration sync error: %o", err); + const message = + err instanceof AxiosError ? JSON.stringify((err as AxiosError)?.response?.data) : (err as Error)?.message; + await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), - syncMessage: (err as Error)?.message, + syncMessage: message, isSynced: false }); } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 5688f7f15..a5a469a8f 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -608,7 +608,7 @@ export const secretServiceFactory = ({ } const secret = await (version === undefined - ? secretDAL.findOne({ + ? secretDAL.findOneWithTags({ folderId, type: secretType, userId: secretType === SecretType.Personal ? actorId : null, @@ -952,15 +952,49 @@ export const secretServiceFactory = ({ }); const decryptedSecrets = secrets.map((el) => decryptSecretRaw(el, botKey)); - const decryptedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => ({ - ...el, - secrets: importedSecrets.map((sec) => + const processedImports = (imports || [])?.map(({ secrets: importedSecrets, ...el }) => { + const decryptedImportSecrets = importedSecrets.map((sec) => decryptSecretRaw( { ...sec, environment: el.environment, workspace: projectId, secretPath: el.secretPath }, botKey ) - ) - })); + ); + + // secret-override to handle duplicate keys from different import levels + // this prioritizes secret values from direct imports + const importedKeys = new Set(); + const importedEntries = decryptedImportSecrets.reduce( + ( + accum: { + secretKey: string; + secretPath: string; + workspace: string; + environment: string; + secretValue: string; + secretComment: string; + version: number; + type: string; + _id: string; + id: string; + user: string | null | undefined; + skipMultilineEncoding: boolean | null | undefined; + }[], + sec + ) => { + if (!importedKeys.has(sec.secretKey)) { + importedKeys.add(sec.secretKey); + return [...accum, sec]; + } + return accum; + }, + [] + ); + + return { + ...el, + secrets: importedEntries + }; + }); if (expandSecretReferences) { const expandSecrets = interpolateSecrets({ @@ -971,10 +1005,24 @@ export const secretServiceFactory = ({ }); const batchSecretsExpand = async ( - secretBatch: { secretKey: string; secretValue: string; secretComment?: string; secretPath: string }[] + secretBatch: { + secretKey: string; + secretValue: string; + secretComment?: string; + secretPath: string; + skipMultilineEncoding: boolean | null | undefined; + }[] ) => { // Group secrets by secretPath - const secretsByPath: Record = {}; + const secretsByPath: Record< + string, + { + secretKey: string; + secretValue: string; + secretComment?: string; + skipMultilineEncoding: boolean | null | undefined; + }[] + > = {}; secretBatch.forEach((secret) => { if (!secretsByPath[secret.secretPath]) { @@ -990,11 +1038,15 @@ export const secretServiceFactory = ({ continue; } - const secretRecord: Record = {}; + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding: boolean | null | undefined } + > = {}; secretsByPath[secPath].forEach((decryptedSecret) => { secretRecord[decryptedSecret.secretKey] = { value: decryptedSecret.secretValue, - comment: decryptedSecret.secretComment + comment: decryptedSecret.secretComment, + skipMultilineEncoding: decryptedSecret.skipMultilineEncoding }; }); @@ -1011,12 +1063,12 @@ export const secretServiceFactory = ({ await batchSecretsExpand(decryptedSecrets); // expand imports by batch - await Promise.all(decryptedImports.map((decryptedImport) => batchSecretsExpand(decryptedImport.secrets))); + await Promise.all(processedImports.map((processedImport) => batchSecretsExpand(processedImport.secrets))); } return { secrets: decryptedSecrets, - imports: decryptedImports + imports: processedImports }; }; @@ -1068,7 +1120,8 @@ export const secretServiceFactory = ({ secretPath, secretValue, secretComment, - skipMultilineEncoding + skipMultilineEncoding, + tagIds }: TCreateSecretRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -1096,7 +1149,8 @@ export const secretServiceFactory = ({ secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, secretCommentTag: secretCommentEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); return decryptSecretRaw(secret, botKey); @@ -1113,7 +1167,8 @@ export const secretServiceFactory = ({ type, secretPath, secretValue, - skipMultilineEncoding + skipMultilineEncoding, + tagIds }: TUpdateSecretRawDTO) => { const botKey = await projectBotService.getBotKey(projectId); if (!botKey) throw new BadRequestError({ message: "Project bot not found", name: "bot_not_found_error" }); @@ -1133,7 +1188,8 @@ export const secretServiceFactory = ({ secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); await snapshotService.performSnapshot(secret.folderId); diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 18a0077fe..1aac324c5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -164,6 +164,7 @@ export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretValue: string; type: SecretType; + tagIds?: string[]; secretComment?: string; skipMultilineEncoding?: boolean; }; @@ -174,6 +175,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { secretName: string; secretValue?: string; type: SecretType; + tagIds?: string[]; skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 203406e30..4d641bb8d 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -111,8 +111,37 @@ export const secretVersionDALFactory = (db: TDbClient) => { } }; + const pruneExcessVersions = async () => { + try { + await db(TableName.SecretVersion) + .with("version_cte", (qb) => { + void qb + .from(TableName.SecretVersion) + .select( + "id", + "folderId", + db.raw( + `ROW_NUMBER() OVER (PARTITION BY ${TableName.SecretVersion}."secretId" ORDER BY ${TableName.SecretVersion}."createdAt" DESC) AS row_num` + ) + ); + }) + .join(TableName.SecretFolder, `${TableName.SecretFolder}.id`, `${TableName.SecretVersion}.folderId`) + .join(TableName.Environment, `${TableName.Environment}.id`, `${TableName.SecretFolder}.envId`) + .join(TableName.Project, `${TableName.Project}.id`, `${TableName.Environment}.projectId`) + .join("version_cte", "version_cte.id", `${TableName.SecretVersion}.id`) + .whereRaw(`version_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (error) { + throw new DatabaseError({ + error, + name: "Secret Version Prune" + }); + } + }; + return { ...secretVersionOrm, + pruneExcessVersions, findLatestVersionMany, bulkUpdate, findLatestVersionByFolderId, diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 7d6b98b31..1fb89c553 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -41,21 +41,8 @@ export enum SmtpHost { Office365 = "smtp.office365.com" } -export const getTlsOption = (host?: SmtpHost | string, secure?: boolean) => { - if (!secure) return { secure: false }; - if (!host) return { secure: true }; - - if ((host as SmtpHost) === SmtpHost.Sendgrid) { - return { secure: true, port: 465 }; // more details here https://nodemailer.com/smtp/ - } - if (host.includes("amazonaws.com")) { - return { tls: { ciphers: "TLSv1.2" } }; - } - return { requireTLS: true, tls: { ciphers: "TLSv1.2" } }; -}; - export const smtpServiceFactory = (cfg: TSmtpConfig) => { - const smtp = createTransport({ ...cfg, ...getTlsOption(cfg.host, cfg.secure) }); + const smtp = createTransport(cfg); const isSmtpOn = Boolean(cfg.host); const sendMail = async ({ substitutions, recipients, template, subjectLine }: TSmtpSendMail) => { diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index a82259db6..693078fbd 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -21,6 +21,7 @@ type TUserServiceFactoryDep = { | "findOneUserAction" | "createUserAction" | "findUserEncKeyByUserId" + | "delete" >; userAliasDAL: Pick; orgMembershipDAL: Pick; @@ -85,7 +86,7 @@ export const userServiceFactory = ({ tx ); - // check if there are users with the same email. + // check if there are verified users with the same email. const users = await userDAL.find( { email, @@ -134,6 +135,15 @@ export const userServiceFactory = ({ ); } } else { + await userDAL.delete( + { + email, + isAccepted: false, + isEmailVerified: false + }, + tx + ); + // update current user's username to [email] await userDAL.updateById( user.id, diff --git a/cli/.gitignore b/cli/.gitignore index 5fa3e39c5..8eb54d72b 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -1,3 +1,4 @@ .infisical.json dist/ agent-config.test.yaml +.test.env \ No newline at end of file diff --git a/cli/go.mod b/cli/go.mod index 833745eff..6a1da8c6d 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,7 +3,9 @@ module github.com/Infisical/infisical-merge go 1.21 require ( + github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/charmbracelet/lipgloss v0.5.0 + github.com/creack/pty v1.1.21 github.com/denisbrodbeck/machineid v1.0.1 github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.8.0 @@ -29,7 +31,6 @@ require ( require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect - github.com/bradleyjkemp/cupaloy/v2 v2.8.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index 353579136..ff3030a9c 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -74,6 +74,8 @@ github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= +github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/cli/packages/util/common.go b/cli/packages/util/common.go index 2b57383ef..55907da9d 100644 --- a/cli/packages/util/common.go +++ b/cli/packages/util/common.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" "os" + + "github.com/Infisical/infisical-merge/packages/config" ) func GetHomeDir() (string, error) { @@ -21,7 +23,7 @@ func WriteToFile(fileName string, dataToWrite []byte, filePerm os.FileMode) erro return nil } -func CheckIsConnectedToInternet() (ok bool) { - _, err := http.Get("http://clients3.google.com/generate_204") +func ValidateInfisicalAPIConnection() (ok bool) { + _, err := http.Get(fmt.Sprintf("%v/status", config.INFISICAL_URL)) return err == nil } diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 27f0636a9..02c2704bd 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -307,32 +307,33 @@ func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tag } func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectConfigFilePath string) ([]models.SingleEnvironmentVariable, error) { - isConnected := CheckIsConnectedToInternet() var secretsToReturn []models.SingleEnvironmentVariable // var serviceTokenDetails api.GetServiceTokenDetailsResponse var errorToReturn error if params.InfisicalToken == "" && params.UniversalAuthAccessToken == "" { - if isConnected { - log.Debug().Msg("GetAllEnvironmentVariables: Connected to internet, checking logged in creds") - - if projectConfigFilePath == "" { - RequireLocalWorkspaceFile() - } else { - ValidateWorkspaceFile(projectConfigFilePath) - } - - RequireLogin() + if projectConfigFilePath == "" { + RequireLocalWorkspaceFile() + } else { + ValidateWorkspaceFile(projectConfigFilePath) } + RequireLogin() + log.Debug().Msg("GetAllEnvironmentVariables: Trying to fetch secrets using logged in details") loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + isConnected := ValidateInfisicalAPIConnection() + + if isConnected { + log.Debug().Msg("GetAllEnvironmentVariables: Connected to Infisical instance, checking logged in creds") + } + if err != nil { return nil, err } - if loggedInUserDetails.LoginExpired { + if isConnected && loggedInUserDetails.LoginExpired { PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") } @@ -364,12 +365,12 @@ func GetAllEnvironmentVariables(params models.GetAllSecretsParameters, projectCo backupSecretsEncryptionKey := []byte(loggedInUserDetails.UserCredentials.PrivateKey)[0:32] if errorToReturn == nil { - WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, backupSecretsEncryptionKey, secretsToReturn) + WriteBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupSecretsEncryptionKey, secretsToReturn) } // only attempt to serve cached secrets if no internet connection and if at least one secret cached if !isConnected { - backedSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, backupSecretsEncryptionKey) + backedSecrets, err := ReadBackupSecrets(infisicalDotJson.WorkspaceId, params.Environment, params.SecretsPath, backupSecretsEncryptionKey) if len(backedSecrets) > 0 { PrintWarning("Unable to fetch latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug") secretsToReturn = backedSecrets @@ -634,8 +635,9 @@ func GetPlainTextSecrets(key []byte, encryptedSecrets []api.EncryptedSecretV3) ( return plainTextSecrets, nil } -func WriteBackupSecrets(workspace string, environment string, encryptionKey []byte, secrets []models.SingleEnvironmentVariable) error { - fileName := fmt.Sprintf("secrets_%s_%s", workspace, environment) +func WriteBackupSecrets(workspace string, environment string, secretsPath string, encryptionKey []byte, secrets []models.SingleEnvironmentVariable) error { + formattedPath := strings.ReplaceAll(secretsPath, "/", "-") + fileName := fmt.Sprintf("secrets_%s_%s_%s", workspace, environment, formattedPath) secrets_backup_folder_name := "secrets-backup" _, fullConfigFileDirPath, err := GetFullConfigFilePath() @@ -672,8 +674,9 @@ func WriteBackupSecrets(workspace string, environment string, encryptionKey []by return nil } -func ReadBackupSecrets(workspace string, environment string, encryptionKey []byte) ([]models.SingleEnvironmentVariable, error) { - fileName := fmt.Sprintf("secrets_%s_%s", workspace, environment) +func ReadBackupSecrets(workspace string, environment string, secretsPath string, encryptionKey []byte) ([]models.SingleEnvironmentVariable, error) { + formattedPath := strings.ReplaceAll(secretsPath, "/", "-") + fileName := fmt.Sprintf("secrets_%s_%s_%s", workspace, environment, formattedPath) secrets_backup_folder_name := "secrets-backup" _, fullConfigFileDirPath, err := GetFullConfigFilePath() diff --git a/cli/scripts/export_test_env.sh b/cli/scripts/export_test_env.sh new file mode 100644 index 000000000..0b242281d --- /dev/null +++ b/cli/scripts/export_test_env.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +TEST_ENV_FILE=".test.env" + +# Check if the .env file exists +if [ ! -f "$TEST_ENV_FILE" ]; then + echo "$TEST_ENV_FILE does not exist." + exit 1 +fi + +# Export the variables +while IFS= read -r line +do + # Skip empty lines and lines starting with # + if [[ -z "$line" || "$line" =~ ^\# ]]; then + continue + fi + # Read the key-value pair + IFS='=' read -r key value <<< "$line" + eval export $key=\$value +done < "$TEST_ENV_FILE" + +echo "Test environment variables set." diff --git a/cli/test/.snapshots/test-TestUserAuth_SecretsGetAll b/cli/test/.snapshots/test-TestUserAuth_SecretsGetAll new file mode 100644 index 000000000..260607e97 --- /dev/null +++ b/cli/test/.snapshots/test-TestUserAuth_SecretsGetAll @@ -0,0 +1,7 @@ +┌───────────────┬──────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├───────────────┼──────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└───────────────┴──────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection new file mode 100644 index 000000000..c48627f73 --- /dev/null +++ b/cli/test/.snapshots/test-testUserAuth_SecretsGetAllWithoutConnection @@ -0,0 +1,8 @@ +Warning: Unable to fetch latest secret(s) due to connection error, serving secrets from last successful fetch. For more info, run with --debug +┌───────────────┬──────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├───────────────┼──────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└───────────────┴──────────────┴─────────────┘ diff --git a/cli/test/export_test.go b/cli/test/export_test.go index 9a936871d..c44bf20af 100644 --- a/cli/test/export_test.go +++ b/cli/test/export_test.go @@ -8,7 +8,6 @@ import ( func TestUniversalAuth_ExportSecretsWithImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") @@ -24,8 +23,6 @@ func TestUniversalAuth_ExportSecretsWithImports(t *testing.T) { } func TestServiceToken_ExportSecretsWithImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") if err != nil { @@ -41,8 +38,6 @@ func TestServiceToken_ExportSecretsWithImports(t *testing.T) { func TestUniversalAuth_ExportSecretsWithoutImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") if err != nil { @@ -57,8 +52,6 @@ func TestUniversalAuth_ExportSecretsWithoutImports(t *testing.T) { } func TestServiceToken_ExportSecretsWithoutImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") if err != nil { diff --git a/cli/test/helper.go b/cli/test/helper.go index 995367c4b..819f4c4c9 100644 --- a/cli/test/helper.go +++ b/cli/test/helper.go @@ -2,10 +2,10 @@ package tests import ( "fmt" + "log" "os" "os/exec" "strings" - "testing" ) const ( @@ -23,6 +23,8 @@ type Credentials struct { ServiceToken string ProjectID string EnvSlug string + UserEmail string + UserPassword string } var creds = Credentials{ @@ -32,18 +34,21 @@ var creds = Credentials{ ServiceToken: os.Getenv("CLI_TESTS_SERVICE_TOKEN"), ProjectID: os.Getenv("CLI_TESTS_PROJECT_ID"), EnvSlug: os.Getenv("CLI_TESTS_ENV_SLUG"), + UserEmail: os.Getenv("CLI_TESTS_USER_EMAIL"), + UserPassword: os.Getenv("CLI_TESTS_USER_PASSWORD"), } func ExecuteCliCommand(command string, args ...string) (string, error) { cmd := exec.Command(command, args...) output, err := cmd.CombinedOutput() if err != nil { + fmt.Println(fmt.Sprint(err) + ": " + string(output)) return strings.TrimSpace(string(output)), err } return strings.TrimSpace(string(output)), nil } -func SetupCli(t *testing.T) { +func SetupCli() { if creds.ClientID == "" || creds.ClientSecret == "" || creds.ServiceToken == "" || creds.ProjectID == "" || creds.EnvSlug == "" { panic("Missing required environment variables") @@ -57,7 +62,7 @@ func SetupCli(t *testing.T) { if !alreadyBuilt { if err := exec.Command("go", "build", "../.").Run(); err != nil { - t.Fatal(err) + log.Fatal(err) } } diff --git a/cli/test/login_test.go b/cli/test/login_test.go index 0f4591413..71273a3ec 100644 --- a/cli/test/login_test.go +++ b/cli/test/login_test.go @@ -1,14 +1,124 @@ package tests import ( + "log" + "os/exec" + "strings" "testing" + "github.com/creack/pty" "github.com/stretchr/testify/assert" ) -func MachineIdentityLoginCmd(t *testing.T) { - SetupCli(t) +func UserInitCmd() { + c := exec.Command(FORMATTED_CLI_NAME, "init") + ptmx, err := pty.Start(c) + if err != nil { + log.Fatalf("error running CLI command: %v", err) + } + defer func() { _ = ptmx.Close() }() + stepChan := make(chan int, 10) + + go func() { + buf := make([]byte, 1024) + step := -1 + for { + n, err := ptmx.Read(buf) + if n > 0 { + terminalOut := string(buf) + if strings.Contains(terminalOut, "Which Infisical organization would you like to select a project from?") && step < 0 { + step += 1 + stepChan <- step + } else if strings.Contains(terminalOut, "Which of your Infisical projects would you like to connect this project to?") && step < 1 { + step += 1; + stepChan <- step + } + } + if err != nil { + close(stepChan) + return + } + } + }() + + for i := range stepChan { + switch i { + case 0: + ptmx.Write([]byte("\n")) + case 1: + ptmx.Write([]byte("\n")) + } + } +} + +func UserLoginCmd() { + // set vault to file because CI has no keyring + vaultCmd := exec.Command(FORMATTED_CLI_NAME, "vault", "set", "file") + _, err := vaultCmd.Output() + if err != nil { + log.Fatalf("error setting vault: %v", err) + } + + // Start programmatic interaction with CLI + c := exec.Command(FORMATTED_CLI_NAME, "login", "--interactive") + ptmx, err := pty.Start(c) + if err != nil { + log.Fatalf("error running CLI command: %v", err) + } + defer func() { _ = ptmx.Close() }() + + stepChan := make(chan int, 10) + + go func() { + buf := make([]byte, 1024) + step := -1 + for { + n, err := ptmx.Read(buf) + if n > 0 { + terminalOut := string(buf) + if strings.Contains(terminalOut, "Infisical Cloud") && step < 0 { + step += 1; + stepChan <- step + } else if strings.Contains(terminalOut, "Email") && step < 1 { + step += 1; + stepChan <- step + } else if strings.Contains(terminalOut, "Password") && step < 2 { + step += 1; + stepChan <- step + } else if strings.Contains(terminalOut, "Infisical organization") && step < 3 { + step += 1; + stepChan <- step + } else if strings.Contains(terminalOut, "Enter passphrase") && step < 4 { + step += 1; + stepChan <- step + } + } + if err != nil { + close(stepChan) + return + } + } + }() + + for i := range stepChan { + switch i { + case 0: + ptmx.Write([]byte("\n")) + case 1: + ptmx.Write([]byte(creds.UserEmail)) + ptmx.Write([]byte("\n")) + case 2: + ptmx.Write([]byte(creds.UserPassword)) + ptmx.Write([]byte("\n")) + case 3: + ptmx.Write([]byte("\n")) + } + } + +} + +func MachineIdentityLoginCmd(t *testing.T) { if creds.UAAccessToken != "" { return } diff --git a/cli/test/main_test.go b/cli/test/main_test.go new file mode 100644 index 000000000..e14893aec --- /dev/null +++ b/cli/test/main_test.go @@ -0,0 +1,23 @@ +package tests + +import ( + "fmt" + "os" + "testing" +) + +func TestMain(m *testing.M) { + // Setup + fmt.Println("Setting up CLI...") + SetupCli() + fmt.Println("Performing user login...") + UserLoginCmd() + fmt.Println("Performing infisical init...") + UserInitCmd() + + // Run the tests + code := m.Run() + + // Exit + os.Exit(code) +} diff --git a/cli/test/run_test.go b/cli/test/run_test.go index 808f4f14f..d2c6021cc 100644 --- a/cli/test/run_test.go +++ b/cli/test/run_test.go @@ -8,8 +8,6 @@ import ( ) func TestServiceToken_RunCmdRecursiveAndImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") if err != nil { @@ -25,8 +23,6 @@ func TestServiceToken_RunCmdRecursiveAndImports(t *testing.T) { } } func TestServiceToken_RunCmdWithImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") if err != nil { @@ -44,8 +40,6 @@ func TestServiceToken_RunCmdWithImports(t *testing.T) { func TestUniversalAuth_RunCmdRecursiveAndImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") if err != nil { @@ -63,8 +57,6 @@ func TestUniversalAuth_RunCmdRecursiveAndImports(t *testing.T) { func TestUniversalAuth_RunCmdWithImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") if err != nil { @@ -83,8 +75,6 @@ func TestUniversalAuth_RunCmdWithImports(t *testing.T) { func TestUniversalAuth_RunCmdWithoutImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") if err != nil { @@ -101,8 +91,6 @@ func TestUniversalAuth_RunCmdWithoutImports(t *testing.T) { } func TestServiceToken_RunCmdWithoutImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") if err != nil { diff --git a/cli/test/secrets_by_name_test.go b/cli/test/secrets_by_name_test.go index 440324e1a..26a8314bb 100644 --- a/cli/test/secrets_by_name_test.go +++ b/cli/test/secrets_by_name_test.go @@ -7,8 +7,6 @@ import ( ) func TestServiceToken_GetSecretsByNameRecursive(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -23,8 +21,6 @@ func TestServiceToken_GetSecretsByNameRecursive(t *testing.T) { } func TestServiceToken_GetSecretsByNameWithNotFoundSecret(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -39,8 +35,6 @@ func TestServiceToken_GetSecretsByNameWithNotFoundSecret(t *testing.T) { } func TestServiceToken_GetSecretsByNameWithImports(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -56,8 +50,6 @@ func TestServiceToken_GetSecretsByNameWithImports(t *testing.T) { func TestUniversalAuth_GetSecretsByNameRecursive(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -73,8 +65,6 @@ func TestUniversalAuth_GetSecretsByNameRecursive(t *testing.T) { func TestUniversalAuth_GetSecretsByNameWithNotFoundSecret(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -90,8 +80,6 @@ func TestUniversalAuth_GetSecretsByNameWithNotFoundSecret(t *testing.T) { func TestUniversalAuth_GetSecretsByNameWithImports(t *testing.T) { MachineIdentityLoginCmd(t) - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { diff --git a/cli/test/secrets_test.go b/cli/test/secrets_test.go index 453666406..f5d5a7b1f 100644 --- a/cli/test/secrets_test.go +++ b/cli/test/secrets_test.go @@ -3,12 +3,12 @@ package tests import ( "testing" + "github.com/Infisical/infisical-merge/packages/util" "github.com/bradleyjkemp/cupaloy/v2" ) -func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { - SetupCli(t) +func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") if err != nil { @@ -23,8 +23,6 @@ func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { } func TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { - SetupCli(t) - output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") if err != nil { @@ -39,7 +37,6 @@ func TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing. } func TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { - SetupCli(t) MachineIdentityLoginCmd(t) output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") @@ -56,7 +53,6 @@ func TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { } func TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { - SetupCli(t) MachineIdentityLoginCmd(t) output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") @@ -73,7 +69,6 @@ func TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing } func TestUniversalAuth_SecretsGetWrongEnvironment(t *testing.T) { - SetupCli(t) MachineIdentityLoginCmd(t) output, _ := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", "invalid-env", "--recursive", "--silent") @@ -85,3 +80,45 @@ func TestUniversalAuth_SecretsGetWrongEnvironment(t *testing.T) { } } + +func TestUserAuth_SecretsGetAll(t *testing.T) { + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + + // explicitly called here because it should happen directly after successful secretsGetAll + testUserAuth_SecretsGetAllWithoutConnection(t) +} + +func testUserAuth_SecretsGetAllWithoutConnection(t *testing.T) { + originalConfigFile, err := util.GetConfigFile() + if err != nil { + t.Fatalf("error getting config file") + } + newConfigFile := originalConfigFile + + // set it to a URL that will always be unreachable + newConfigFile.LoggedInUserDomain = "http://localhost:4999" + util.WriteConfigFile(&newConfigFile) + + // restore config file + defer util.WriteConfigFile(&originalConfigFile) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-authorities/cert.mdx b/docs/api-reference/endpoints/certificate-authorities/cert.mdx new file mode 100644 index 000000000..3706e0b11 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve certificate / chain" +openapi: "GET /api/v1/pki/ca/{caId}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/create.mdx b/docs/api-reference/endpoints/certificate-authorities/create.mdx new file mode 100644 index 000000000..35e758e4b --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/ca" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/crl.mdx b/docs/api-reference/endpoints/certificate-authorities/crl.mdx new file mode 100644 index 000000000..a7b7755de --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/crl.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve CRL" +openapi: "GET /api/v1/pki/ca/{caId}/crl" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/csr.mdx b/docs/api-reference/endpoints/certificate-authorities/csr.mdx new file mode 100644 index 000000000..2477a629e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/csr.mdx @@ -0,0 +1,4 @@ +--- +title: "Get CSR" +openapi: "GET /api/v1/pki/ca/{caId}/csr" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/delete.mdx b/docs/api-reference/endpoints/certificate-authorities/delete.mdx new file mode 100644 index 000000000..f79b8f458 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx new file mode 100644 index 000000000..7f0e40f95 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/import-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Import certificate" +openapi: "POST /api/v1/pki/ca/{caId}/import-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx new file mode 100644 index 000000000..045cada58 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx @@ -0,0 +1,4 @@ +--- +title: "Issue certificate" +openapi: "POST /api/v1/pki/ca/{caId}/issue-certificate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/read.mdx b/docs/api-reference/endpoints/certificate-authorities/read.mdx new file mode 100644 index 000000000..54dc26392 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx new file mode 100644 index 000000000..310bbea26 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/sign-intermediate.mdx @@ -0,0 +1,4 @@ +--- +title: "Sign intermediate certificate" +openapi: "POST /api/v1/pki/ca/{caId}/sign-intermediate" +--- diff --git a/docs/api-reference/endpoints/certificate-authorities/update.mdx b/docs/api-reference/endpoints/certificate-authorities/update.mdx new file mode 100644 index 000000000..d18a728bf --- /dev/null +++ b/docs/api-reference/endpoints/certificate-authorities/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/ca/{caId}" +--- diff --git a/docs/api-reference/endpoints/certificates/cert-body.mdx b/docs/api-reference/endpoints/certificates/cert-body.mdx new file mode 100644 index 000000000..e4c3b0123 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/cert-body.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Certificate Body / Chain" +openapi: "GET /api/v1/pki/certificates/{serialNumber}/certificate" +--- diff --git a/docs/api-reference/endpoints/certificates/delete.mdx b/docs/api-reference/endpoints/certificates/delete.mdx new file mode 100644 index 000000000..27042af42 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/read.mdx b/docs/api-reference/endpoints/certificates/read.mdx new file mode 100644 index 000000000..ce6463dde --- /dev/null +++ b/docs/api-reference/endpoints/certificates/read.mdx @@ -0,0 +1,4 @@ +--- +title: "Retrieve" +openapi: "GET /api/v1/pki/certificates/{serialNumber}" +--- diff --git a/docs/api-reference/endpoints/certificates/revoke.mdx b/docs/api-reference/endpoints/certificates/revoke.mdx new file mode 100644 index 000000000..e4da73a19 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/revoke.mdx @@ -0,0 +1,4 @@ +--- +title: "Revoke" +openapi: "POST /api/v1/pki/certificates/{serialNumber}/revoke" +--- diff --git a/docs/documentation/guides/local-development.mdx b/docs/documentation/guides/local-development.mdx index c2651cb58..9ffc0fca0 100644 --- a/docs/documentation/guides/local-development.mdx +++ b/docs/documentation/guides/local-development.mdx @@ -13,11 +13,11 @@ There is a number of issues that arise with secret management in local developme ## Solution -One of the main benefits of Infisical is the facilitation of secret management workflows in local development use cases. In particular, Infisical heavily follows the "Security Shift Left" principle to enable developers to effotlessly follow secure practices when coding. +One of the main benefits of Infisical is the facilitation of secret management workflows in local development use cases. In particular, Infisical heavily follows the "Security Shift Left" principle to enable developers to effortlessly follow secure practices when coding. ### CLI -[Infisical CLI](/cli/overview) is the most frequently used Infisical tool for secret management in local development environments. It makes it easy to inject secrets right into the local application environments based on the permissions given to corresponsing developers. +[Infisical CLI](/cli/overview) is the most frequently used Infisical tool for secret management in local development environments. It makes it easy to inject secrets right into the local application environments based on the permissions given to corresponding developers. ### Dashboard @@ -31,4 +31,4 @@ By default, all the secrets in the Infisical environments are shared among proje ### Secret Scanning -In addition, Infisical also provides a set of tools to automatically prevent secret leaks to git history. This functionlality can be set up on the level of [Infisical CLI using pre-commit hooks](/cli/scanning-overview#automatically-scan-changes-before-you-commit) or through a direct integration with platforms like GitHub. \ No newline at end of file +In addition, Infisical also provides a set of tools to automatically prevent secret leaks to git history. This functionality can be set up on the level of [Infisical CLI using pre-commit hooks](/cli/scanning-overview#automatically-scan-changes-before-you-commit) or through a direct integration with platforms like GitHub. \ No newline at end of file diff --git a/docs/documentation/platform/pki/certificates.mdx b/docs/documentation/platform/pki/certificates.mdx new file mode 100644 index 000000000..fe5546681 --- /dev/null +++ b/docs/documentation/platform/pki/certificates.mdx @@ -0,0 +1,211 @@ +--- +title: "Certificates" +sidebarTitle: "Certificates" +description: "Learn how to issue X.509 certificates with Infisical." +--- + +## Concept + +Assuming that you've created a Private CA hierarchy with a root CA and an intermediate CA, you can now issue/revoke X.509 certificates using the intermediate CA. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] + B --> D[Leaf Certificate] + C --> E[Leaf Certificate] +``` + +
+ +## Workflow + +The typical workflow for managing certificates consists of the following steps: + +1. Issuing a certificate under an intermediate CA with details like name and validity period. +2. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, + you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed + data structure issued by a CA containing a list of revoked certificates to check if a certificate has been revoked. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. + + +## Guide to Issuing Certificates + +In the following steps, we explore how to issue a X.509 certificate under a CA. + + + + + + + To create a certificate, head to your Project > Internal PKI > Certificates and press **Create Certificate**. + + ![pki issue certificate](/images/platform/pki/cert-issue.png) + + Here, set the **CA** to the CA you want to issue the certificate under and fill out details for the certificate. + + ![pki issue certificate modal](/images/platform/pki/cert-issue-modal.png) + + Here's some guidance on each field: + + - Issuing CA: The CA under which to issue the certificate. + - Friendly Name: A friendly name for the certificate; this is only for display and defaults to the common name of the certificate if left empty. + - Common Name (CN): The (common) name of the certificate. + - TTL: The lifetime of the certificate in seconds. + - Valid Until: The date until which the certificate is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + + + + Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. + + ![pki certificate body](/images/platform/pki/cert-body.png) + + + Make sure to download and store the **Private Key** in a secure location as it will only be displayed once at the time of certificate issuance. + The **Certificate Body** and **Certificate Chain** will remain accessible and can be copied at any time. + + + + + + To create a certificate, make an API request to the [Create Certificate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint, + specifying the issuing CA. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//issue-certificate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "commonName": "My Certificate", + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + privateKey: "...", + serialNumber: "..." + } + ``` + + + Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + + + + + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate under a CA and obtain a Certificate Revocation List (CRL) for a CA. + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by + selecting the **Revoke Certificate** option for it and specifying the reason + for revocation. + + ![pki revoke certificate](/images/platform/pki/cert-revoke.png) + + ![pki revoke certificate modal](/images/platform/pki/cert-revoke-modal.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by selecting the **View CRL** option under the + issuing CA and downloading the CRL file. + + ![pki view crl](/images/platform/pki/ca-crl.png) + + ![pki download crl](/images/platform/pki/ca-crl-modal.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + + + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + specifying the serial number of the certificate and the reason for revocation. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "revocationReason": "UNSPECIFIED" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully revoked certificate", + serialNumber: "...", + revokedAt: "..." + } + ``` + + + In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. + To obtain the CRL of the CA, make an API request to the [Get CRL](/api-reference/endpoints/certificate-authorities/crl) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crl' \ + --header 'Authorization: Bearer ' + ``` + + ### Sample response + + ```bash Response + { + crl: "..." + } + ``` + + To verify a certificate against the CRL with OpenSSL, you can use the following command: + + ```bash + openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem + ``` + + + + + + +## FAQ + + + + To renew a certificate, you have to issue a new certificate from the same CA + with the same common name as the old certificate. The original certificate + will continue to be valid through its original TTL unless explicitly + revoked. + + diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx new file mode 100644 index 000000000..259f15a5d --- /dev/null +++ b/docs/documentation/platform/pki/overview.mdx @@ -0,0 +1,12 @@ +--- +title: "Internal PKI" +sidebarTitle: "Overview" +description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." +--- + +Infisical can be used to create a Private Certificate Authority (CA) hierarchy and issue X.509 certificates for internal use. This allows you to manage your own PKI infrastructure and issue digital certificates for services, applications, and devices. + +Infisical's internal PKI offering is split into two modules: + +- [Private CA](/documentation/platform/pki/private-ca): Infisical lets you create private CAs, including root and intermediary CAs. +- [Certificates](/documentation/platform/pki/certificates): Infisical allows you to issue X.509 certificates using the private CAs you create. diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/private-ca.mdx new file mode 100644 index 000000000..0ebb31e2c --- /dev/null +++ b/docs/documentation/platform/pki/private-ca.mdx @@ -0,0 +1,250 @@ +--- +title: "Private CA" +sidebarTitle: "Private CA" +description: "Learn how to create a Private CA hierarchy with Infisical." +--- + +## Concept + +The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities +used to issue digital certificates for services, applications, and devices. + +
+ +```mermaid +graph TD + A[Root CA] + A --> B[Intermediate CA] + A --> C[Intermediate CA] +``` + +
+ +## Workflow + +A typical workflow for setting up a Private CA hierarchy consists of the following steps: + +1. Configuring a root CA with details like name, validity period, and path length. +2. Configuring and chaining intermediate CA(s) with details like name, validity period, path length, and imported certificate. +3. Managing the CA lifecycle events such as CA succession. + + + Note that this workflow can be executed via the Infisical UI or manually such + as via API. If manually executing the workflow, you may have to create a + Certificate Signing Request (CSR) for the intermediate CA, create an + intermediate certificate using the root CA private key and CSR, and import the + intermediate certificate back to the intermediate CA as part of Step 2. + + +## Guide + +In the following steps, we explore how to create a simple Private CA hierarchy +consisting of a root CA and an intermediate CA. + + + + + + To create a root CA, head to your Project > Internal PKI > Certificate Authorities and press **Create CA**. + + ![pki create ca](/images/platform/pki/ca-create.png) + + Here, set the **CA Type** to **Root** and fill out details for the root CA. + + ![pki create root ca](/images/platform/pki/ca-create-root.png) + + Here's some guidance on each field: + + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. A path of `-1` implies no limit; a path of `0` implies no intermediate CAs can be chained. + - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair that the CA creates when it issues a certificate. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. + - Friendly Name: A friendly name for the CA; this is only for display and defaults to the subject of the CA if left empty. + - Organization (O): The organization name. + - Country (C): The country code. + - State or Province Name: The state or province. + - Locality Name: The city or locality. + - Common Name: The name of the CA. + + + The Organization, Country, State or Province Name, Locality Name, and Common Name make up the **Distinguished Name (DN)** or **subject** of the CA. + At least one of these fields must be filled out. + + + + 1.1. To create an intermediate CA, press **Create CA** again but this time specifying the **CA Type** to be **Intermediate**. Fill out the details for the intermediate CA. + + ![pki create intermediate ca](/images/platform/pki/ca-create-intermediate.png) + + 1.2. Next, press the **Install Certificate** option on the intermediate CA from step 1.1. + + ![pki install cert opt](/images/platform/pki/ca-install-intermediate-opt.png) + + Here, set the **Parent CA** to the root CA created in step 1 and configure the intended **Valid Until** and **Path Length** fields on the intermediate CA; feel free to use the prefilled values. + + ![pki install cert](/images/platform/pki/ca-install-intermediate.png) + + Here's some guidance on each field: + + - Parent CA: The parent CA to which this intermediate CA will be chained. In this case, it should be the root CA created in step 1. + - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). The date must be within the validity period of the parent CA. + - Path Length: The maximum number of intermediate CAs that can be chained to this CA. The path length must be less than the path length of the parent CA. + + Finally, press **Install** to chain the intermediate CA to the root CA; this creates a Certificate Signing Request (CSR) for the intermediate CA, creates an intermediate certificate using the root CA private key and CSR, and imports the signed certificate back to the intermediate CA. + + ![pki cas](/images/platform/pki/cas.png) + + Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. + Now check out the [Certificates](/documentation/platform/pki/certificates) page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + + + To create a root CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `root`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "root", + "commonName": "My Root CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "root", + commonName: "My Root CA", + ... + } + } + ``` + + By default, Infisical creates a root CA with the `RSA_2048` key algorithm, validity period of 10 years, with no restrictions on path length; + you may override these defaults by specifying your own options when making the API request. + + + + 2.1. To create an intermediate CA, make an API request to the [Create CA](/api-reference/endpoints/certificate-authorities/create) API endpoint, specifying the `type` as `intermediate`. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectSlug": "", + "type": "intermediate", + "commonName": "My Intermediate CA" + }' + ``` + + ### Sample response + + ```bash Response + { + ca: { + id: "", + type: "intermediate", + commonName: "My Intermediate CA", + ... + } + } + ``` + + 2.2. Next, get a certificate signing request from the intermediate CA by making an API request to the [Get CSR](/api-reference/endpoints/certificate-authorities/csr) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//csr' \ + --header 'Authorization: Bearer ' \ + --data-raw '' + ``` + + ### Sample response + + ```bash Response + { + csr: "..." + } + ``` + + 2.3. Next, create an intermediate certificate by making an API request to the [Sign Intermediate](/api-reference/endpoints/certificate-authorities/sign-intermediate) API endpoint + containing the CSR from step 2.2, referencing the root CA created in step 1. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//sign-intermediate' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "csr": "", + "notAfter": "2029-06-12" + }' + ``` + + ### Sample response + + ```bash Response + { + certificate: "...", + certificateChain: "...", + issuingCaCertificate: "...", + serialNumber: "...", + } + ``` + + + The `notAfter` value must be within the validity period of the root CA that is if the root CA is valid until `2029-06-12`, the intermediate CA must be valid until a date before `2029-06-12`. + + + 2.4. Finally, import the intermediate certificate and certificate chain from step 2.3 back to the intermediate CA by making an API request to the [Import Certificate](/api-reference/endpoints/certificate-authorities/import-cert) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/ca//import-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "certificate": "", + "certificateChain": "" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully imported certificate to CA", + caId: "..." + } + ``` + + Great! You’ve successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the Certificates page to learn more about how to issue X.509 certificates using the intermediate CA. + + + + + + +## FAQ + + + + Infisical supports `RSA 2048`, `RSA 4096`, `ECDSA P-256`, `ECDSA P-384` key + algorithms specified at the time of creating a CA. + + diff --git a/docs/images/platform/pki/ca-create-intermediate.png b/docs/images/platform/pki/ca-create-intermediate.png new file mode 100644 index 000000000..e52e5735c Binary files /dev/null and b/docs/images/platform/pki/ca-create-intermediate.png differ diff --git a/docs/images/platform/pki/ca-create-root.png b/docs/images/platform/pki/ca-create-root.png new file mode 100644 index 000000000..3c954b833 Binary files /dev/null and b/docs/images/platform/pki/ca-create-root.png differ diff --git a/docs/images/platform/pki/ca-create.png b/docs/images/platform/pki/ca-create.png new file mode 100644 index 000000000..35096c721 Binary files /dev/null and b/docs/images/platform/pki/ca-create.png differ diff --git a/docs/images/platform/pki/ca-crl-modal.png b/docs/images/platform/pki/ca-crl-modal.png new file mode 100644 index 000000000..af26b1aca Binary files /dev/null and b/docs/images/platform/pki/ca-crl-modal.png differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png new file mode 100644 index 000000000..4794034a1 Binary files /dev/null and b/docs/images/platform/pki/ca-crl.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate-opt.png b/docs/images/platform/pki/ca-install-intermediate-opt.png new file mode 100644 index 000000000..2bdcbf306 Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate-opt.png differ diff --git a/docs/images/platform/pki/ca-install-intermediate.png b/docs/images/platform/pki/ca-install-intermediate.png new file mode 100644 index 000000000..ca30ad6ff Binary files /dev/null and b/docs/images/platform/pki/ca-install-intermediate.png differ diff --git a/docs/images/platform/pki/cas.png b/docs/images/platform/pki/cas.png new file mode 100644 index 000000000..b532768e2 Binary files /dev/null and b/docs/images/platform/pki/cas.png differ diff --git a/docs/images/platform/pki/cert-body.png b/docs/images/platform/pki/cert-body.png new file mode 100644 index 000000000..8ed67a7ec Binary files /dev/null and b/docs/images/platform/pki/cert-body.png differ diff --git a/docs/images/platform/pki/cert-issue-modal.png b/docs/images/platform/pki/cert-issue-modal.png new file mode 100644 index 000000000..1516ab1cb Binary files /dev/null and b/docs/images/platform/pki/cert-issue-modal.png differ diff --git a/docs/images/platform/pki/cert-issue.png b/docs/images/platform/pki/cert-issue.png new file mode 100644 index 000000000..6b3e5887b Binary files /dev/null and b/docs/images/platform/pki/cert-issue.png differ diff --git a/docs/images/platform/pki/cert-revoke-modal.png b/docs/images/platform/pki/cert-revoke-modal.png new file mode 100644 index 000000000..07bc7fce8 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke-modal.png differ diff --git a/docs/images/platform/pki/cert-revoke.png b/docs/images/platform/pki/cert-revoke.png new file mode 100644 index 000000000..ff7fcc597 Binary files /dev/null and b/docs/images/platform/pki/cert-revoke.png differ diff --git a/docs/images/platform/pki/certs.png b/docs/images/platform/pki/certs.png new file mode 100644 index 000000000..4e1b49959 Binary files /dev/null and b/docs/images/platform/pki/certs.png differ diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 41a41726c..9f3a63f8c 100644 --- a/docs/integrations/platforms/kubernetes.mdx +++ b/docs/integrations/platforms/kubernetes.mdx @@ -24,14 +24,13 @@ The operator can be install via [Helm](https://helm.sh) or [kubectl](https://git **Install the Helm chart** - For production deployments, it is highly recommended to set the chart version and the application version during installs and upgrades. - This will prevent the operator from being accidentally updated to the latest version and introduce unintended breaking changes. - - View application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) + To select a specific version, view the application versions [here](https://hub.docker.com/r/infisical/kubernetes-operator/tags) and chart versions [here](https://cloudsmith.io/~infisical/repos/helm-charts/packages/detail/helm/secrets-operator/#versions) ```bash - helm install --generate-name infisical-helm-charts/secrets-operator --version= --set controllerManager.manager.image.tag= + helm install --generate-name infisical-helm-charts/secrets-operator + ``` + ```bash # Example installing app version v0.2.0 and chart version 0.1.4 helm install --generate-name infisical-helm-charts/secrets-operator --version=0.1.4 --set controllerManager.manager.image.tag=v0.2.0 ``` @@ -58,46 +57,108 @@ Once you apply the manifest, the operator will be installed in `infisical-operat Once you have installed the operator to your cluster, you'll need to create a `InfisicalSecret` custom resource definition (CRD). ```yaml example-infisical-secret-crd.yaml + apiVersion: secrets.infisical.com/v1alpha1 kind: InfisicalSecret metadata: - name: infisicalsecret-sample - labels: - label-to-be-passed-to-managed-secret: sample-value - annotations: - example.com/annotation-to-be-passed-to-managed-secret: "sample-value" + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" spec: - hostAPI: https://app.infisical.com/api - resyncInterval: 10 - authentication: - # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. - # If you have multiple authentication methods defined, it may cause issues. - universalAuth: - secretsScope: - projectSlug: - envSlug: # "dev", "staging", "prod", etc.. - secretsPath: "" # Root is "/" - recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. - - credentialsRef: - secretName: universal-auth-credentials - secretNamespace: default + hostAPI: https://app.infisical.com/api + resyncInterval: 10 + authentication: + # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. + # If you have multiple authentication methods defined, it may cause issues. + + # (Deprecated) Service Token Auth + serviceToken: + serviceTokenSecretReference: + secretName: service-token + secretNamespace: default + secretsScope: + envSlug: + secretsPath: + recursive: true + + # Universal Auth + universalAuth: + secretsScope: + projectSlug: new-ob-em + envSlug: dev # "dev", "staging", "prod", etc.. + secretsPath: "/" # Root is "/" + recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false + credentialsRef: + secretName: universal-auth-credentials + secretNamespace: default + + # Native Kubernetes Auth + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # AWS IAM Auth + awsIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # Azure Auth + azureAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP ID Token Auth + gcpIdTokenAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP IAM Auth + gcpIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + managedSecretReference: + secretName: managed-secret + secretNamespace: default + creationPolicy: "Orphan" ## Owner | Orphan + # secretType: kubernetes.io/dockerconfigjson - # Service tokens are deprecated and will be removed in the near future. Please use Machine Identities for authenticating with Infisical. - serviceToken: - serviceTokenSecretReference: - secretName: service-token - secretNamespace: default - secretsScope: - envSlug: - secretsPath: # Root is "/" - recursive: true # Fetch all secrets from the specified path and all sub-directories. Default is false. - managedSecretReference: - secretName: managed-secret - secretNamespace: default - creationPolicy: "Orphan" ## Owner | Orphan (default) - # secretType: kubernetes.io/dockerconfigjson ``` ### InfisicalSecret CRD properties @@ -156,7 +217,7 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. -{" "} + Make sure to also populate the `secretsScope` field with the project slug @@ -187,6 +248,339 @@ spec: + + The Kubernetes machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within a Kubernetes environment. + + + + 1.1. Start by creating a service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. + + ```yaml infisical-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-auth + namespace: default + + ``` + + ``` + kubectl apply -f infisical-service-account.yaml + ``` + + 1.2. Bind the service account to the `system:auth-delegator` cluster role. As described [here](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#other-component-roles), this role allows delegated authentication and authorization checks, specifically for Infisical to access the [TokenReview API](https://kubernetes.io/docs/reference/kubernetes-api/authentication-resources/token-review-v1/). You can apply the following configuration file: + + ```yaml cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: role-tokenreview-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-auth + namespace: default + ``` + + ``` + kubectl apply -f cluster-role-binding.yaml + ``` + + 1.3. Next, create a long-lived service account JWT token (i.e. the token reviewer JWT token) for the service account using this configuration file for a new `Secret` resource: + + ```yaml service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-auth-token + annotations: + kubernetes.io/service-account.name: "infisical-auth" + ``` + + + ``` + kubectl apply -f service-account-token.yaml + ``` + + 1.4. Link the secret in step 1.3 to the service account in step 1.1: + + ```bash + kubectl patch serviceaccount infisical-auth -p '{"secrets": [{"name": "infisical-auth-token"}]}' -n default + ``` + + 1.5. Finally, retrieve the token reviewer JWT token from the secret. + + ```bash + kubectl get secret infisical-auth-token -n default -o=jsonpath='{.data.token}' | base64 --decode + ``` + + Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + + + + + To create an identity, head to your Organization Settings > Access Control > Machine Identities and press **Create identity**. + + ![identities organization](/images/platform/identities/identities-org.png) + + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. + + ![identities organization create](/images/platform/identities/identities-org-create.png) + + Now input a few details for your new identity. Here's some guidance for each field: + + - Name (required): A friendly name for the identity. + - Role (required): A role from the **Organization Roles** tab for the identity to assume. The organization role assigned will determine what organization level resources this identity can have access to. + + Once you've created an identity, you'll be prompted to configure the authentication method for it. Here, select **Kubernetes Auth**. + + + To learn more about each field of the Kubernetes native authentication method, see step 2 of [guide](/documentation/platform/identities/kubernetes-auth#guide). + + + ![identities organization create auth method](/images/platform/identities/identities-org-create-kubernetes-auth-method.png) + + + + + To allow the operator to use the given identity to access secrets, you will need to add the identity to project(s) that you would like to grant it access to. + + To do this, head over to the project you want to add the identity to and go to Project Settings > Access Control > Machine Identities and press **Add identity**. + + Next, select the identity you want to add to the project and the project level role you want to allow it to assume. The project role assigned will determine what project level resources this identity can have access to. + + ![identities project](/images/platform/identities/identities-project.png) + + ![identities project create](/images/platform/identities/identities-project-create.png) + + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. + In the `authentication.kubernetesAuth.identityId` field, add the identity ID of the machine identity you created. + See the example below for more details. + + + Add the service account details from the previous steps under `authentication.kubernetesAuth.serviceAccountRef`. + Here you will need to enter the name and namespace of the service account. + The example below shows a complete InfisicalSecret resource with all required fields defined. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml example-kubernetes-auth.yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + kubernetesAuth: + identityId: + serviceAccountRef: + name: + namespace: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + ... +``` + + + + + The AWS IAM machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within an AWS environment like an EC2 or a Lambda function. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about AWS machine identities here](/documentation/platform/identities/aws-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. In the `authentication.awsIamAuth.identityId` field, add the identity ID of the machine identity you created. See the example below for more details. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml example-aws-iam-auth.yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + awsIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + ... +``` + + + + + The Azure machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within an Azure environment. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about Azure machine identities here](/documentation/platform/identities/azure-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. In the `authentication.azureAuth.identityId` field, add the identity ID of the machine identity you created. See the example below for more details. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml example-azure-auth.yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + azureAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + ... +``` + + + + + The GCP ID Token machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used within GCP environments. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about GCP machine identities here](/documentation/platform/identities/gcp-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. In the `authentication.gcpIdTokenAuth.identityId` field, add the identity ID of the machine identity you created. See the example below for more details. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml example-gcp-id-token-auth.yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + gcpIdTokenAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + ... +``` + + + + + + + The GCP IAM machine identity authentication method is used to authenticate with Infisical. The identity ID is stored in a field in the InfisicalSecret resource. This authentication method can only be used both within and outside GCP environments. + + + + You need to create a machine identity, and give it access to the project(s) you want to interact with. You can [read more about GCP machine identities here](/documentation/platform/identities/gcp-auth). + + + Once you have created your machine identity and added it to your project(s), you will need to add the identity ID to your InfisicalSecret resource. In the `authentication.gcpIamAuth.identityId` field, add the identity ID of the machine identity you created. + You'll also need to add the service account key file path to your InfisicalSecret resource. In the `authentication.gcpIamAuth.serviceAccountKeyFilePath` field, add the path to your service account key file path. Please see the example below for more details. + + + + + + Make sure to also populate the `secretsScope` field with the project slug + _`projectSlug`_, environment slug _`envSlug`_, and secrets path + _`secretsPath`_ that you want to fetch secrets from. Please see the example + below. + + +## Example + +```yaml example-gcp-id-token-auth.yaml +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample-crd +spec: + authentication: + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: "/path/to-service-account-key-file-path.json" + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + ... +``` + + + Service tokens are being deprecated in favor of [machine identities](/documentation/platform/identities/machine-identities). diff --git a/docs/mint.json b/docs/mint.json index 7b92d065e..840649d03 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -102,6 +102,14 @@ "documentation/platform/webhooks" ] }, + { + "group": "Internal PKI", + "pages": [ + "documentation/platform/pki/overview ", + "documentation/platform/pki/private-ca", + "documentation/platform/pki/certificates" + ] + }, { "group": "Identities", "pages": [ @@ -384,6 +392,7 @@ "pages": [ "sdks/languages/node", "sdks/languages/python", + "sdks/languages/go", "sdks/languages/java", "sdks/languages/csharp" ] @@ -556,6 +565,30 @@ { "group": "Audit Logs", "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + }, + { + "group": "Certificate Authorities", + "pages": [ + "api-reference/endpoints/certificate-authorities/create", + "api-reference/endpoints/certificate-authorities/read", + "api-reference/endpoints/certificate-authorities/update", + "api-reference/endpoints/certificate-authorities/delete", + "api-reference/endpoints/certificate-authorities/csr", + "api-reference/endpoints/certificate-authorities/cert", + "api-reference/endpoints/certificate-authorities/sign-intermediate", + "api-reference/endpoints/certificate-authorities/import-cert", + "api-reference/endpoints/certificate-authorities/issue-cert", + "api-reference/endpoints/certificate-authorities/crl" + ] + }, + { + "group": "Certificates", + "pages": [ + "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/revoke", + "api-reference/endpoints/certificates/delete", + "api-reference/endpoints/certificates/cert-body" + ] } ] }, diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx new file mode 100644 index 000000000..a8affa154 --- /dev/null +++ b/docs/sdks/languages/go.mdx @@ -0,0 +1,438 @@ +--- +title: "Infisical Go SDK" +sidebarTitle: "Go" +icon: "golang" +--- + + + +If you're working with Go Lang, the official [Infisical Go SDK](https://github.com/infisical/go-sdk) package is the easiest way to fetch and work with secrets for your application. + +- [Package](https://pkg.go.dev/github.com/infisical/go-sdk) +- [Github Repository](https://github.com/infisical/go-sdk) + +## Basic Usage + +```go +package main + +import ( + "fmt" + "os" + + infisical "github.com/infisical/go-sdk" +) + +func main() { + + client, err := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://app.infisical.com + }) + + if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) + } + + _, err = client.Auth().UniversalAuthLogin("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET") + + if err != nil { + fmt.Printf("Authentication failed: %v", err) + os.Exit(1) + } + + apiKeySecret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ + SecretKey: "API_KEY", + Environment: "dev", + ProjectID: "YOUR_PROJECT_ID", + SecretPath: "/", + }) + + if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) + } + + fmt.Printf("API Key Secret: %v", apiKeySecret) + +} +``` + +This example demonstrates how to use the Infisical Go SDK in a simple Go application. The application retrieves a secret named `API_KEY` from the `dev` environment of the `YOUR_PROJECT_ID` project. + + + We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + + +# Installation + +```console +$ go get github.com/infisical/go-sdk +``` +# Configuration + +Import the SDK and create a client instance. + +```go +client, err := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://api.infisical.com + }) + +if err != nil { + fmt.Printf("Error: %v", err) + os.Exit(1) +} +``` + +### ClientSettings methods + + + + + The URL of the Infisical API. Default is `https://api.infisical.com`. + + + + Optionally set the user agent that will be used for HTTP requests. _(Not recommended)_ + + + + + +### Authentication + +The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. + +#### Universal Auth + +**Using environment variables** + +Call `.Auth().UniversalAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```go +_, err := client.Auth().UniversalAuthLogin("CLIENT_ID", "CLIENT_SECRET") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### GCP ID Token Auth + + Please note that this authentication method will only work if you're running your application on Google Cloud Platform. + Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().GcpIdTokenAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err := client.Auth().GcpIdTokenAuthLogin("YOUR_MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### GCP IAM Auth + +**Using environment variables** + +Call `.Auth().GcpIamAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. + +**Using the SDK directly** +```go +_, err = client.Auth().GcpIamAuthLogin("MACHINE_IDENTITY_ID", "SERVICE_ACCOUNT_KEY_FILE_PATH") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### AWS IAM Auth + + Please note that this authentication method will only work if you're running your application on AWS. + Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().AwsIamAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err = client.Auth().AwsIamAuthLogin("MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + + +#### Azure Auth + + Please note that this authentication method will only work if you're running your application on Azure. + Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().AzureAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```go +_, err = client.Auth().AzureAuthLogin("MACHINE_IDENTITY_ID") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +#### Kubernetes Auth + + Please note that this authentication method will only work if you're running your application on Kubernetes. + Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. + + +**Using environment variables** + +Call `.Auth().KubernetesAuthLogin()` with empty arguments to use the following environment variables: + +- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. +- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + +**Using the SDK directly** +```go +// Service account token path will default to /var/run/secrets/kubernetes.io/serviceaccount/token if empty value is passed +_, err = client.Auth().KubernetesAuthLogin("MACHINE_IDENTITY_ID", "SERVICE_ACCOUNT_TOKEN_PATH") + +if err != nil { + fmt.Println(err) + os.Exit(1) +} +``` + +## Working with Secrets + +### client.Secrets().List(options) + +```go +secrets, err := client.Secrets().List(infisical.ListSecretsOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretPath: "/foo/bar", + AttachToProcessEnv: false, +}) +``` + +Retrieve all secrets within the Infisical project and environment that client is connected to + +#### Parameters + + + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + + The project ID where the secret lives in. + + + + The path from where secrets should be fetched from. + + + + Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. + + + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + + + Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. + + + + Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) + + + + + +### client.Secrets().Retrieve(options) + +```go +secret, err := client.Secrets().Retrieve(infisical.RetrieveSecretOptions{ + SecretKey: "API_KEY", + ProjectID: "PROJECT_ID", + Environment: "dev", +}) +``` + +Retrieve a secret from Infisical. + +By default, `Secrets().Retrieve()` fetches and returns a shared secret. + +#### Parameters + + + + + The key of the secret to retrieve. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be fetched from. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Create(options) + +```go +secret, err := client.Secrets().Create(infisical.CreateSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + + SecretKey: "NEW_SECRET_KEY", + SecretValue: "NEW_SECRET_VALUE", + SecretComment: "This is a new secret", +}) +``` + +Create a new secret in Infisical. + +#### Parameters + + + + + The key of the secret to create. + + + The value of the secret. + + + A comment for the secret. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be created. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Update(options) + +```go +secret, err := client.Secrets().Update(infisical.UpdateSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretKey: "NEW_SECRET_KEY", + NewSecretValue: "NEW_SECRET_VALUE", + NewSkipMultilineEncoding: false, +}) +``` + +Update an existing secret in Infisical. + +#### Parameters + + + + + The key of the secret to update. + + + The new value of the secret. + + + Whether or not to skip multiline encoding for the new secret value. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be updated. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + + +### client.Secrets().Delete(options) + +```go +secret, err := client.Secrets().Delete(infisical.DeleteSecretOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + SecretKey: "SECRET_KEY", +}) +``` + +Delete a secret in Infisical. + +#### Parameters + + + + + The key of the secret to update. + + + The project ID where the secret lives in. + + + The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. + + + The path from where secret should be deleted. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + + \ No newline at end of file diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 33e6c697c..b225a3ace 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -48,44 +48,44 @@ The platform utilizes Postgres to persist all of its data and Redis for caching Without email configuration, Infisical's core functions like sign-up/login and secret operations work, but this disables multi-factor authentication, email invites for projects, alerts for suspicious logins, and all other email-dependent features. - - Hostname to connect to for establishing SMTP connections - - -{" "} - - - Credential to connect to host (e.g. team@infisical.com) + + Hostname to connect to for establishing SMTP connections -{" "} - - - Credential to connect to host - - -{" "} - Port to connect to for establishing SMTP connections -{" "} - - - If true, use TLS when connecting to host. If false, TLS will be used if - STARTTLS is supported + + Credential to connect to host (e.g. team@infisical.com) -{" "} + + Credential to connect to host + Email address to be used for sending emails - - Name label to be used in From field (e.g. Team) - + + Name label to be used in From field (e.g. Team) + + + + If this is `true` and `SMTP_PORT` is not 465 then TLS is not used even if the + server supports STARTTLS extension. + + + + If this is `true` and `SMTP_PORT` is not 465 then Infisical tries to use + STARTTLS even if the server does not advertise support for it. If the + connection can not be encrypted then message is not sent. + + + + If this is `true`, Infisical will validate the server's SSL/TLS certificate and reject the connection if the certificate is invalid or not trusted. If set to `false`, the client will accept the server's certificate regardless of its validity, which can be useful in development or testing environments but is not recommended for production use. + @@ -105,7 +105,6 @@ SMTP_HOST=smtp.sendgrid.net SMTP_USERNAME=apikey SMTP_PASSWORD=SG.rqFsfjxYPiqE1lqZTgD_lz7x8IVLx # your SendGrid API Key from step above SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -128,7 +127,6 @@ SMTP_HOST=smtp.mailgun.org # obtained from credentials page SMTP_USERNAME=postmaster@example.mailgun.org # obtained from credentials page SMTP_PASSWORD=password # obtained from credentials page SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -159,7 +157,6 @@ SMTP_FROM_NAME=Infisical SMTP_USERNAME=xxx # your SMTP username SMTP_PASSWORD=xxx # your SMTP password SMTP_PORT=465 - SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -187,7 +184,6 @@ SMTP_HOST=smtp.socketlabs.com SMTP_USERNAME=username # obtained from your credentials SMTP_PASSWORD=password # obtained from your credentials SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -229,7 +225,6 @@ SMTP_HOST=smtp.resend.com SMTP_USERNAME=resend SMTP_PASSWORD=YOUR_API_KEY SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your email address being used to send out emails SMTP_FROM_NAME=Infisical ``` @@ -253,7 +248,6 @@ SMTP_HOST=smtp.gmail.com SMTP_USERNAME=hey@gmail.com # your email SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@gmail.com SMTP_FROM_NAME=Infisical ``` @@ -277,7 +271,6 @@ SMTP_HOST=smtp.office365.com SMTP_USERNAME=username@yourdomain.com # your username SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=username@yourdomain.com SMTP_FROM_NAME=Infisical ``` @@ -294,7 +287,6 @@ SMTP_HOST=smtp.zoho.com SMTP_USERNAME=username # your email SMTP_PASSWORD=password # your password SMTP_PORT=587 -SMTP_SECURE=true SMTP_FROM_ADDRESS=hey@example.com # your personal Zoho email or domain-based email linked to Zoho Mail SMTP_FROM_NAME=Infisical ``` @@ -320,7 +312,8 @@ To login into Infisical with OAuth providers such as Google, configure the assoc - When set, all visits to the Infisical login page will automatically redirect users of your Infisical instance to the SAML identity provider associated with the specified organization slug. +When set, all visits to the Infisical login page will automatically redirect users of your Infisical instance to the SAML identity provider associated with the specified organization slug. + diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 9090fc603..5251dc406 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -2,6 +2,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id ARG NEXT_INFISICAL_PLATFORM_VERSION=next-infisical-platform-version +ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:16-alpine AS deps # Install dependencies only when needed. Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. @@ -31,6 +32,8 @@ ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -57,7 +60,9 @@ ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG ARG NEXT_INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION=$NEXT_INFISICAL_PLATFORM_VERSION - +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY \ + BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY COPY --chown=nextjs:nodejs --chmod=555 scripts ./scripts COPY --from=builder /app/public ./public RUN chown nextjs:nodejs ./public/data diff --git a/frontend/next.config.js b/frontend/next.config.js index 9d894694f..5e48e70da 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -1,13 +1,12 @@ - const path = require("path"); const ContentSecurityPolicy = ` default-src 'self'; - script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com 'unsafe-inline' 'unsafe-eval'; - style-src 'self' https://rsms.me 'unsafe-inline'; + script-src 'self' https://app.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval'; + style-src 'self' https://rsms.me 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com; child-src https://api.stripe.com; - frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/; - connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:*; + frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com; + connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com; img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:; media-src https://js.intercomcdn.com; font-src 'self' https://fonts.intercomcdn.com/ https://maxcdn.bootstrapcdn.com https://rsms.me https://fonts.gstatic.com; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c33c9dc36..489df0ea1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,7 +4,6 @@ "requires": true, "packages": { "": { - "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -19,6 +18,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", @@ -3200,6 +3200,24 @@ "react": ">=16.3" } }, + "node_modules/@hcaptcha/loader": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@hcaptcha/loader/-/loader-1.2.4.tgz", + "integrity": "sha512-3MNrIy/nWBfyVVvMPBKdKrX7BeadgiimW0AL/a/8TohNtJqxoySKgTJEXOQvYwlHemQpUzFrIsK74ody7JiMYw==" + }, + "node_modules/@hcaptcha/react-hcaptcha": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.10.1.tgz", + "integrity": "sha512-P0en4gEZAecah7Pt3WIaJO2gFlaLZKkI0+Tfdg8fNqsDxqT9VytZWSkH4WAkiPRULK1QcGgUZK+J56MXYmPifw==", + "dependencies": { + "@babel/runtime": "^7.17.9", + "@hcaptcha/loader": "^1.2.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/@headlessui/react": { "version": "1.7.18", "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-1.7.18.tgz", diff --git a/frontend/package.json b/frontend/package.json index e01ef945e..a4acb5738 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.2", "@fortawesome/react-fontawesome": "^0.2.0", + "@hcaptcha/react-hcaptcha": "^1.10.1", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^2.9.10", "@octokit/rest": "^19.0.7", diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh index d9138bb77..644877d8f 100755 --- a/frontend/scripts/initialize-standalone-build.sh +++ b/frontend/scripts/initialize-standalone-build.sh @@ -4,6 +4,8 @@ scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_POSTHOG_API_KEY scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTERCOM_ID" +scripts/replace-standalone-build-variable.sh "$BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY" "$NEXT_PUBLIC_CAPTCHA_SITE_KEY" + if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-standalone-build-telemetry.sh true diff --git a/frontend/scripts/start.sh b/frontend/scripts/start.sh index 1488ad328..7dda6c95b 100644 --- a/frontend/scripts/start.sh +++ b/frontend/scripts/start.sh @@ -6,6 +6,8 @@ scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_INTERCOM_ID" "$NEXT_PUBLIC_INTER scripts/replace-variable.sh "$BAKED_NEXT_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" +scripts/replace-variable.sh "$BAKED_NEXT_PUBLIC_CAPTCHA_SITE_KEY" "$NEXT_PUBLIC_CAPTCHA_SITE_KEY" + if [ "$TELEMETRY_ENABLED" != "false" ]; then echo "Telemetry is enabled" scripts/set-telemetry.sh true diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index e95f5bf88..8f7c4bb9f 100644 --- a/frontend/src/components/utilities/attemptCliLogin.ts +++ b/frontend/src/components/utilities/attemptCliLogin.ts @@ -30,11 +30,13 @@ export interface IsCliLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); return new Promise((resolve, reject) => { @@ -70,7 +72,8 @@ const attemptLogin = async ({ } = await login2({ email, clientProof, - providerAuthToken + providerAuthToken, + captchaToken }); if (mfaEnabled) { // case: MFA is enabled diff --git a/frontend/src/components/utilities/attemptLogin.ts b/frontend/src/components/utilities/attemptLogin.ts index 195cf9b9a..b909b1ba7 100644 --- a/frontend/src/components/utilities/attemptLogin.ts +++ b/frontend/src/components/utilities/attemptLogin.ts @@ -22,11 +22,13 @@ interface IsLoginSuccessful { const attemptLogin = async ({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }: { email: string; password: string; providerAuthToken?: string; + captchaToken?: string; }): Promise => { const telemetry = new Telemetry().getInstance(); // eslint-disable-next-line new-cap @@ -58,6 +60,7 @@ const attemptLogin = async ({ iv, tag } = await login2({ + captchaToken, email, clientProof, providerAuthToken diff --git a/frontend/src/components/utilities/config/index.ts b/frontend/src/components/utilities/config/index.ts index 10d4856c0..9b3bf37f1 100644 --- a/frontend/src/components/utilities/config/index.ts +++ b/frontend/src/components/utilities/config/index.ts @@ -2,5 +2,6 @@ const ENV = process.env.NEXT_PUBLIC_ENV! || "development"; // investigate const POSTHOG_API_KEY = process.env.NEXT_PUBLIC_POSTHOG_API_KEY!; const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST! || "https://app.posthog.com"; const INTERCOMid = process.env.NEXT_PUBLIC_INTERCOMid!; +const CAPTCHA_SITE_KEY = process.env.NEXT_PUBLIC_CAPTCHA_SITE_KEY!; -export { ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; +export { CAPTCHA_SITE_KEY, ENV, INTERCOMid, POSTHOG_API_KEY, POSTHOG_HOST }; diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx index 9dfb5ff62..b453456d7 100644 --- a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -78,7 +78,8 @@ export const SecretPathInput = ({ const validPaths = inputValue.split("/"); validPaths.pop(); - const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}/`; + // removed trailing slash + const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; onChange?.(newValue); setInputValue(newValue); setSecretPath(newValue); diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 79c8f2d30..113aaff19 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,7 +24,9 @@ export enum ProjectPermissionSub { SecretRollback = "secret-rollback", SecretApproval = "secret-approval", SecretRotation = "secret-rotation", - Identity = "identity" + Identity = "identity", + CertificateAuthorities = "certificate-authorities", + Certificates = "certificates" } type SubjectFields = { @@ -51,6 +53,8 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] + | [ProjectPermissionActions, ProjectPermissionSub.Certificates] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Workspace] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index ff8e700a4..6c4e91ffc 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -93,27 +93,29 @@ const initProjectHelper = async ({ projectName }: { projectName: string }) => { }); try { - secrets?.forEach((secret) => { - createSecret({ - workspaceId: project.id, - environment: secret.environment, - type: secret.type, - secretKey: secret.secretName, - secretKeyCiphertext: secret.secretKeyCiphertext, - secretKeyIV: secret.secretKeyIV, - secretKeyTag: secret.secretKeyTag, - secretValueCiphertext: secret.secretValueCiphertext, - secretValueIV: secret.secretValueIV, - secretValueTag: secret.secretValueTag, - secretCommentCiphertext: secret.secretCommentCiphertext, - secretCommentIV: secret.secretCommentIV, - secretCommentTag: secret.secretCommentTag, - secretPath: "/", - metadata: { - source: "signup" - } - }); - }); + await Promise.allSettled( + (secrets || []).map((secret) => + createSecret({ + workspaceId: project.id, + environment: secret.environment, + type: secret.type, + secretKey: secret.secretName, + secretKeyCiphertext: secret.secretKeyCiphertext, + secretKeyIV: secret.secretKeyIV, + secretKeyTag: secret.secretKeyTag, + secretValueCiphertext: secret.secretValueCiphertext, + secretValueIV: secret.secretValueIV, + secretValueTag: secret.secretValueTag, + secretCommentCiphertext: secret.secretCommentCiphertext, + secretCommentIV: secret.secretCommentIV, + secretCommentTag: secret.secretCommentTag, + secretPath: "/", + metadata: { + source: "signup" + } + }) + ) + ); } catch (err) { console.error("Failed to upload secrets", err); } diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 348700374..082bff02c 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -26,7 +26,6 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", - [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", @@ -43,7 +42,21 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.UPDATE_SECRET_IMPORT]: "Update secret import", [EventType.DELETE_SECRET_IMPORT]: "Delete secret import", [EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS]: "Update denied permissions", - [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role" + [EventType.UPDATE_USER_WORKSPACE_ROLE]: "Update user role", + [EventType.CREATE_CA]: "Create CA", + [EventType.GET_CA]: "Get CA", + [EventType.UPDATE_CA]: "Update CA", + [EventType.DELETE_CA]: "Delete CA", + [EventType.GET_CA_CSR]: "Get CA CSR", + [EventType.GET_CA_CERT]: "Get CA certificate", + [EventType.SIGN_INTERMEDIATE]: "Sign intermediate", + [EventType.IMPORT_CA_CERT]: "Import CA certificate", + [EventType.GET_CA_CRL]: "Get CA CRL", + [EventType.ISSUE_CERT]: "Issue certificate", + [EventType.GET_CERT]: "Get certificate", + [EventType.DELETE_CERT]: "Delete certificate", + [EventType.REVOKE_CERT]: "Revoke certificate", + [EventType.GET_CERT_BODY]: "Get certificate body" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index f6ccd8f81..ad49998c5 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -56,5 +56,19 @@ export enum EventType { UPDATE_SECRET_IMPORT = "update-secret-import", DELETE_SECRET_IMPORT = "delete-secret-import", UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role", - UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions" + UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions", + CREATE_CA = "create-certificate-authority", + GET_CA = "get-certificate-authority", + UPDATE_CA = "update-certificate-authority", + DELETE_CA = "delete-certificate-authority", + GET_CA_CSR = "get-certificate-authority-csr", + GET_CA_CERT = "get-certificate-authority-cert", + SIGN_INTERMEDIATE = "sign-intermediate", + IMPORT_CA_CERT = "import-certificate-authority-cert", + GET_CA_CRL = "get-certificate-authority-crl", + ISSUE_CERT = "issue-cert", + GET_CERT = "get-cert", + DELETE_CERT = "delete-cert", + REVOKE_CERT = "revoke-cert", + GET_CERT_BODY = "get-cert-body" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 92611c814..3b607e596 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -1,3 +1,4 @@ +import { CaStatus } from "../ca"; import { IdentityTrustedIp } from "../identities/types"; import { ActorType, EventType, UserAgentType } from "./enums"; @@ -462,6 +463,125 @@ interface UpdateUserDeniedPermissions { }; } +interface CreateCa { + type: EventType.CREATE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCa { + type: EventType.GET_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface UpdateCa { + type: EventType.UPDATE_CA; + metadata: { + caId: string; + dn: string; + status: CaStatus; + }; +} + +interface DeleteCa { + type: EventType.DELETE_CA; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCsr { + type: EventType.GET_CA_CSR; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCert { + type: EventType.GET_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface SignIntermediate { + type: EventType.SIGN_INTERMEDIATE; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface ImportCaCert { + type: EventType.IMPORT_CA_CERT; + metadata: { + caId: string; + dn: string; + }; +} + +interface GetCaCrl { + type: EventType.GET_CA_CRL; + metadata: { + caId: string; + dn: string; + }; +} + +interface IssueCert { + type: EventType.ISSUE_CERT; + metadata: { + caId: string; + dn: string; + serialNumber: string; + }; +} + +interface GetCert { + type: EventType.GET_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface DeleteCert { + type: EventType.DELETE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface RevokeCert { + type: EventType.REVOKE_CERT; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + +interface GetCertBody { + type: EventType.GET_CERT_BODY; + metadata: { + certId: string; + cn: string; + serialNumber: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -504,7 +624,21 @@ export type Event = | UpdateSecretImportEvent | DeleteSecretImportEvent | UpdateUserRole - | UpdateUserDeniedPermissions; + | UpdateUserDeniedPermissions + | CreateCa + | GetCa + | UpdateCa + | DeleteCa + | GetCaCsr + | GetCaCert + | SignIntermediate + | ImportCaCert + | GetCaCrl + | IssueCert + | GetCert + | DeleteCert + | RevokeCert + | GetCertBody; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 41c324bff..ce1b18bc8 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -30,6 +30,7 @@ export type Login1DTO = { }; export type Login2DTO = { + captchaToken?: string; email: string; clientProof: string; providerAuthToken?: string; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx new file mode 100644 index 000000000..dbef15ffd --- /dev/null +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -0,0 +1,12 @@ +import { CaStatus,CaType } from "./enums"; + +export const caTypeToNameMap: { [K in CaType]: string } = { + [CaType.ROOT]: "Root", + [CaType.INTERMEDIATE]: "Intermediate" +}; + +export const caStatusToNameMap: { [K in CaStatus]: string } = { + [CaStatus.ACTIVE]: "Active", + [CaStatus.DISABLED]: "Disabled", + [CaStatus.PENDING_CERTIFICATE]: "Pending Certificate" +}; diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx new file mode 100644 index 000000000..bdd498a12 --- /dev/null +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -0,0 +1,10 @@ +export enum CaType { + ROOT = "root", + INTERMEDIATE = "intermediate" +} + +export enum CaStatus { + ACTIVE = "active", + DISABLED = "disabled", + PENDING_CERTIFICATE = "pending-certificate" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx new file mode 100644 index 000000000..60b53478d --- /dev/null +++ b/frontend/src/hooks/api/ca/index.tsx @@ -0,0 +1,10 @@ +export { CaStatus, CaType } from "./enums"; +export { + useCreateCa, + useCreateCertificate, + useDeleteCa, + useImportCaCertificate, + useSignIntermediate, + useUpdateCa +} from "./mutations"; +export { useGetCaById, useGetCaCert, useGetCaCrl,useGetCaCsr } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx new file mode 100644 index 000000000..bb018f71a --- /dev/null +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -0,0 +1,108 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { + TCertificateAuthority, + TCreateCaDTO, + TCreateCertificateDTO, + TCreateCertificateResponse, + TDeleteCaDTO, + TImportCaCertificateDTO, + TImportCaCertificateResponse, + TSignIntermediateDTO, + TSignIntermediateResponse, + TUpdateCaDTO +} from "./types"; + +export const useCreateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { + data: { ca } + } = await apiRequest.post<{ ca: TCertificateAuthority }>("/api/v1/pki/ca/", body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useUpdateCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, projectSlug, ...body }) => { + const { + data: { ca } + } = await apiRequest.patch<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`, body); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useDeleteCa = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId }) => { + const { + data: { ca } + } = await apiRequest.delete<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +export const useSignIntermediate = () => { + // TODO: consider renaming + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${body.caId}/sign-intermediate`, + body + ); + return data; + } + }); +}; + +export const useImportCaCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/import-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceCas({ projectSlug })); + } + }); +}; + +// consider rename to issue certificate +export const useCreateCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ caId, ...body }) => { + const { data } = await apiRequest.post( + `/api/v1/pki/ca/${caId}/issue-certificate`, + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx new file mode 100644 index 000000000..e78274391 --- /dev/null +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -0,0 +1,70 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificateAuthority } from "./types"; + +export const caKeys = { + getCaById: (caId: string) => [{ caId }, "ca"], + getCaCert: (caId: string) => [{ caId }, "ca-cert"], + getCaCsr: (caId: string) => [{ caId }, "ca-csr"], + getCaCrl: (caId: string) => [{ caId }, "ca-crl"] +}; + +export const useGetCaById = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaById(caId), + queryFn: async () => { + const { + data: { ca } + } = await apiRequest.get<{ ca: TCertificateAuthority }>(`/api/v1/pki/ca/${caId}`); + return ca; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCert = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCert(caId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/ca/${caId}/certificate`); + return data; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCsr = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCsr(caId), + queryFn: async () => { + const { + data: { csr } + } = await apiRequest.get<{ + csr: string; + }>(`/api/v1/pki/ca/${caId}/csr`); + return csr; + }, + enabled: Boolean(caId) + }); +}; + +export const useGetCaCrl = (caId: string) => { + return useQuery({ + queryKey: caKeys.getCaCrl(caId), + queryFn: async () => { + const { + data: { crl } + } = await apiRequest.get<{ + crl: string; + }>(`/api/v1/pki/ca/${caId}/crl`); + return crl; + }, + enabled: Boolean(caId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts new file mode 100644 index 000000000..64511253b --- /dev/null +++ b/frontend/src/hooks/api/ca/types.ts @@ -0,0 +1,95 @@ +import { CertKeyAlgorithm } from "../certificates/enums"; +import { CaStatus, CaType } from "./enums"; + +export type TCertificateAuthority = { + id: string; + parentCaId?: string; + projectId: string; + type: CaType; + status: CaStatus; + friendlyName: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + dn: string; + maxPathLength?: number; + notAfter?: string; + notBefore?: string; + keyAlgorithm: CertKeyAlgorithm; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCaDTO = { + projectSlug: string; + type: string; + friendlyName?: string; + organization: string; + ou: string; + country: string; + province: string; + locality: string; + commonName: string; + notAfter?: string; + maxPathLength: number; + keyAlgorithm: CertKeyAlgorithm; +}; + +export type TUpdateCaDTO = { + projectSlug: string; + caId: string; + status?: CaStatus; +}; + +export type TDeleteCaDTO = { + projectSlug: string; + caId: string; +}; + +export type TSignIntermediateDTO = { + caId: string; + csr: string; + maxPathLength: number; + notBefore?: string; + notAfter?: string; +}; + +export type TSignIntermediateResponse = { + certificate: string; + certificateChain: string; + issuingCaCertificate: string; + serialNumber: string; +}; + +export type TImportCaCertificateDTO = { + caId: string; + projectSlug: string; + certificate: string; + certificateChain: string; +}; + +export type TImportCaCertificateResponse = { + message: string; + caId: string; +}; + +export type TCreateCertificateDTO = { + projectSlug: string; + caId: string; + friendlyName?: string; + commonName: string; + ttl: string; // string compatible with ms + notBefore?: string; + notAfter?: string; +}; + +export type TCreateCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx new file mode 100644 index 000000000..e5a9b7a43 --- /dev/null +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -0,0 +1,60 @@ +import { CertKeyAlgorithm, CertStatus,CrlReason } from "./enums"; + +export const certStatusToNameMap: { [K in CertStatus]: string } = { + [CertStatus.ACTIVE]: "Active", + [CertStatus.REVOKED]: "Revoked" +}; + +export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { + [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_4096]: "RSA 4096", + [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", + [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" +}; + +export const certKeyAlgorithms = [ + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], + value: CertKeyAlgorithm.ECDSA_P256 + }, + { + label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P384], + value: CertKeyAlgorithm.ECDSA_P384 + } +]; + +export const crlReasonToNameMap: { [K in CrlReason]: string } = { + [CrlReason.UNSPECIFIED]: "Unspecified", + [CrlReason.KEY_COMPROMISE]: "Key Compromise", + [CrlReason.CA_COMPROMISE]: "CA Compromise", + [CrlReason.AFFILIATION_CHANGED]: "Affiliation Changed", + [CrlReason.SUPERSEDED]: "Superseded", + [CrlReason.CESSATION_OF_OPERATION]: "Cessation of Operation", + [CrlReason.CERTIFICATE_HOLD]: "Certificate Hold", + // [CrlReason.REMOVE_FROM_CRL]: "Remove from CRL", + [CrlReason.PRIVILEGE_WITHDRAWN]: "Privilege Withdrawn", + [CrlReason.A_A_COMPROMISE]: "A/A Compromise" +}; + +export const crlReasons = [ + { label: crlReasonToNameMap[CrlReason.UNSPECIFIED], value: CrlReason.UNSPECIFIED }, + { label: crlReasonToNameMap[CrlReason.KEY_COMPROMISE], value: CrlReason.KEY_COMPROMISE }, + { label: crlReasonToNameMap[CrlReason.CA_COMPROMISE], value: CrlReason.CA_COMPROMISE }, + { + label: crlReasonToNameMap[CrlReason.AFFILIATION_CHANGED], + value: CrlReason.AFFILIATION_CHANGED + }, + { label: crlReasonToNameMap[CrlReason.SUPERSEDED], value: CrlReason.SUPERSEDED }, + { + label: crlReasonToNameMap[CrlReason.CESSATION_OF_OPERATION], + value: CrlReason.CESSATION_OF_OPERATION + }, + { label: crlReasonToNameMap[CrlReason.CERTIFICATE_HOLD], value: CrlReason.CERTIFICATE_HOLD }, + { + label: crlReasonToNameMap[CrlReason.PRIVILEGE_WITHDRAWN], + value: CrlReason.PRIVILEGE_WITHDRAWN + }, + { label: crlReasonToNameMap[CrlReason.A_A_COMPROMISE], value: CrlReason.A_A_COMPROMISE } +]; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx new file mode 100644 index 000000000..d0da0273a --- /dev/null +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -0,0 +1,24 @@ +export enum CertStatus { + ACTIVE = "active", + REVOKED = "revoked" +} + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CrlReason { + UNSPECIFIED = "UNSPECIFIED", + KEY_COMPROMISE = "KEY_COMPROMISE", + CA_COMPROMISE = "CA_COMPROMISE", + AFFILIATION_CHANGED = "AFFILIATION_CHANGED", + SUPERSEDED = "SUPERSEDED", + CESSATION_OF_OPERATION = "CESSATION_OF_OPERATION", + CERTIFICATE_HOLD = "CERTIFICATE_HOLD", + // REMOVE_FROM_CRL = "REMOVE_FROM_CRL", + PRIVILEGE_WITHDRAWN = "PRIVILEGE_WITHDRAWN", + A_A_COMPROMISE = "A_A_COMPROMISE" +} diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx new file mode 100644 index 000000000..dd922fd6a --- /dev/null +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -0,0 +1,2 @@ +export { useDeleteCert, useRevokeCert } 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 new file mode 100644 index 000000000..4c0f92339 --- /dev/null +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; + +export const useDeleteCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber }) => { + const { + data: { certificate } + } = await apiRequest.delete<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; + +export const useRevokeCert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ serialNumber, revocationReason }) => { + const { + data: { certificate } + } = await apiRequest.post<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}/revoke`, + { + revocationReason + } + ); + return certificate; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(workspaceKeys.forWorkspaceCertificates(projectSlug)); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/queries.tsx b/frontend/src/hooks/api/certificates/queries.tsx new file mode 100644 index 000000000..50c751c06 --- /dev/null +++ b/frontend/src/hooks/api/certificates/queries.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCertificate } from "./types"; + +export const certKeys = { + getCertById: (serialNumber: string) => [{ serialNumber }, "cert"], + getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"] +}; + +export const useGetCert = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertById(serialNumber), + queryFn: async () => { + const { + data: { certificate } + } = await apiRequest.get<{ certificate: TCertificate }>( + `/api/v1/pki/certificates/${serialNumber}` + ); + return certificate; + }, + enabled: Boolean(serialNumber) + }); +}; + +export const useGetCertBody = (serialNumber: string) => { + return useQuery({ + queryKey: certKeys.getCertBody(serialNumber), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificate: string; + certificateChain: string; + serialNumber: string; + }>(`/api/v1/pki/certificates/${serialNumber}/certificate`); + return data; + }, + enabled: Boolean(serialNumber) + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts new file mode 100644 index 000000000..2dd600187 --- /dev/null +++ b/frontend/src/hooks/api/certificates/types.ts @@ -0,0 +1,23 @@ +import { CertStatus } from "./enums"; + +export type TCertificate = { + id: string; + caId: string; + status: CertStatus; + friendlyName: string; + commonName: string; + serialNumber: string; + notBefore: string; + notAfter: string; +}; + +export type TDeleteCertDTO = { + projectSlug: string; + serialNumber: string; +}; + +export type TRevokeCertDTO = { + projectSlug: string; + serialNumber: string; + revocationReason: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 61e8cb666..b87c3f13f 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -5,6 +5,8 @@ export * from "./auditLogs"; export * from "./auditLogStreams"; export * from "./auth"; export * from "./bots"; +export * from "./ca"; +export * from "./certificates"; export * from "./dynamicSecret"; export * from "./dynamicSecretLease"; export * from "./groups"; @@ -17,6 +19,7 @@ export * from "./keys"; export * from "./ldapConfig"; export * from "./organization"; export * from "./projectUserAdditionalPrivilege"; +export * from "./rateLimit"; export * from "./roles"; export * from "./scim"; export * from "./secretApproval"; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 3aa8f3ed1..81d0f00ca 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -73,6 +73,9 @@ export const useCreateIntegration = () => { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; }) => { const { diff --git a/frontend/src/hooks/api/rateLimit/index.ts b/frontend/src/hooks/api/rateLimit/index.ts new file mode 100644 index 000000000..f3f81b1c9 --- /dev/null +++ b/frontend/src/hooks/api/rateLimit/index.ts @@ -0,0 +1,2 @@ +export { useUpdateRateLimit } from "./mutation"; +export { useGetRateLimit } from "./queries"; diff --git a/frontend/src/hooks/api/rateLimit/mutation.ts b/frontend/src/hooks/api/rateLimit/mutation.ts new file mode 100644 index 000000000..22a7f9898 --- /dev/null +++ b/frontend/src/hooks/api/rateLimit/mutation.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { rateLimitQueryKeys } from "./queries"; +import { TRateLimit } from "./types"; + +export const useUpdateRateLimit = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (opt) => { + const { data } = await apiRequest.put<{ rateLimit: TRateLimit }>("/api/v1/rate-limit", opt); + return data.rateLimit; + }, + onSuccess: (data) => { + queryClient.setQueryData(rateLimitQueryKeys.rateLimit(), data); + queryClient.invalidateQueries(rateLimitQueryKeys.rateLimit()); + } + }); +}; diff --git a/frontend/src/hooks/api/rateLimit/queries.ts b/frontend/src/hooks/api/rateLimit/queries.ts new file mode 100644 index 000000000..5a52ff74b --- /dev/null +++ b/frontend/src/hooks/api/rateLimit/queries.ts @@ -0,0 +1,34 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TRateLimit } from "./types"; + +export const rateLimitQueryKeys = { + rateLimit: () => ["rate-limit"] as const +}; + +const fetchRateLimit = async () => { + const { data } = await apiRequest.get<{ rateLimit: TRateLimit }>("/api/v1/rate-limit"); + return data.rateLimit; +}; + +export const useGetRateLimit = ({ + options = {} +}: { + options?: Omit< + UseQueryOptions< + TRateLimit, + unknown, + TRateLimit, + ReturnType + >, + "queryKey" | "queryFn" + >; +} = {}) => + useQuery({ + queryKey: rateLimitQueryKeys.rateLimit(), + queryFn: fetchRateLimit, + ...options, + enabled: options?.enabled ?? true + }); diff --git a/frontend/src/hooks/api/rateLimit/types.ts b/frontend/src/hooks/api/rateLimit/types.ts new file mode 100644 index 000000000..5697fc298 --- /dev/null +++ b/frontend/src/hooks/api/rateLimit/types.ts @@ -0,0 +1,10 @@ +export type TRateLimit = { + readRateLimit: number; + writeRateLimit: number; + secretsRateLimit: number; + authRateLimit: number; + inviteUserRateLimit: number; + mfaRateLimit: number; + creationLimit: number; + publicEndpointLimit: number; +}; diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx index 2879c859b..701a20543 100644 --- a/frontend/src/hooks/api/secretImports/queries.tsx +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -264,13 +264,12 @@ export const useGetImportedSecretsAllEnvs = ({ }); const isImportedSecretPresentInEnv = useCallback( - (secPath: string, envSlug: string, secretName: string) => { + (envSlug: string, secretName: string) => { const selectedEnvIndex = environments.indexOf(envSlug); if (selectedEnvIndex !== -1) { - const isPresent = secretImports?.[selectedEnvIndex]?.data?.find( - ({ secretPath, secrets }) => - secretPath === secPath && secrets.some((s) => s.key === secretName) + const isPresent = secretImports?.[selectedEnvIndex]?.data?.find(({ secrets }) => + secrets.some((s) => s.key === secretName) ); return Boolean(isPresent); diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 45414292d..66959ad1b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -25,14 +25,15 @@ export type SubscriptionPlan = { ldap: boolean; groups: boolean; status: - | "incomplete" - | "incomplete_expired" - | "trialing" - | "active" - | "past_due" - | "canceled" - | "unpaid" - | null; + | "incomplete" + | "incomplete_expired" + | "trialing" + | "active" + | "past_due" + | "canceled" + | "unpaid" + | null; trial_end: number | null; has_used_trial: boolean; + caCrl: boolean; }; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b0cadac23..1daec0fd0 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -21,6 +21,8 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceCas, + useListWorkspaceCertificates, useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, @@ -28,5 +30,4 @@ export { useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment, - useUpgradeProject -} from "./queries"; + useUpgradeProject} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 71dbb8e01..7f94bb498 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { CaStatus } from "../ca/enums"; +import { TCertificateAuthority } from "../ca/types"; +import { TCertificate } from "../certificates/types"; import { TGroupMembership } from "../groups/types"; import { IdentityMembership } from "../identities/types"; import { IntegrationAuth } from "../integrationAuth/types"; @@ -20,6 +23,7 @@ import { TUpdateWorkspaceIdentityRoleDTO, TUpdateWorkspaceUserRoleDTO, UpdateEnvironmentDTO, + UpdatePitVersionLimitDTO, Workspace } from "./types"; @@ -39,7 +43,23 @@ export const workspaceKeys = { getWorkspaceIdentityMemberships: (workspaceId: string) => [{ workspaceId }, "workspace-identity-memberships"] as const, getWorkspaceGroupMemberships: (workspaceId: string) => - [{ workspaceId }, "workspace-groups"] as const + [{ workspaceId }, "workspace-groups"] as const, + getWorkspaceCas: ({ projectSlug }: { projectSlug: string }) => + [{ projectSlug }, "workspace-cas"] as const, + specificWorkspaceCas: ({ projectSlug, status }: { projectSlug: string; status?: CaStatus }) => + [...workspaceKeys.getWorkspaceCas({ projectSlug }), { status }] as const, + allWorkspaceCertificates: () => ["workspace-certificates"] as const, + forWorkspaceCertificates: (slug: string) => + [...workspaceKeys.allWorkspaceCertificates(), slug] as const, + specificWorkspaceCertificates: ({ + slug, + offset, + limit + }: { + slug: string; + offset: number; + limit: number; + }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -249,6 +269,21 @@ export const useToggleAutoCapitalization = () => { }); }; +export const useUpdateWorkspaceVersionLimit = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, UpdatePitVersionLimitDTO>({ + mutationFn: ({ projectSlug, pitVersionLimit }) => { + return apiRequest.put(`/api/v1/workspace/${projectSlug}/version-limit`, { + pitVersionLimit + }); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; + export const useDeleteWorkspace = () => { const queryClient = useQueryClient(); @@ -469,3 +504,70 @@ export const useListWorkspaceGroups = (projectSlug: string) => { enabled: true }); }; + +export const useListWorkspaceCas = ({ + projectSlug, + status +}: { + projectSlug: string; + status?: CaStatus; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCas({ + projectSlug, + status + }), + queryFn: async () => { + const params = new URLSearchParams({ + ...(status && { status }) + }); + + const { + data: { cas } + } = await apiRequest.get<{ cas: TCertificateAuthority[] }>( + `/api/v2/workspace/${projectSlug}/cas`, + { + params + } + ); + return cas; + }, + enabled: Boolean(projectSlug) + }); +}; + +export const useListWorkspaceCertificates = ({ + projectSlug, + offset, + limit +}: { + projectSlug: string; + offset: number; + limit: number; +}) => { + return useQuery({ + queryKey: workspaceKeys.specificWorkspaceCertificates({ + slug: projectSlug, + offset, + limit + }), + queryFn: async () => { + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit) + }); + + const { + data: { certificates, totalCount } + } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( + `/api/v2/workspace/${projectSlug}/certificates`, + { + params + } + ); + + return { certificates, totalCount }; + }, + enabled: Boolean(projectSlug) + }); +}; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 8be9beed0..5a90cf2b1 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -14,8 +14,10 @@ export type Workspace = { orgId: string; version: ProjectVersion; upgradeStatus: string | null; + updatedAt: string; autoCapitalization: boolean; environments: WorkspaceEnv[]; + pitVersionLimit: number; slug: string; }; @@ -48,6 +50,7 @@ export type CreateWorkspaceDTO = { }; export type RenameWorkspaceDTO = { workspaceID: string; newWorkspaceName: string }; +export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; export type ToggleAutoCapitalizationDTO = { workspaceID: string; state: boolean }; export type DeleteWorkspaceDTO = { workspaceID: string }; @@ -128,4 +131,4 @@ export type TUpdateWorkspaceGroupRoleDTO = { temporaryAccessStartTime: string; } )[]; -}; \ No newline at end of file +}; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 1b5df0037..72351df88 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -519,6 +519,18 @@ export const AppLayout = ({ children }: LayoutProps) => { + + + + Internal PKI + + + { Secret Sharing diff --git a/frontend/src/lib/fn/date.ts b/frontend/src/lib/fn/date.ts new file mode 100644 index 000000000..66423b3cb --- /dev/null +++ b/frontend/src/lib/fn/date.ts @@ -0,0 +1,46 @@ +export const timeAgo = (inputDate: Date, currentDate: Date): string => { + const now = new Date(currentDate).getTime(); + const date = new Date(inputDate).getTime(); + const elapsedMilliseconds = now - date; + const elapsedSeconds = Math.abs(Math.floor(elapsedMilliseconds / 1000)); + const elapsedMinutes = Math.abs(Math.floor(elapsedSeconds / 60)); + const elapsedHours = Math.abs(Math.floor(elapsedMinutes / 60)); + const elapsedDays = Math.abs(Math.floor(elapsedHours / 24)); + const elapsedWeeks = Math.abs(Math.floor(elapsedDays / 7)); + const elapsedMonths = Math.abs(Math.floor(elapsedDays / 30)); + const elapsedYears = Math.abs(Math.floor(elapsedDays / 365)); + + if (elapsedYears > 0) { + return `${elapsedYears} year${elapsedYears === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedMonths > 0) { + return `${elapsedMonths} month${elapsedMonths === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedWeeks > 0) { + return `${elapsedWeeks} week${elapsedWeeks === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedDays > 0) { + return `${elapsedDays} day${elapsedDays === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedHours > 0) { + return `${elapsedHours} hour${elapsedHours === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + if (elapsedMinutes > 0) { + return `${elapsedMinutes} minute${elapsedMinutes === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; + } + return `${elapsedSeconds} second${elapsedSeconds === 1 ? "" : "s"} ${ + elapsedMilliseconds >= 0 ? "ago" : "from now" + }`; +}; diff --git a/frontend/src/pages/integrations/github/create.tsx b/frontend/src/pages/integrations/github/create.tsx index e42a7e9eb..f92a98943 100644 --- a/frontend/src/pages/integrations/github/create.tsx +++ b/frontend/src/pages/integrations/github/create.tsx @@ -33,6 +33,7 @@ import { Input, Select, SelectItem, + Switch, Tab, TabList, TabPanel, @@ -59,7 +60,7 @@ const schema = yup.object({ selectedSourceEnvironment: yup.string().trim().required("Project Environment is required"), secretPath: yup.string().trim().required("Secrets Path is required"), secretSuffix: yup.string().trim().optional(), - + shouldEnableDelete: yup.boolean().optional(), scope: yup.mixed().oneOf(targetEnv.slice()).required(), repoIds: yup.mixed().when("scope", { @@ -98,7 +99,6 @@ type FormData = yup.InferType; export default function GitHubCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); - const integrationAuthId = (queryString.parse(router.asPath.split("?")[1]).integrationAuthId as string) ?? ""; @@ -120,7 +120,8 @@ export default function GitHubCreateIntegrationPage() { defaultValues: { secretPath: "/", scope: "github-repo", - repoIds: [] + repoIds: [], + shouldEnableDelete: false } }); @@ -177,7 +178,8 @@ export default function GitHubCreateIntegrationPage() { app: targetApp.name, // repo name owner: targetApp.owner, // repo owner metadata: { - secretSuffix: data.secretSuffix + secretSuffix: data.secretSuffix, + shouldEnableDelete: data.shouldEnableDelete } }); }) @@ -194,7 +196,8 @@ export default function GitHubCreateIntegrationPage() { scope: data.scope, owner: integrationAuthOrgs?.find((e) => e.orgId === data.orgId)?.name, metadata: { - secretSuffix: data.secretSuffix + secretSuffix: data.secretSuffix, + shouldEnableDelete: data.shouldEnableDelete } }); break; @@ -211,7 +214,8 @@ export default function GitHubCreateIntegrationPage() { owner: repoOwner, targetEnvironmentId: data.envId, metadata: { - secretSuffix: data.secretSuffix + secretSuffix: data.secretSuffix, + shouldEnableDelete: data.shouldEnableDelete } }); break; @@ -546,6 +550,21 @@ export default function GitHubCreateIntegrationPage() { animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: 30 }} > +
+ ( + onChange(isChecked)} + isChecked={value} + > + Delete secrets in Github that are not in Infisical + + )} + /> +
; @@ -138,7 +141,9 @@ export default function GitLabCreateIntegrationPage() { targetAppId, targetEnvironment, secretPrefix, - secretSuffix + secretSuffix, + shouldMaskSecrets, + shouldProtectSecrets }: FormData) => { try { setIsLoading(true); @@ -156,7 +161,9 @@ export default function GitLabCreateIntegrationPage() { secretPath, metadata: { secretPrefix, - secretSuffix + secretSuffix, + shouldMaskSecrets, + shouldProtectSecrets } }); @@ -390,6 +397,36 @@ export default function GitLabCreateIntegrationPage() { exit={{ opacity: 0, translateX: 30 }} className="pb-[14.25rem]" > +
+ ( + onChange(isChecked)} + isChecked={value} + > +
Mark Infisical secrets in Gitlab as 'Masked' secrets
+
+ )} + /> +
+
+ ( + onChange(isChecked)} + isChecked={value} + > + Mark Infisical secrets in Gitlab as 'Protected' secrets + + )} + /> +
void) { // Get the text field const copyText = document.getElementById(id) as HTMLInputElement; @@ -309,8 +318,9 @@ const LearningItem = ({ href={link} >
null} @@ -321,10 +331,11 @@ const LearningItem = ({ await registerUserAction.mutateAsync(userAction); } }} - className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${complete + className={`group relative flex h-[5.5rem] w-full items-center justify-between overflow-hidden rounded-md border ${ + complete ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} + } text-mineshaft-100 duration-200`} >
@@ -402,8 +413,9 @@ const LearningItemSquare = ({ href={link} >
null} @@ -414,10 +426,11 @@ const LearningItemSquare = ({ await registerUserAction.mutateAsync(userAction); } }} - className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${complete + className={`group relative flex w-full items-center justify-between overflow-hidden rounded-md border ${ + complete ? "cursor-default border-mineshaft-900 bg-gradient-to-r from-[#0e1f01] to-mineshaft-700" : "cursor-pointer border-mineshaft-600 bg-mineshaft-800 shadow-xl hover:bg-mineshaft-700" - } text-mineshaft-100 duration-200`} + } text-mineshaft-100 duration-200`} >
@@ -431,8 +444,9 @@ const LearningItemSquare = ({
)}
{complete ? "Complete!" : `About ${time}`}
@@ -461,7 +475,6 @@ const formSchema = yup.object({ type TAddProjectFormData = yup.InferType; // #TODO: Update all the workspaceIds - const OrganizationPage = withPermission( () => { const { t } = useTranslation(); @@ -496,6 +509,9 @@ const OrganizationPage = withPermission( const createWs = useCreateWorkspace(); const { user } = useUser(); const { data: serverDetails } = useFetchServerStatus(); + const [projectsViewMode, setProjectsViewMode] = useState( + (localStorage.getItem("projectsViewMode") as ProjectsViewMode) || ProjectsViewMode.GRID + ); const onCreateProject = async ({ name, addMembers }: TAddProjectFormData) => { // type check @@ -550,6 +566,95 @@ const OrganizationPage = withPermission( }, []); const isWorkspaceEmpty = !isWorkspaceLoading && orgWorkspaces?.length === 0; + const filteredWorkspaces = orgWorkspaces.filter((ws) => + ws?.name?.toLowerCase().includes(searchFilter.toLowerCase()) + ); + + const projectsGridView = ( +
+ {isWorkspaceLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+
+ +
+
+ +
+
+ +
+
+ ))} + {filteredWorkspaces.map((workspace) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className="min-w-72 group flex h-40 cursor-pointer flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" + > +
{workspace.name}
+
+ {workspace.environments?.length || 0} environments +
+ +
+ ))} +
+ ); + + const projectsListView = ( +
+ {isWorkspaceLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {filteredWorkspaces.map((workspace, ind) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + ind === 0 && "rounded-t-md" + } ${ind === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} + > +
+ +
{workspace.name}
+
+
+
+ {workspace.environments?.length || 0} environments +
+
+
+ ))} +
+ ); return (
@@ -580,7 +685,9 @@ const OrganizationPage = withPermission(
)}
-

Projects

+
+

Projects

+
setSearchFilter(e.target.value)} leftIcon={} /> +
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
{(isAllowed) => (
-
- {isWorkspaceLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {orgWorkspaces - .filter((ws) => ws?.name?.toLowerCase().includes(searchFilter.toLowerCase())) - .map((workspace) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className="min-w-72 group flex h-40 cursor-pointer flex-col justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-4" - > -
{workspace.name}
-
- {workspace.environments?.length || 0} environments -
- -
- ))} -
+ {projectsViewMode === ProjectsViewMode.LIST ? projectsListView : projectsGridView} {isWorkspaceEmpty && (
-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
- -
-
+
+

Onboarding Guide

+
+ {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. -
+ <> + + + + )} +
+ +
+
+ {orgWorkspaces.length !== 0 && ( +
+
+
+ + {false && ( +
+ +
+ )} +
+
Inject secrets locally
+
+ Replace .env files with a more secure and efficient alternative.
-
- About 2 min -
- - {false &&
} +
+ About 2 min +
- )} - {orgWorkspaces.length !== 0 && ( - - )} -
- )} + + {false &&
} +
+ )} + {orgWorkspaces.length !== 0 && ( + + )} +
+ )} { diff --git a/frontend/src/pages/project/[id]/certificates/index.tsx b/frontend/src/pages/project/[id]/certificates/index.tsx new file mode 100644 index 000000000..ede2e75f0 --- /dev/null +++ b/frontend/src/pages/project/[id]/certificates/index.tsx @@ -0,0 +1,23 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { CertificatesPage } from "@app/views/Project/CertificatesPage"; + +const Certificates = () => { + const { t } = useTranslation(); + + return ( +
+ + {t("common.head-title", { title: "Certificates" })} + + + + +
+ ); +}; + +export default Certificates; + +Certificates.requireAuth = true; diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index ac7df8c8f..6e2c788ae 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -1,15 +1,17 @@ -import { FormEvent, useEffect, useState } from "react"; +import { FormEvent, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; import { faGithub, faGitlab, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faLock } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import Error from "@app/components/basic/Error"; import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; +import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; import { Button, Input } from "@app/components/v2"; import { useServerConfig } from "@app/context"; import { useFetchServerStatus } from "@app/hooks/api"; @@ -32,6 +34,9 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: const [loginError, setLoginError] = useState(false); const { config } = useServerConfig(); const queryParams = new URLSearchParams(window.location.search); + const [captchaToken, setCaptchaToken] = useState(""); + const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false); + const captchaRef = useRef(null); const { data: serverDetails } = useFetchServerStatus(); useEffect(() => { @@ -56,7 +61,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: // attemptCliLogin const isCliLoginSuccessful = await attemptCliLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { @@ -78,7 +84,8 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: } else { const isLoginSuccessful = await attemptLogin({ email: email.toLowerCase(), - password + password, + captchaToken }); if (isLoginSuccessful && isLoginSuccessful.success) { @@ -112,6 +119,12 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: return; } + if (err.response.data.error === "Captcha Required") { + setShouldShowCaptcha(true); + setIsLoading(false); + return; + } + setLoginError(true); createNotification({ text: "Login unsuccessful. Double-check your credentials and try again.", @@ -119,6 +132,11 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } + if (captchaRef.current) { + captchaRef.current.resetCaptcha(); + } + + setCaptchaToken(""); setIsLoading(false); }; @@ -240,8 +258,19 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: className="select:-webkit-autofill:focus h-10" />
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
+
*/} +
+

Certificate Revocation List

+
+ { + navigator.clipboard.writeText(crl); + setIsCrlCopied.on(); + }} + > + + + Copy + + + { + downloadTxtFile("crl.pem", crl); + }} + > + + + Download + + +
+
+
+

{crl}

+
+ + )} +
+ + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx new file mode 100644 index 000000000..4d2bcb243 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/CaInstallCertModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + // DatePicker, + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { + CaStatus, + useGetCaById, + useGetCaCsr, + useImportCaCertificate, + useListWorkspaceCas, + useSignIntermediate +} from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getMiddleDate = (date1: Date, date2: Date) => { + const timestamp1 = date1.getTime(); + const timestamp2 = date2.getTime(); + + const middleTimestamp = (timestamp1 + timestamp2) / 2; + + return new Date(middleTimestamp); +}; + +const schema = z.object({ + parentCaId: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["installCaCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["installCaCert"]>, state?: boolean) => void; +}; + +enum ParentCaType { + Internal = "internal", + External = "external" +} + +export const CaInstallCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const [parentCaType] = useState(ParentCaType.Internal); + const { currentWorkspace } = useWorkspace(); + const caId = (popUp?.installCaCert?.data as { caId: string })?.caId || ""; + + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + const { data: ca } = useGetCaById(caId); + const { data: csr } = useGetCaCsr(caId); + + const { mutateAsync: signIntermediate } = useSignIntermediate(); + const { mutateAsync: importCaCertificate } = useImportCaCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + maxPathLength: "0" + } + }); + + useEffect(() => { + if (cas?.length) { + setValue("parentCaId", cas[0].id); + } + }, [cas, setValue]); + + const parentCaId = watch("parentCaId"); + const { data: parentCa } = useGetCaById(parentCaId); + + useEffect(() => { + if (parentCa?.maxPathLength) { + setValue( + "maxPathLength", + (parentCa.maxPathLength === -1 ? 3 : parentCa.maxPathLength - 1).toString() + ); + } + + if (parentCa?.notAfter) { + const parentCaNotAfter = new Date(parentCa.notAfter); + const middleDate = getMiddleDate(new Date(), parentCaNotAfter); + setValue("notAfter", format(middleDate, "yyyy-MM-dd")); + } + }, [parentCa]); + + const onFormSubmit = async ({ notAfter, maxPathLength }: FormData) => { + try { + if (!csr || !caId || !currentWorkspace?.slug) return; + + const { certificate, certificateChain } = await signIntermediate({ + caId: parentCaId, + csr, + maxPathLength: Number(maxPathLength), + notAfter, + notBefore: new Date().toISOString() + }); + + await importCaCertificate({ + caId, + projectSlug: currentWorkspace?.slug, + certificate, + certificateChain + }); + + reset(); + + createNotification({ + text: "Successfully installed certificate for CA", + type: "success" + }); + handlePopUpToggle("installCaCert", false); + } catch (err) { + createNotification({ + text: "Failed to install certificate for CA", + type: "error" + }); + } + }; + + function generatePathLengthOpts(parentCaMaxPathLength: number): number[] { + if (parentCaMaxPathLength === -1) { + return [-1, 0, 1, 2, 3]; + } + + return Array.from({ length: parentCaMaxPathLength }, (_, index) => index); + } + + const renderForm = (parentCaTypeInput: ParentCaType) => { + switch (parentCaTypeInput) { + case ParentCaType.Internal: + return ( +
+ ( + + + + )} + /> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ); + default: + return
External TODO
; + } + }; + + return ( + { + handlePopUpToggle("installCaCert", isOpen); + reset(); + }} + > + + {/* + + */} + {renderForm(parentCaType)} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx new file mode 100644 index 000000000..9ad602c06 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaInstallCertModal/index.tsx @@ -0,0 +1 @@ +export { CaInstallCertModal } from "./CaInstallCertModal"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx new file mode 100644 index 000000000..28fd1adbe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaModal.tsx @@ -0,0 +1,433 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { format } from "date-fns"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem + // DatePicker +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaType, useCreateCa, useGetCaById } from "@app/hooks/api/ca"; +import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants"; +import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const isValidDate = (dateString: string) => { + const date = new Date(dateString); + return !Number.isNaN(date.getTime()); +}; + +const getDateTenYearsFromToday = () => { + const date = new Date(); + date.setFullYear(date.getFullYear() + 10); + return format(date, "yyyy-MM-dd"); +}; + +const schema = z + .object({ + type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]), + friendlyName: z.string(), + organization: z.string(), + ou: z.string(), + country: z.string(), + province: z.string(), + locality: z.string(), + commonName: z.string(), + notAfter: z.string().trim().refine(isValidDate, { message: "Invalid date format" }), + maxPathLength: z.string(), + keyAlgorithm: z.enum([ + CertKeyAlgorithm.RSA_2048, + CertKeyAlgorithm.RSA_4096, + CertKeyAlgorithm.ECDSA_P256, + CertKeyAlgorithm.ECDSA_P384 + ]) + }) + .required(); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["ca"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; +}; + +const caTypes = [ + { label: "Root", value: CaType.ROOT }, + { label: "Intermediate", value: CaType.INTERMEDIATE } +]; + +export const CaModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + // const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false); + + const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || ""); + const { mutateAsync: createMutateAsync } = useCreateCa(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + watch + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + } + }); + + const caType = watch("type"); + + useEffect(() => { + if (ca) { + reset({ + type: ca.type, + friendlyName: ca.friendlyName, + organization: ca.organization, + ou: ca.ou, + country: ca.country, + province: ca.province, + locality: ca.locality, + commonName: ca.commonName, + notAfter: ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "", + maxPathLength: ca.maxPathLength ? String(ca.maxPathLength) : "", + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + type: CaType.ROOT, + friendlyName: "", + organization: "", + ou: "", + country: "", + province: "", + locality: "", + commonName: "", + notAfter: getDateTenYearsFromToday(), + maxPathLength: "-1", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + + const onFormSubmit = async ({ + type, + friendlyName, + commonName, + organization, + ou, + country, + locality, + province, + notAfter, + maxPathLength, + keyAlgorithm + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + await createMutateAsync({ + projectSlug: currentWorkspace.slug, + type, + friendlyName, + commonName, + organization, + ou, + country, + province, + locality, + notAfter, + maxPathLength: Number(maxPathLength), + keyAlgorithm + }); + + reset(); + handlePopUpToggle("ca", false); + + createNotification({ + text: "Successfully created CA", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create CA", + type: "error" + }); + } + }; + + return ( + { + reset(); + handlePopUpToggle("ca", isOpen); + }} + > + +
+ ( + + + + )} + /> + {caType === CaType.ROOT && ( + <> + {/* { + return ( + + { + onChange(date); + setIsStartDatePickerOpen(false); + }} + popUpProps={{ + open: isStartDatePickerOpen, + onOpenChange: setIsStartDatePickerOpen + }} + popUpContentProps={{}} + /> + + ); + }} + /> */} + ( + + + + )} + /> + ( + + + + )} + /> + + )} + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!ca && ( +
+ + +
+ )} + +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx new file mode 100644 index 000000000..86e617a56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaSection.tsx @@ -0,0 +1,135 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal, UpgradePlanModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CaCertModal } from "./CaCertModal"; +import { CaCrlModal } from "./CaCrlModal"; +import { CaInstallCertModal } from "./CaInstallCertModal"; +import { CaModal } from "./CaModal"; +import { CaTable } from "./CaTable"; + +export const CaSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCa } = useDeleteCa(); + const { mutateAsync: updateCa } = useUpdateCa(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "ca", + "caCert", + "installCaCert", + "deleteCa", + "caStatus", // enable / disable + "caCrl", // enable / disable + "upgradePlan" + ] as const); + + const onRemoveCaSubmit = async (caId: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCa({ caId, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted CA", + type: "success" + }); + + handlePopUpClose("deleteCa"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete CA", + type: "error" + }); + } + }; + + const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => { + try { + if (!currentWorkspace?.slug) return; + + await updateCa({ caId, projectSlug: currentWorkspace.slug, status }); + + await createNotification({ + text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "success" + }); + + handlePopUpClose("caStatus"); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`, + type: "error" + }); + } + }; + + return ( +
+
+

Certificate Authorities

+ + {(isAllowed) => ( + + )} + +
+ + + + + + handlePopUpToggle("deleteCa", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)} + /> + handlePopUpToggle("caStatus", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus }) + } + /> + handlePopUpToggle("upgradePlan", isOpen)} + text={(popUp.upgradePlan?.data as { description: string })?.description} + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx new file mode 100644 index 000000000..a35a4e432 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaTable.tsx @@ -0,0 +1,258 @@ +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFile, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { + ProjectPermissionActions, + ProjectPermissionSub, + useSubscription, + useWorkspace} from "@app/context"; +import { CaStatus, useListWorkspaceCas } from "@app/hooks/api"; +import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "caCrl", "upgradePlan"] + >, + data?: { + caId?: string; + dn?: string; + status?: CaStatus; + description?: string; + } + ) => void; +}; + +export const CaTable = ({ handlePopUpOpen }: Props) => { + const { subscription } = useSubscription(); + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "" + }); + return ( +
+ + + + + + + + + + + + {isLoading && } + {!isLoading && + data && + data.length > 0 && + data.map((ca) => { + return ( + + + + + + + + ); + })} + +
Friendly NameStatusTypeValid Until +
{ca.friendlyName}{caStatusToNameMap[ca.status]}{caTypeToNameMap[ca.type]}{ca.notAfter ? format(new Date(ca.notAfter), "yyyy-MM-dd") : "-"} + + +
+ + + +
+
+ + {ca.status === CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("installCaCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + Install Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + handlePopUpOpen("caCert", { + caId: ca.id + }); + }} + disabled={!isAllowed} + icon={} + > + View Certificate + + )} + + )} + {ca.status !== CaStatus.PENDING_CERTIFICATE && ( + + {(isAllowed) => ( + { + if (!subscription?.caCrl) { + handlePopUpOpen("upgradePlan", { + description: + "You can use the certificate revocation list (CRL) feature if you upgrade your Infisical plan." + }); + } else { + handlePopUpOpen("caCrl", { + caId: ca.id + }); + } + }} + disabled={!isAllowed} + icon={} + > + View CRL + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("ca", { + caId: ca.id + }) + } + disabled={!isAllowed} + icon={} + > + View CA + + )} + + {(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && ( + + {(isAllowed) => ( + + handlePopUpOpen("caStatus", { + caId: ca.id, + status: + ca.status === CaStatus.ACTIVE + ? CaStatus.DISABLED + : CaStatus.ACTIVE + }) + } + disabled={!isAllowed} + icon={} + > + {`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`} + + )} + + )} + + {(isAllowed) => ( + + handlePopUpOpen("deleteCa", { + caId: ca.id, + dn: ca.dn + }) + } + disabled={!isAllowed} + icon={} + > + Delete CA + + )} + + +
+
+ {!isLoading && data?.length === 0 && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx new file mode 100644 index 000000000..1f23813bc --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/index.tsx @@ -0,0 +1 @@ +export { CaSection } from "./CaSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx new file mode 100644 index 000000000..9e52be028 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/index.tsx @@ -0,0 +1 @@ +export { CaTab } from "./CaTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..f054e2546 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CertificatesSection } from "./components"; + +export const CertificatesTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx new file mode 100644 index 000000000..01c79589c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateCertModal.tsx @@ -0,0 +1,37 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCertBody } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +type Props = { + popUp: UsePopUpState<["certificateCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificateCert"]>, state?: boolean) => void; +}; + +export const CertificateCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCertBody( + (popUp?.certificateCert?.data as { serialNumber: string })?.serialNumber || "" + ); + + return ( + { + handlePopUpToggle("certificateCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx new file mode 100644 index 000000000..8c40af74f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateContent.tsx @@ -0,0 +1,173 @@ +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import FileSaver from "file-saver"; + +import { IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey?: string; +}; + +export const CertificateContent = ({ + serialNumber, + certificate, + certificateChain, + privateKey +}: Props) => { + const [copyTextSerialNumber, isCopyingSerialNumber, setCopyTextSerialNumber] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + const [copyTextCertificate, isCopyingCertificate, setCopyTextCertificate] = useTimedReset( + { + initialState: "Copy to clipboard" + } + ); + const [copyTextCertificateChain, isCopyingCertificateChain, setCopyTextCertificateChain] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const [copyTextCertificateSk, isCopyingCertificateSk, setCopyTextCertificateSk] = + useTimedReset({ + initialState: "Copy to clipboard" + }); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + + return ( +
+

Serial Number

+
+

{serialNumber}

+ + { + navigator.clipboard.writeText(serialNumber); + setCopyTextSerialNumber("Copied"); + }} + > + + + +
+
+

Certificate Body

+
+ + { + navigator.clipboard.writeText(certificate); + setCopyTextCertificate("Copied"); + }} + > + + + + + { + downloadTxtFile("cert.pem", certificate); + }} + > + + + +
+
+
+

{certificate}

+
+ {certificateChain && ( + <> +
+

Certificate Chain

+
+ + { + navigator.clipboard.writeText(certificateChain); + setCopyTextCertificateChain("Copied"); + }} + > + + + + + { + downloadTxtFile("chain.pem", certificateChain); + }} + > + + + +
+
+
+

{certificateChain}

+
+ + )} + {privateKey && ( + <> +
+

Certificate Private Key

+
+ + { + navigator.clipboard.writeText(privateKey); + setCopyTextCertificateSk("Copied"); + }} + > + + + + + { + downloadTxtFile("private_key.txt", privateKey); + }} + > + + + +
+
+
+

{privateKey}

+
+ + )} +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx new file mode 100644 index 000000000..03e70a80c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateModal.tsx @@ -0,0 +1,241 @@ +import { useEffect, useState } 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, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { CaStatus, useCreateCertificate, useGetCert, useListWorkspaceCas } from "@app/hooks/api"; +import { caTypeToNameMap } from "@app/hooks/api/ca/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + caId: z.string(), + friendlyName: z.string(), + commonName: z.string().trim().min(1), + ttl: z.string().trim() +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificate"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["certificate"]>, state?: boolean) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificate?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data: cas } = useListWorkspaceCas({ + projectSlug: currentWorkspace?.slug ?? "", + status: CaStatus.ACTIVE + }); + + const { mutateAsync: createCertificate } = useCreateCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting }, + setValue + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (cert) { + reset({ + caId: cert.caId, + friendlyName: cert.friendlyName, + commonName: cert.commonName, + ttl: "" + }); + } else { + reset({ + caId: "", + friendlyName: "", + commonName: "", + ttl: "" + }); + } + }, [cert]); + + const onFormSubmit = async ({ caId, friendlyName, commonName, ttl }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await createCertificate({ + projectSlug: currentWorkspace.slug, + caId, + friendlyName, + commonName, + ttl + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully created certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to create certificate", + type: "error" + }); + } + }; + + useEffect(() => { + if (cas?.length) { + setValue("caId", cas[0].id); + } + }, [cas]); + + return ( + { + handlePopUpToggle("certificate", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx new file mode 100644 index 000000000..cd5e2118c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificateRevocationModal.tsx @@ -0,0 +1,131 @@ +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, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useRevokeCert } from "@app/hooks/api"; +import { crlReasons } from "@app/hooks/api/certificates/constants"; +import { CrlReason } from "@app/hooks/api/certificates/enums"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + revocationReason: z.enum([ + CrlReason.UNSPECIFIED, + CrlReason.KEY_COMPROMISE, + CrlReason.CA_COMPROMISE, + CrlReason.AFFILIATION_CHANGED, + CrlReason.SUPERSEDED, + CrlReason.CESSATION_OF_OPERATION, + CrlReason.CERTIFICATE_HOLD, + CrlReason.PRIVILEGE_WITHDRAWN, + CrlReason.A_A_COMPROMISE + ]) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["revokeCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["revokeCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRevocationModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: revokeCertificate } = useRevokeCert(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ revocationReason }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const {serialNumber} = popUp.revokeCertificate.data as { serialNumber: string }; + + await revokeCertificate({ + projectSlug: currentWorkspace.slug, + serialNumber, + revocationReason + }); + + reset(); + handlePopUpToggle("revokeCertificate", false); + + createNotification({ + text: "Successfully revoked certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to revoke certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("revokeCertificate", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx new file mode 100644 index 000000000..58bb8ef4c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesSection.tsx @@ -0,0 +1,88 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteCert } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { CertificateCertModal } from "./CertificateCertModal"; +import { CertificateModal } from "./CertificateModal"; +import { CertificateRevocationModal } from "./CertificateRevocationModal"; +import { CertificatesTable } from "./CertificatesTable"; + +export const CertificatesSection = () => { + const { currentWorkspace } = useWorkspace(); + const { mutateAsync: deleteCert } = useDeleteCert(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificate", + "certificateCert", + "deleteCertificate", + "revokeCertificate" + ] as const); + + const onRemoveCertificateSubmit = async (serialNumber: string) => { + try { + if (!currentWorkspace?.slug) return; + + await deleteCert({ serialNumber, projectSlug: currentWorkspace.slug }); + + await createNotification({ + text: "Successfully deleted certificate", + type: "success" + }); + + handlePopUpClose("deleteCertificate"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete certificate", + type: "error" + }); + } + }; + + return ( +
+
+

Certificates

+ + {(isAllowed) => ( + + )} + +
+ + + + + handlePopUpToggle("deleteCertificate", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveCertificateSubmit( + (popUp?.deleteCertificate?.data as { serialNumber: string })?.serialNumber + ) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx new file mode 100644 index 000000000..f23299aa8 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/CertificatesTable.tsx @@ -0,0 +1,205 @@ +import { useState } from "react"; +import { + faBan, + faCertificate, + faEllipsis, + faEye, + faFileExport, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Pagination, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useListWorkspaceCertificates } from "@app/hooks/api"; +import { certStatusToNameMap } from "@app/hooks/api/certificates/constants"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState< + ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] + >, + data?: { + serialNumber?: string; + commonName?: string; + } + ) => void; +}; + +const PER_PAGE_INIT = 25; + +export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(PER_PAGE_INIT); + + const { currentWorkspace } = useWorkspace(); + const { data, isLoading } = useListWorkspaceCertificates({ + projectSlug: currentWorkspace?.slug ?? "", + offset: (page - 1) * perPage, + limit: perPage + }); + + return ( +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data?.certificates.map((certificate) => { + return ( + + + + + + + ); + })} + +
Friendly NameStatusValid Until +
{certificate.friendlyName}{certStatusToNameMap[certificate.status]} + {certificate.notAfter + ? format(new Date(certificate.notAfter), "yyyy-MM-dd") + : "-"} + + + +
+ + + +
+
+ + + {(isAllowed) => ( + + handlePopUpOpen("certificateCert", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Export Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("revokeCertificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + Revoke Certificate + + )} + + + {(isAllowed) => ( + + handlePopUpOpen("deleteCertificate", { + serialNumber: certificate.serialNumber, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Delete Certificate + + )} + + +
+
+ {!isLoading && data?.totalCount !== undefined && data.totalCount >= PER_PAGE_INIT && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.certificates?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx new file mode 100644 index 000000000..7854a6f8b --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/components/index.tsx @@ -0,0 +1 @@ +export { CertificatesSection } from "./CertificatesSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CertificatesTab/index.tsx @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/index.tsx new file mode 100644 index 000000000..9dbd8694f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/index.tsx @@ -0,0 +1,2 @@ +export { CaTab } from "./CaTab"; +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/views/Project/CertificatesPage/index.tsx b/frontend/src/views/Project/CertificatesPage/index.tsx new file mode 100644 index 000000000..a72556644 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/index.tsx @@ -0,0 +1 @@ +export { CertificatesPage } from "./CertificatesPage"; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx index 188d81c65..40b841e0f 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/MultiEnvProjectPermission.tsx @@ -70,6 +70,7 @@ export const MultiEnvProjectPermission = ({ }, [allRule]); const handlePermissionChange = (val: Permission) => { + if(!val) return switch (val) { case Permission.NoAccess: { const permissions = getValue("permissions"); @@ -106,7 +107,7 @@ export const MultiEnvProjectPermission = ({ className={twMerge( "rounded-md bg-mineshaft-800 px-10 py-6", (selectedPermissionCategory !== Permission.NoAccess || isCustom) && - "border-l-2 border-primary-600" + "border-l-2 border-primary-600" )} >
diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx index cd2eaeee0..2140887de 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.tsx @@ -4,6 +4,7 @@ import { faAnchorLock, faArrowLeft, faBook, + faCertificate, faCog, faKey, faLock, @@ -13,8 +14,7 @@ import { faShield, faTags, faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; + faUsers} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -117,6 +117,18 @@ const SINGLE_PERMISSION_LIST = [ subtitle: "IP allowlist management control", icon: faNetworkWired, formName: "ip-allowlist" + }, + { + title: "Certificate Authorities", + subtitle: "CA management control", + icon: faCertificate, + formName: "certificate-authorities" + }, + { + title: "Certificates", + subtitle: "Certificate management control", + icon: faCertificate, + formName: "certificates" } ] as const; diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts index 4d4d45e64..0b534347d 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/ProjectRoleModifySection.utils.ts @@ -48,6 +48,8 @@ export const formSchema = z.object({ tags: generalPermissionSchema, "audit-logs": generalPermissionSchema, "ip-allowlist": generalPermissionSchema, + "certificate-authorities": generalPermissionSchema, + certificates: generalPermissionSchema, // akhilmhdh: refactor all keys like below [ProjectPermissionSub.SecretApproval]: generalPermissionSchema, workspace: z diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx index 03fa41b68..8d1fb1db3 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SecretRollbackPermission.tsx @@ -53,6 +53,7 @@ export const SecretRollbackPermission = ({ isNonEditable, setValue, control }: P }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if(!val) return; if (val === Permission.Custom) setIsCustom.on(); else setIsCustom.off(); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx index fd65cfd58..763aa32f4 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/SingleProjectPermission.tsx @@ -25,6 +25,8 @@ type Props = { | "audit-logs" | "ip-allowlist" | "identity" + | "certificate-authorities" + | "certificates" | ProjectPermissionSub.SecretApproval; isNonEditable?: boolean; setValue: UseFormSetValue; @@ -98,6 +100,7 @@ export const SingleProjectPermission = ({ }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if(!val) return; if (val === Permission.Custom) setIsCustom.on(); else setIsCustom.off(); diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx index 9b04c5236..c13a6abde 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleModifySection/WsProjectPermission.tsx @@ -52,6 +52,7 @@ export const WsProjectPermission = ({ isNonEditable, setValue, control }: Props) }, [selectedPermissionCategory]); const handlePermissionChange = (val: Permission) => { + if(!val) return; if (val === Permission.Custom) setIsCustom.on(); else setIsCustom.off(); diff --git a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx index 1a41b0d35..aa2a3cba3 100644 --- a/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretImportListView/SecretImportListView.tsx @@ -45,7 +45,6 @@ export const computeImportedSecretRows = ( if (importedSecIndex === -1) return []; const importedSec = importSecrets[importedSecIndex]; - const overridenSec: Record = {}; for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) { @@ -61,11 +60,28 @@ export const computeImportedSecretRows = ( overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" }; }); - return importedSec.secrets.map(({ key, value }) => ({ - key, - value, - overriden: overridenSec?.[key] - })); + const importedEntry: Record = {}; + const importedSecretEntries: { + key: string; + value: string; + overriden: { + env: string; + secretPath: string; + }; + }[] = []; + + importedSec.secrets.forEach(({ key, value }) => { + if (!importedEntry[key]) { + importedSecretEntries.push({ + key, + value, + overriden: overridenSec?.[key] + }); + importedEntry[key] = true; + } + }); + + return importedSecretEntries; }; type Props = { @@ -159,8 +175,9 @@ export const SecretImportListView = ({ importEnv.slug === environment && isReserved && importPath === - `${secretPath === "/" ? "" : secretPath}/${ReservedFolders.SecretReplication - }${replicationImportId}` + `${secretPath === "/" ? "" : secretPath}/${ + ReservedFolders.SecretReplication + }${replicationImportId}` ); if (reservedImport) { setReplicationSecrets((state) => ({ @@ -206,8 +223,9 @@ export const SecretImportListView = ({ isOpen={popUp.deleteSecretImport.isOpen} deleteKey="unlink" title="Do you want to remove this secret import?" - subTitle={`This will unlink secrets from environment ${(popUp.deleteSecretImport?.data as TSecretImport)?.importEnv - } of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`} + subTitle={`This will unlink secrets from environment ${ + (popUp.deleteSecretImport?.data as TSecretImport)?.importEnv + } of path ${(popUp.deleteSecretImport?.data as TSecretImport)?.importPath}?`} onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)} onDeleteApproved={handleSecretImportDelete} /> diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index 28e2fed15..f1b9a4c35 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -393,15 +393,15 @@ export const SecretDetailSidebar = ({ {(isAllowed) => ( onChange(!isChecked)} - isChecked={!value} + onCheckedChange={(isChecked) => onChange(isChecked)} + isChecked={value} onBlur={onBlur} isDisabled={!isAllowed} className="items-center" > - Enable multi line encoding + Multi line encoding diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx index f1476c167..5e6342d2c 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretOverviewTableRow.tsx @@ -29,7 +29,7 @@ type Props = { onSecretCreate: (env: string, key: string, value: string) => Promise; onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise; onSecretDelete: (env: string, key: string, secretId?: string) => Promise; - isImportedSecretPresentInEnv: (name: string, env: string, secretName: string) => boolean; + isImportedSecretPresentInEnv: (env: string, secretName: string) => boolean; }; export const SecretOverviewTableRow = ({ @@ -53,9 +53,8 @@ export const SecretOverviewTableRow = ({ <> setIsFormExpanded.toggle()} className="group">
@@ -83,7 +82,7 @@ export const SecretOverviewTableRow = ({ {environments.map(({ slug }, i) => { const secret = getSecretByKey(slug, secretKey); - const isSecretImported = isImportedSecretPresentInEnv(secretPath, slug, secretKey); + const isSecretImported = isImportedSecretPresentInEnv(slug, secretKey); const isSecretPresent = Boolean(secret); const isSecretEmpty = secret?.value === ""; @@ -108,8 +107,8 @@ export const SecretOverviewTableRow = ({ isSecretPresent ? "Present secret" : isSecretImported - ? "Imported secret" - : "Missing secret" + ? "Imported secret" + : "Missing secret" } >
; + +export const PointInTimeVersionLimitSection = () => { + const { mutateAsync: updatePitVersion } = useUpdateWorkspaceVersionLimit(); + + const { currentWorkspace } = useWorkspace(); + const { membership } = useProjectPermission(); + + const { + control, + formState: { isSubmitting, isDirty }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + pitVersionLimit: currentWorkspace?.pitVersionLimit || 10 + } + }); + + if (!currentWorkspace) return null; + + const handleVersionLimitSubmit = async ({ pitVersionLimit }: TForm) => { + try { + await updatePitVersion({ + pitVersionLimit, + projectSlug: currentWorkspace.slug + }); + + createNotification({ + text: "Successfully updated version limit", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed updating project's version limit", + type: "error" + }); + } + }; + + const isAdmin = membership.roles.includes(ProjectMembershipRole.Admin); + return ( +
+
+

Version Retention

+
+

+ This defines the maximum number of recent secret versions to keep per folder. Excess versions will be removed at midnight (UTC) each day. +

+
+
+ ( + + + + )} + /> +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/index.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/index.tsx new file mode 100644 index 000000000..242b8c79a --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/index.tsx @@ -0,0 +1 @@ +export { PointInTimeVersionLimitSection } from "./PointInTimeVersionLimitSection"; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index 7d7c30fb0..511dff93e 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -3,6 +3,7 @@ import { BackfillSecretReferenceSecretion } from "../BackfillSecretReferenceSect import { DeleteProjectSection } from "../DeleteProjectSection"; import { E2EESection } from "../E2EESection"; import { EnvironmentSection } from "../EnvironmentSection"; +import { PointInTimeVersionLimitSection } from "../PointInTimeVersionLimitSection"; import { ProjectNameChangeSection } from "../ProjectNameChangeSection"; import { SecretTagsSection } from "../SecretTagsSection"; @@ -14,6 +15,7 @@ export const ProjectGeneralTab = () => { +
diff --git a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx index ce1b117f3..52fddb22c 100644 --- a/frontend/src/views/admin/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/admin/DashboardPage/DashboardPage.tsx @@ -18,12 +18,16 @@ import { Tab, TabList, TabPanel, - Tabs} from "@app/components/v2"; + Tabs +} from "@app/components/v2"; import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useUpdateServerConfig } from "@app/hooks/api"; +import { RateLimitPanel } from "./RateLimitPanel"; + enum TabSections { - Settings = "settings" + Settings = "settings", + RateLimit = "rate-limit" } enum SignUpModes { @@ -117,6 +121,7 @@ export const AdminDashboardPage = () => {
General + Rate Limit
@@ -233,6 +238,9 @@ export const AdminDashboardPage = () => { + + +
)} diff --git a/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx b/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx new file mode 100644 index 000000000..3979e002b --- /dev/null +++ b/frontend/src/views/admin/DashboardPage/RateLimitPanel.tsx @@ -0,0 +1,262 @@ +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, ContentLoader, FormControl, Input, UpgradePlanModal } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { usePopUp } from "@app/hooks"; +import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api"; + +const formSchema = z.object({ + readRateLimit: z.number(), + writeRateLimit: z.number(), + secretsRateLimit: z.number(), + authRateLimit: z.number(), + inviteUserRateLimit: z.number(), + mfaRateLimit: z.number(), + creationLimit: z.number(), + publicEndpointLimit: z.number() +}); + +type TRateLimitForm = z.infer; + +export const RateLimitPanel = () => { + const { data: rateLimit, isLoading } = useGetRateLimit(); + const { subscription } = useSubscription(); + const { mutateAsync: updateRateLimit } = useUpdateRateLimit(); + const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const); + + const { + control, + handleSubmit, + formState: { isSubmitting, isDirty } + } = useForm({ + resolver: zodResolver(formSchema), + values: { + // eslint-disable-next-line + readRateLimit: rateLimit?.readRateLimit ?? 600, + writeRateLimit: rateLimit?.writeRateLimit ?? 200, + secretsRateLimit: rateLimit?.secretsRateLimit ?? 60, + authRateLimit: rateLimit?.authRateLimit ?? 60, + inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30, + mfaRateLimit: rateLimit?.mfaRateLimit ?? 20, + creationLimit: rateLimit?.creationLimit ?? 30, + publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30 + } + }); + + const onRateLimitFormSubmit = async (formData: TRateLimitForm) => { + try { + if (subscription && !subscription.customRateLimits) { + handlePopUpOpen("upgradePlan"); + return; + } + + const { + readRateLimit, + writeRateLimit, + secretsRateLimit, + authRateLimit, + inviteUserRateLimit, + mfaRateLimit, + creationLimit, + publicEndpointLimit + } = formData; + + await updateRateLimit({ + readRateLimit, + writeRateLimit, + secretsRateLimit, + authRateLimit, + inviteUserRateLimit, + mfaRateLimit, + creationLimit, + publicEndpointLimit + }); + createNotification({ + text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.", + type: "success" + }); + } catch (e) { + console.error(e); + createNotification({ + type: "error", + text: "Failed to update rate limiting setting." + }); + } + }; + + return isLoading ? ( + + ) : ( +
+
+
Configure rate limits
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ + handlePopUpToggle("upgradePlan", isOpen)} + text="You can configure custom rate limits if you switch to Infisical's Enterprise plan." + /> + + ); +}; diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 91632c97d..28bc3403f 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.5.2 +version: v0.6.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.5.2" +appVersion: "v0.6.0" diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index edc3eaaae..f1152c619 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -37,6 +37,135 @@ spec: properties: authentication: properties: + awsIamAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + azureAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + gcpIamAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + serviceAccountKeyFilePath: + type: string + required: + - identityId + - secretsScope + - serviceAccountKeyFilePath + type: object + gcpIdTokenAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + kubernetesAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + serviceAccountRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + required: + - identityId + - secretsScope + - serviceAccountRef + type: object serviceAccount: properties: environmentName: diff --git a/helm-charts/secrets-operator/templates/manager-rbac.yaml b/helm-charts/secrets-operator/templates/manager-rbac.yaml index 758bc7dfe..ca6fd36e1 100644 --- a/helm-charts/secrets-operator/templates/manager-rbac.yaml +++ b/helm-charts/secrets-operator/templates/manager-rbac.yaml @@ -27,6 +27,14 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 8564e79f5..716cec7dc 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.5.2 # fixed to prevent accidental upgrade + tag: v0.6.0 resources: limits: cpu: 500m diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index f6a7ef022..6ea7e4c7b 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -11,6 +11,16 @@ type Authentication struct { ServiceToken ServiceTokenDetails `json:"serviceToken"` // +kubebuilder:validation:Optional UniversalAuth UniversalAuthDetails `json:"universalAuth"` + // +kubebuilder:validation:Optional + KubernetesAuth KubernetesAuthDetails `json:"kubernetesAuth"` + // +kubebuilder:validation:Optional + AwsIamAuth AWSIamAuthDetails `json:"awsIamAuth"` + // +kubebuilder:validation:Optional + AzureAuth AzureAuthDetails `json:"azureAuth"` + // +kubebuilder:validation:Optional + GcpIdTokenAuth GCPIdTokenAuthDetails `json:"gcpIdTokenAuth"` + // +kubebuilder:validation:Optional + GcpIamAuth GcpIamAuthDetails `json:"gcpIamAuth"` } type UniversalAuthDetails struct { @@ -20,6 +30,57 @@ type UniversalAuthDetails struct { SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` } +type KubernetesAuthDetails struct { + // +kubebuilder:validation:Required + IdentityID string `json:"identityId"` + // +kubebuilder:validation:Required + ServiceAccountRef KubernetesServiceAccountRef `json:"serviceAccountRef"` + + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` +} + +type KubernetesServiceAccountRef struct { + // +kubebuilder:validation:Required + Name string `json:"name"` + // +kubebuilder:validation:Required + Namespace string `json:"namespace"` +} + +type AWSIamAuthDetails struct { + // +kubebuilder:validation:Required + IdentityID string `json:"identityId"` + + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` +} + +type AzureAuthDetails struct { + // +kubebuilder:validation:Required + IdentityID string `json:"identityId"` + + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` +} + +type GCPIdTokenAuthDetails struct { + // +kubebuilder:validation:Required + IdentityID string `json:"identityId"` + + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` +} + +type GcpIamAuthDetails struct { + // +kubebuilder:validation:Required + IdentityID string `json:"identityId"` + // +kubebuilder:validation:Required + ServiceAccountKeyFilePath string `json:"serviceAccountKeyFilePath"` + + // +kubebuilder:validation:Required + SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` +} + type ServiceTokenDetails struct { // +kubebuilder:validation:Required ServiceTokenSecretReference KubeSecretReference `json:"serviceTokenSecretReference"` diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index 23251a194..75d46d28b 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -26,12 +26,33 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AWSIamAuthDetails) DeepCopyInto(out *AWSIamAuthDetails) { + *out = *in + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSIamAuthDetails. +func (in *AWSIamAuthDetails) DeepCopy() *AWSIamAuthDetails { + if in == nil { + return nil + } + out := new(AWSIamAuthDetails) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Authentication) DeepCopyInto(out *Authentication) { *out = *in out.ServiceAccount = in.ServiceAccount out.ServiceToken = in.ServiceToken out.UniversalAuth = in.UniversalAuth + out.KubernetesAuth = in.KubernetesAuth + out.AwsIamAuth = in.AwsIamAuth + out.AzureAuth = in.AzureAuth + out.GcpIdTokenAuth = in.GcpIdTokenAuth + out.GcpIamAuth = in.GcpIamAuth } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Authentication. @@ -44,6 +65,54 @@ func (in *Authentication) DeepCopy() *Authentication { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AzureAuthDetails) DeepCopyInto(out *AzureAuthDetails) { + *out = *in + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AzureAuthDetails. +func (in *AzureAuthDetails) DeepCopy() *AzureAuthDetails { + if in == nil { + return nil + } + out := new(AzureAuthDetails) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GCPIdTokenAuthDetails) DeepCopyInto(out *GCPIdTokenAuthDetails) { + *out = *in + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GCPIdTokenAuthDetails. +func (in *GCPIdTokenAuthDetails) DeepCopy() *GCPIdTokenAuthDetails { + if in == nil { + return nil + } + out := new(GCPIdTokenAuthDetails) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GcpIamAuthDetails) DeepCopyInto(out *GcpIamAuthDetails) { + *out = *in + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GcpIamAuthDetails. +func (in *GcpIamAuthDetails) DeepCopy() *GcpIamAuthDetails { + if in == nil { + return nil + } + out := new(GcpIamAuthDetails) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InfisicalSecret) DeepCopyInto(out *InfisicalSecret) { *out = *in @@ -158,6 +227,38 @@ func (in *KubeSecretReference) DeepCopy() *KubeSecretReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesAuthDetails) DeepCopyInto(out *KubernetesAuthDetails) { + *out = *in + out.ServiceAccountRef = in.ServiceAccountRef + out.SecretsScope = in.SecretsScope +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesAuthDetails. +func (in *KubernetesAuthDetails) DeepCopy() *KubernetesAuthDetails { + if in == nil { + return nil + } + out := new(KubernetesAuthDetails) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubernetesServiceAccountRef) DeepCopyInto(out *KubernetesServiceAccountRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesServiceAccountRef. +func (in *KubernetesServiceAccountRef) DeepCopy() *KubernetesServiceAccountRef { + if in == nil { + return nil + } + out := new(KubernetesServiceAccountRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MachineIdentityScopeInWorkspace) DeepCopyInto(out *MachineIdentityScopeInWorkspace) { *out = *in diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 855bff7a9..e14aa3993 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -37,6 +37,135 @@ spec: properties: authentication: properties: + awsIamAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + azureAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + gcpIamAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + serviceAccountKeyFilePath: + type: string + required: + - identityId + - secretsScope + - serviceAccountKeyFilePath + type: object + gcpIdTokenAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + required: + - identityId + - secretsScope + type: object + kubernetesAuth: + properties: + identityId: + type: string + secretsScope: + properties: + envSlug: + type: string + projectSlug: + type: string + recursive: + type: boolean + secretsPath: + type: string + required: + - envSlug + - projectSlug + - secretsPath + type: object + serviceAccountRef: + properties: + name: + type: string + namespace: + type: string + required: + - name + - namespace + type: object + required: + - identityId + - secretsScope + - serviceAccountRef + type: object serviceAccount: properties: environmentName: diff --git a/k8-operator/config/rbac/role.yaml b/k8-operator/config/rbac/role.yaml index 68ea375b7..10c2af414 100644 --- a/k8-operator/config/rbac/role.yaml +++ b/k8-operator/config/rbac/role.yaml @@ -27,6 +27,14 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - get + - list + - watch - apiGroups: - apps resources: diff --git a/k8-operator/config/samples/k8s-auth/cluster-role-binding.yaml b/k8-operator/config/samples/k8s-auth/cluster-role-binding.yaml new file mode 100644 index 000000000..c1adb56e4 --- /dev/null +++ b/k8-operator/config/samples/k8s-auth/cluster-role-binding.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: role-tokenreview-binding + namespace: default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator +subjects: + - kind: ServiceAccount + name: infisical-auth + namespace: default diff --git a/k8-operator/config/samples/k8s-auth/infisical-service-account.yaml b/k8-operator/config/samples/k8s-auth/infisical-service-account.yaml new file mode 100644 index 000000000..60c7f14fd --- /dev/null +++ b/k8-operator/config/samples/k8s-auth/infisical-service-account.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: infisical-auth + namespace: default \ No newline at end of file diff --git a/k8-operator/config/samples/k8s-auth/sample.yaml b/k8-operator/config/samples/k8s-auth/sample.yaml new file mode 100644 index 000000000..6dcbae13a --- /dev/null +++ b/k8-operator/config/samples/k8s-auth/sample.yaml @@ -0,0 +1,32 @@ +apiVersion: secrets.infisical.com/v1alpha1 +kind: InfisicalSecret +metadata: + name: infisicalsecret-sample + labels: + label-to-be-passed-to-managed-secret: sample-value + annotations: + example.com/annotation-to-be-passed-to-managed-secret: "sample-value" +spec: + hostAPI: https://app.infisical.com/api + resyncInterval: 10 + authentication: + # Native Kubernetes Auth + kubernetesAuth: + identityId: <> + serviceAccountRef: + name: infisical-auth + namespace: default + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: dsf-gpb-t + envSlug: dev + secretsPath: "/" + recursive: true + + + managedSecretReference: + secretName: managed-secret-k8s + secretNamespace: default + creationPolicy: "Orphan" ## Owner | Orphan + # secretType: kubernetes.io/dockerconfigjson diff --git a/k8-operator/config/samples/k8s-auth/service-account-token.yaml b/k8-operator/config/samples/k8s-auth/service-account-token.yaml new file mode 100644 index 000000000..894c98b06 --- /dev/null +++ b/k8-operator/config/samples/k8s-auth/service-account-token.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Secret +type: kubernetes.io/service-account-token +metadata: + name: infisical-auth-token + annotations: + kubernetes.io/service-account.name: "infisical-auth" \ No newline at end of file diff --git a/k8-operator/config/samples/sample.yaml b/k8-operator/config/samples/sample.yaml index 1c8a9a3f0..6652b8160 100644 --- a/k8-operator/config/samples/sample.yaml +++ b/k8-operator/config/samples/sample.yaml @@ -12,26 +12,85 @@ spec: authentication: # Make sure to only have 1 authentication method defined, serviceToken/universalAuth. # If you have multiple authentication methods defined, it may cause issues. + + # (Deprecated) Service Token Auth serviceToken: serviceTokenSecretReference: secretName: service-token secretNamespace: default secretsScope: envSlug: - secretsPath: # Root is "/" - recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false + secretsPath: + recursive: true + # Universal Auth universalAuth: secretsScope: - projectSlug: - envSlug: # "dev", "staging", "prod", etc.. - secretsPath: "" # Root is "/" + projectSlug: new-ob-em + envSlug: dev # "dev", "staging", "prod", etc.. + secretsPath: "/" # Root is "/" recursive: true # Wether or not to use recursive mode (Fetches all secrets in an environment from a given secret path, and all folders inside the path) / defaults to false - credentialsRef: secretName: universal-auth-credentials secretNamespace: default + # Native Kubernetes Auth + kubernetesAuth: + identityId: + serviceAccountTokenPath: "/path/to/your/service-account/token" # Optional, defaults to /var/run/secrets/kubernetes.io/serviceaccount/token + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # AWS IAM Auth + awsIamAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # Azure Auth + azureAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP ID Token Auth + gcpIdTokenAuth: + identityId: + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + + # GCP IAM Auth + gcpIamAuth: + identityId: + serviceAccountKeyFilePath: "/path/to-service-account-key-file-path.json" + + # secretsScope is identical to the secrets scope in the universalAuth field in this sample. + secretsScope: + projectSlug: your-project-slug + envSlug: prod + secretsPath: "/path" + recursive: true + managedSecretReference: secretName: managed-secret secretNamespace: default diff --git a/k8-operator/controllers/conditions.go b/k8-operator/controllers/conditions.go index dea822088..312b1d699 100644 --- a/k8-operator/controllers/conditions.go +++ b/k8-operator/controllers/conditions.go @@ -40,7 +40,7 @@ func (r *InfisicalSecretReconciler) SetReadyToSyncSecretsConditions(ctx context. return r.Client.Status().Update(ctx, infisicalSecret) } -func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.Context, infisicalSecret *v1alpha1.InfisicalSecret, errorToConditionOn error) { +func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.Context, infisicalSecret *v1alpha1.InfisicalSecret, authStrategy AuthStrategyType, errorToConditionOn error) { if infisicalSecret.Status.Conditions == nil { infisicalSecret.Status.Conditions = []metav1.Condition{} } @@ -50,7 +50,7 @@ func (r *InfisicalSecretReconciler) SetInfisicalTokenLoadCondition(ctx context.C Type: "secrets.infisical.com/LoadedInfisicalToken", Status: metav1.ConditionTrue, Reason: "OK", - Message: "Infisical controller has located the Infisical token in provided Kubernetes secret", + Message: fmt.Sprintf("Infisical controller has loaded the Infisical token in provided Kubernetes secret, using %v authentication strategy", authStrategy), }) } else { meta.SetStatusCondition(&infisicalSecret.Status.Conditions, metav1.Condition{ diff --git a/k8-operator/controllers/infisicalsecret_auth.go b/k8-operator/controllers/infisicalsecret_auth.go new file mode 100644 index 000000000..44861df87 --- /dev/null +++ b/k8-operator/controllers/infisicalsecret_auth.go @@ -0,0 +1,150 @@ +package controllers + +import ( + "context" + "errors" + "fmt" + + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/util" + infisicalSdk "github.com/infisical/go-sdk" +) + +type AuthStrategyType string + +var AuthStrategy = struct { + SERVICE_TOKEN AuthStrategyType + SERVICE_ACCOUNT AuthStrategyType + UNIVERSAL_MACHINE_IDENTITY AuthStrategyType + KUBERNETES_MACHINE_IDENTITY AuthStrategyType + AWS_IAM_MACHINE_IDENTITY AuthStrategyType + AZURE_MACHINE_IDENTITY AuthStrategyType + GCP_ID_TOKEN_MACHINE_IDENTITY AuthStrategyType + GCP_IAM_MACHINE_IDENTITY AuthStrategyType +}{ + SERVICE_TOKEN: "SERVICE_TOKEN", + SERVICE_ACCOUNT: "SERVICE_ACCOUNT", + UNIVERSAL_MACHINE_IDENTITY: "UNIVERSAL_MACHINE_IDENTITY", + KUBERNETES_MACHINE_IDENTITY: "KUBERNETES_AUTH_MACHINE_IDENTITY", + AWS_IAM_MACHINE_IDENTITY: "AWS_IAM_MACHINE_IDENTITY", + AZURE_MACHINE_IDENTITY: "AZURE_MACHINE_IDENTITY", + GCP_ID_TOKEN_MACHINE_IDENTITY: "GCP_ID_TOKEN_MACHINE_IDENTITY", + GCP_IAM_MACHINE_IDENTITY: "GCP_IAM_MACHINE_IDENTITY", +} + +type AuthenticationDetails struct { + authStrategy AuthStrategyType + machineIdentityScope v1alpha1.MachineIdentityScopeInWorkspace // This will only be set if a machine identity auth method is used (e.g. UniversalAuth or KubernetesAuth, etc.) + isMachineIdentityAuth bool +} + +var ErrAuthNotApplicable = errors.New("authentication not applicable") + +func (r *InfisicalSecretReconciler) handleUniversalAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + + // Machine Identities: + universalAuthKubeSecret, err := r.GetInfisicalUniversalAuthFromKubeSecret(ctx, infisicalSecret) + universalAuthSpec := infisicalSecret.Spec.Authentication.UniversalAuth + + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get machine identity creds from kube secret [err=%s]", err) + } + + if universalAuthKubeSecret.ClientId == "" && universalAuthKubeSecret.ClientSecret == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err = infisicalClient.Auth().UniversalAuthLogin(universalAuthKubeSecret.ClientId, universalAuthKubeSecret.ClientSecret) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with machine identity credentials [err=%s]", err) + } + + fmt.Println("Successfully authenticated with machine identity credentials") + + return AuthenticationDetails{authStrategy: AuthStrategy.UNIVERSAL_MACHINE_IDENTITY, machineIdentityScope: universalAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil + +} + +func (r *InfisicalSecretReconciler) handleKubernetesAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + kubernetesAuthSpec := infisicalSecret.Spec.Authentication.KubernetesAuth + + if kubernetesAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + serviceAccountToken, err := util.GetServiceAccountToken(r.Client, kubernetesAuthSpec.ServiceAccountRef.Namespace, kubernetesAuthSpec.ServiceAccountRef.Name) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to get service account token [err=%s]", err) + } + + _, err = infisicalClient.Auth().KubernetesRawServiceAccountTokenLogin(kubernetesAuthSpec.IdentityID, serviceAccountToken) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with Kubernetes native auth [err=%s]", err) + } + + return AuthenticationDetails{authStrategy: AuthStrategy.KUBERNETES_MACHINE_IDENTITY, machineIdentityScope: kubernetesAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil + +} + +func (r *InfisicalSecretReconciler) handleAwsIamAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + awsIamAuthSpec := infisicalSecret.Spec.Authentication.AwsIamAuth + + if awsIamAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().AwsIamAuthLogin(awsIamAuthSpec.IdentityID) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with AWS IAM auth [err=%s]", err) + } + + return AuthenticationDetails{authStrategy: AuthStrategy.AWS_IAM_MACHINE_IDENTITY, machineIdentityScope: awsIamAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil + +} + +func (r *InfisicalSecretReconciler) handleAzureAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + azureAuthSpec := infisicalSecret.Spec.Authentication.AzureAuth + + if azureAuthSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().AzureAuthLogin(azureAuthSpec.IdentityID) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with Azure auth [err=%s]", err) + } + + return AuthenticationDetails{authStrategy: AuthStrategy.AZURE_MACHINE_IDENTITY, machineIdentityScope: azureAuthSpec.SecretsScope, isMachineIdentityAuth: true}, nil + +} + +func (r *InfisicalSecretReconciler) handleGcpIdTokenAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + gcpIdTokenSpec := infisicalSecret.Spec.Authentication.GcpIdTokenAuth + + if gcpIdTokenSpec.IdentityID == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().GcpIdTokenAuthLogin(gcpIdTokenSpec.IdentityID) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP Id Token auth [err=%s]", err) + } + + return AuthenticationDetails{authStrategy: AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY, machineIdentityScope: gcpIdTokenSpec.SecretsScope, isMachineIdentityAuth: true}, nil + +} + +func (r *InfisicalSecretReconciler) handleGcpIamAuth(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + gcpIamSpec := infisicalSecret.Spec.Authentication.GcpIamAuth + + if gcpIamSpec.IdentityID == "" && gcpIamSpec.ServiceAccountKeyFilePath == "" { + return AuthenticationDetails{}, ErrAuthNotApplicable + } + + _, err := infisicalClient.Auth().GcpIamAuthLogin(gcpIamSpec.IdentityID, gcpIamSpec.ServiceAccountKeyFilePath) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("unable to login with GCP IAM auth [err=%s]", err) + } + + return AuthenticationDetails{authStrategy: AuthStrategy.GCP_IAM_MACHINE_IDENTITY, machineIdentityScope: gcpIamSpec.SecretsScope, isMachineIdentityAuth: true}, nil +} diff --git a/k8-operator/controllers/infisicalsecret_controller.go b/k8-operator/controllers/infisicalsecret_controller.go index 5eba262ac..2078341d8 100644 --- a/k8-operator/controllers/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret_controller.go @@ -9,10 +9,11 @@ import ( "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + controllerUtil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - "github.com/Infisical/infisical/k8-operator/api/v1alpha1" secretsv1alpha1 "github.com/Infisical/infisical/k8-operator/api/v1alpha1" "github.com/Infisical/infisical/k8-operator/packages/api" + infisicalSdk "github.com/infisical/go-sdk" ) // InfisicalSecretReconciler reconciles a InfisicalSecret object @@ -27,24 +28,59 @@ type InfisicalSecretReconciler struct { //+kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;update +//+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.13.1/pkg/reconcile + +type ResourceVariables struct { + infisicalClient infisicalSdk.InfisicalClientInterface + authDetails AuthenticationDetails +} + +// Maps the infisicalSecretCR.UID to a infisicalSdk.InfisicalClientInterface and AuthenticationDetails. +var resourceVariablesMap = make(map[string]ResourceVariables) + +const FINALIZER_NAME = "secrets.finalizers.infisical.com" + +func (r *InfisicalSecretReconciler) addFinalizer(ctx context.Context, infisicalSecret *secretsv1alpha1.InfisicalSecret) error { + if !controllerUtil.ContainsFinalizer(infisicalSecret, FINALIZER_NAME) { + controllerUtil.AddFinalizer(infisicalSecret, FINALIZER_NAME) + if err := r.Update(ctx, infisicalSecret); err != nil { + return err + } + } + return nil +} + +func (r *InfisicalSecretReconciler) handleFinalizer(ctx context.Context, infisicalSecret *secretsv1alpha1.InfisicalSecret) error { + if controllerUtil.ContainsFinalizer(infisicalSecret, FINALIZER_NAME) { + // Cleanup deployment variables + delete(resourceVariablesMap, string(infisicalSecret.UID)) + + // Remove the finalizer and update the resource + controllerUtil.RemoveFinalizer(infisicalSecret, FINALIZER_NAME) + if err := r.Update(ctx, infisicalSecret); err != nil { + return err + } + } + return nil +} + func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - var infisicalSecretCR v1alpha1.InfisicalSecret + var infisicalSecretCR secretsv1alpha1.InfisicalSecret requeueTime := time.Minute // seconds err := r.Get(ctx, req.NamespacedName, &infisicalSecretCR) if err != nil { if errors.IsNotFound(err) { - fmt.Printf("Infisical Secret CRD not found [err=%v]", err) return ctrl.Result{ Requeue: false, }, nil } else { - fmt.Printf("Unable to fetch Infisical Secret CRD from cluster because [err=%v]", err) + fmt.Printf("\nUnable to fetch Infisical Secret CRD from cluster because [err=%v]", err) return ctrl.Result{ RequeueAfter: requeueTime, }, nil @@ -58,8 +94,20 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ fmt.Printf("\nRe-sync interval set. Interval: %v\n", requeueTime) } + // Add the finalizer if it does not exist, and only add it if the resource is not marked for deletion + if infisicalSecretCR.GetDeletionTimestamp() == nil || infisicalSecretCR.GetDeletionTimestamp().IsZero() { + if err := r.addFinalizer(ctx, &infisicalSecretCR); err != nil { + return ctrl.Result{}, err + } + } + // Check if the resource is already marked for deletion if infisicalSecretCR.GetDeletionTimestamp() != nil { + // Handle the finalizer logic + if err := r.handleFinalizer(ctx, &infisicalSecretCR); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{ Requeue: false, }, nil diff --git a/k8-operator/controllers/infisicalsecret_helper.go b/k8-operator/controllers/infisicalsecret_helper.go index c14f724eb..9ae6ad69a 100644 --- a/k8-operator/controllers/infisicalsecret_helper.go +++ b/k8-operator/controllers/infisicalsecret_helper.go @@ -2,16 +2,21 @@ package controllers import ( "context" + "errors" "fmt" "strings" "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/Infisical/infisical/k8-operator/packages/api" "github.com/Infisical/infisical/k8-operator/packages/model" "github.com/Infisical/infisical/k8-operator/packages/util" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + infisicalSdk "github.com/infisical/go-sdk" + corev1 "k8s.io/api/core/v1" + k8Errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" ) @@ -28,20 +33,51 @@ const OPERATOR_SETTINGS_CONFIGMAP_NAME = "infisical-config" const OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE = "infisical-operator-system" const INFISICAL_DOMAIN = "https://app.infisical.com/api" -type AuthStrategyType string +func (r *InfisicalSecretReconciler) HandleAuthentication(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error) { + + // ? Legacy support, service token auth + infisicalToken, err := r.GetInfisicalTokenFromKubeSecret(ctx, infisicalSecret) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) + } + + // ? Legacy support, service account auth + serviceAccountCreds, err := r.GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) + if err != nil { + return AuthenticationDetails{}, fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) + } + + if serviceAccountCreds.AccessKey != "" || serviceAccountCreds.PrivateKey != "" || serviceAccountCreds.PublicKey != "" { + return AuthenticationDetails{authStrategy: AuthStrategy.SERVICE_ACCOUNT}, nil + } else if infisicalToken != "" { + return AuthenticationDetails{authStrategy: AuthStrategy.SERVICE_TOKEN}, nil + } + + authStrategies := map[AuthStrategyType]func(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret, infisicalClient infisicalSdk.InfisicalClientInterface) (AuthenticationDetails, error){ + AuthStrategy.UNIVERSAL_MACHINE_IDENTITY: r.handleUniversalAuth, + AuthStrategy.KUBERNETES_MACHINE_IDENTITY: r.handleKubernetesAuth, + AuthStrategy.AWS_IAM_MACHINE_IDENTITY: r.handleAwsIamAuth, + AuthStrategy.AZURE_MACHINE_IDENTITY: r.handleAzureAuth, + AuthStrategy.GCP_ID_TOKEN_MACHINE_IDENTITY: r.handleGcpIdTokenAuth, + AuthStrategy.GCP_IAM_MACHINE_IDENTITY: r.handleGcpIamAuth, + } + + for authStrategy, authHandler := range authStrategies { + authDetails, err := authHandler(ctx, infisicalSecret, infisicalClient) + + if err == nil { + return authDetails, nil + } + + if err != nil && !errors.Is(err, ErrAuthNotApplicable) { + return AuthenticationDetails{}, fmt.Errorf("authentication failed for strategy [%s] [err=%w]", authStrategy, err) + } + } + + return AuthenticationDetails{}, fmt.Errorf("no authentication method provided") -var AuthStrategy = struct { - SERVICE_TOKEN AuthStrategyType - SERVICE_ACCOUNT AuthStrategyType - UNIVERSAL_MACHINE_IDENTITY AuthStrategyType -}{ - SERVICE_TOKEN: "SERVICE_TOKEN", - SERVICE_ACCOUNT: "SERVICE_ACCOUNT", - UNIVERSAL_MACHINE_IDENTITY: "UNIVERSAL_MACHINE_IDENTITY", } -var machineIdentityTokenInstance *util.MachineIdentityToken - func (r *InfisicalSecretReconciler) GetInfisicalConfigMap(ctx context.Context) (configMap map[string]string, errToReturn error) { // default key values defaultConfigMapData := make(map[string]string) @@ -54,7 +90,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalConfigMap(ctx context.Context) ( }, kubeConfigMap) if err != nil { - if errors.IsNotFound(err) { + if k8Errors.IsNotFound(err) { kubeConfigMap = nil } else { return nil, fmt.Errorf("GetConfigMapByNamespacedName: unable to fetch config map in [namespacedName=%s] [err=%s]", OPERATOR_SETTINGS_CONFIGMAP_NAMESPACE, err) @@ -103,7 +139,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalTokenFromKubeSecret(ctx context. Name: secretName, }) - if errors.IsNotFound(err) { + if k8Errors.IsNotFound(err) { return "", nil } @@ -123,7 +159,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalUniversalAuthFromKubeSecret(ctx Name: infisicalSecret.Spec.Authentication.UniversalAuth.CredentialsRef.SecretName, }) - if errors.IsNotFound(err) { + if k8Errors.IsNotFound(err) { return model.MachineIdentityDetails{}, nil } @@ -146,7 +182,7 @@ func (r *InfisicalSecretReconciler) GetInfisicalServiceAccountCredentialsFromKub Name: infisicalSecret.Spec.Authentication.ServiceAccount.ServiceAccountSecretReference.SecretName, }) - if errors.IsNotFound(err) { + if k8Errors.IsNotFound(err) { return model.ServiceAccountDetails{}, nil } @@ -232,7 +268,6 @@ func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context } managedKubeSecret.Data = plainProcessedSecrets - managedKubeSecret.ObjectMeta.Annotations = map[string]string{} managedKubeSecret.ObjectMeta.Annotations[SECRET_VERSION_ANNOTATION] = ETag err := r.Client.Update(ctx, &managedKubeSecret) @@ -244,37 +279,55 @@ func (r *InfisicalSecretReconciler) UpdateInfisicalManagedKubeSecret(ctx context return nil } -func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) error { - infisicalToken, err := r.GetInfisicalTokenFromKubeSecret(ctx, infisicalSecret) - if err != nil { - return fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) - } +func (r *InfisicalSecretReconciler) GetResourceVariables(infisicalSecret v1alpha1.InfisicalSecret) ResourceVariables { - var authStrategy AuthStrategyType + var resourceVariables ResourceVariables - serviceAccountCreds, err := r.GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) - if err != nil { - return fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) - } + if _, ok := resourceVariablesMap[string(infisicalSecret.UID)]; !ok { - infisicalMachineIdentityCreds, err := r.GetInfisicalUniversalAuthFromKubeSecret(ctx, infisicalSecret) - if err != nil { - return fmt.Errorf("ReconcileInfisicalSecret: unable to get machine identity creds from kube secret [err=%s]", err) - } + client := infisicalSdk.NewInfisicalClient(infisicalSdk.Config{ + SiteUrl: infisicalSecret.Spec.HostAPI, + UserAgent: api.USER_AGENT_NAME, + }) + + resourceVariablesMap[string(infisicalSecret.UID)] = ResourceVariables{ + infisicalClient: client, + authDetails: AuthenticationDetails{}, + } + + resourceVariables = resourceVariablesMap[string(infisicalSecret.UID)] - if serviceAccountCreds.AccessKey != "" || serviceAccountCreds.PrivateKey != "" || serviceAccountCreds.PublicKey != "" { - authStrategy = AuthStrategy.SERVICE_ACCOUNT - } else if infisicalToken != "" { - authStrategy = AuthStrategy.SERVICE_TOKEN - } else if infisicalMachineIdentityCreds.ClientId != "" && infisicalMachineIdentityCreds.ClientSecret != "" { - authStrategy = AuthStrategy.UNIVERSAL_MACHINE_IDENTITY } else { - return fmt.Errorf("no authentication method provided. You must provide either a valid service token or a service account details to fetch secrets\n") + resourceVariables = resourceVariablesMap[string(infisicalSecret.UID)] } - r.SetInfisicalTokenLoadCondition(ctx, &infisicalSecret, err) - if err != nil { - return fmt.Errorf("unable to load Infisical Token from the specified Kubernetes secret with error [%w]", err) + return resourceVariables + +} + +func (r *InfisicalSecretReconciler) UpdateResourceVariables(infisicalSecret v1alpha1.InfisicalSecret, resourceVariables ResourceVariables) { + resourceVariablesMap[string(infisicalSecret.UID)] = resourceVariables +} + +func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context, infisicalSecret v1alpha1.InfisicalSecret) error { + + resourceVariables := r.GetResourceVariables(infisicalSecret) + infisicalClient := resourceVariables.infisicalClient + authDetails := resourceVariables.authDetails + + if authDetails.authStrategy == "" { + fmt.Println("ReconcileInfisicalSecret: No authentication strategy found. Attempting to authenticate") + authDetails, err := r.HandleAuthentication(ctx, infisicalSecret, infisicalClient) + r.SetInfisicalTokenLoadCondition(ctx, &infisicalSecret, authDetails.authStrategy, err) + + if err != nil { + return fmt.Errorf("unable to authenticate [err=%s]", err) + } + + r.UpdateResourceVariables(infisicalSecret, ResourceVariables{ + infisicalClient: infisicalClient, + authDetails: authDetails, + }) } // Look for managed secret by name and namespace @@ -283,7 +336,7 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context Namespace: infisicalSecret.Spec.ManagedSecretReference.SecretNamespace, }) - if err != nil && !errors.IsNotFound(err) { + if err != nil && !k8Errors.IsNotFound(err) { return fmt.Errorf("something went wrong when fetching the managed Kubernetes secret [%w]", err) } @@ -293,15 +346,19 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context secretVersionBasedOnETag = managedKubeSecret.Annotations[SECRET_VERSION_ANNOTATION] } - if authStrategy == AuthStrategy.UNIVERSAL_MACHINE_IDENTITY && machineIdentityTokenInstance == nil { - // Create new machine identity token instance - machineIdentityTokenInstance = util.NewMachineIdentityToken(infisicalMachineIdentityCreds.ClientId, infisicalMachineIdentityCreds.ClientSecret) - } - var plainTextSecretsFromApi []model.SingleEnvironmentVariable var updateDetails model.RequestUpdateUpdateDetails - if authStrategy == AuthStrategy.SERVICE_ACCOUNT { // Service Account + if authDetails.authStrategy == AuthStrategy.SERVICE_ACCOUNT { // Service Account // ! Legacy auth method + serviceAccountCreds, err := r.GetInfisicalServiceAccountCredentialsFromKubeSecret(ctx, infisicalSecret) + if err != nil { + return fmt.Errorf("ReconcileInfisicalSecret: unable to get service account creds from kube secret [err=%s]", err) + } + + if err != nil { + return fmt.Errorf("unable to load Infisical Token from the specified Kubernetes secret with error [%w]", err) + } + plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaServiceAccount(serviceAccountCreds, infisicalSecret.Spec.Authentication.ServiceAccount.ProjectId, infisicalSecret.Spec.Authentication.ServiceAccount.EnvironmentName, secretVersionBasedOnETag) if err != nil { return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) @@ -309,7 +366,12 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context fmt.Println("ReconcileInfisicalSecret: Fetched secrets via service account") - } else if authStrategy == AuthStrategy.SERVICE_TOKEN { // Service Tokens (deprecated) + } else if authDetails.authStrategy == AuthStrategy.SERVICE_TOKEN { // Service Tokens // ! Legacy / Deprecated auth method + infisicalToken, err := r.GetInfisicalTokenFromKubeSecret(ctx, infisicalSecret) + if err != nil { + return fmt.Errorf("ReconcileInfisicalSecret: unable to get service token from kube secret [err=%s]", err) + } + envSlug := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.EnvSlug secretsPath := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.SecretsPath recursive := infisicalSecret.Spec.Authentication.ServiceToken.SecretsScope.Recursive @@ -319,24 +381,17 @@ func (r *InfisicalSecretReconciler) ReconcileInfisicalSecret(ctx context.Context return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } - fmt.Println("ReconcileInfisicalSecret: Fetched secrets via service token") - } else if authStrategy == AuthStrategy.UNIVERSAL_MACHINE_IDENTITY { // Machine Identity - - accessToken, err := machineIdentityTokenInstance.GetToken() - - if err != nil { - return fmt.Errorf("%s", "Waiting for access token to become available") - } - scope := infisicalSecret.Spec.Authentication.UniversalAuth.SecretsScope - plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaUniversalAuth(accessToken, secretVersionBasedOnETag, scope) + fmt.Println("ReconcileInfisicalSecret: Fetched secrets via [type=SERVICE_TOKEN]") + } else if authDetails.isMachineIdentityAuth { // * Machine Identity authentication, the SDK will be authenticated at this point + plainTextSecretsFromApi, updateDetails, err = util.GetPlainTextSecretsViaMachineIdentity(infisicalClient, secretVersionBasedOnETag, authDetails.machineIdentityScope) if err != nil { return fmt.Errorf("\nfailed to get secrets because [err=%v]", err) } - fmt.Println("ReconcileInfisicalSecret: Fetched secrets via universal auth") + fmt.Printf("ReconcileInfisicalSecret: Fetched secrets via machine identity [type=%v]\n", authDetails.authStrategy) } else { - return fmt.Errorf("no authentication method provided. You must provide either a valid service token or a service account details to fetch secrets") + return errors.New("no authentication method provided yet. Please configure a authentication method then try again") } if !updateDetails.Modified { diff --git a/k8-operator/go.mod b/k8-operator/go.mod index 3a5396dc3..f71321607 100644 --- a/k8-operator/go.mod +++ b/k8-operator/go.mod @@ -1,8 +1,9 @@ module github.com/Infisical/infisical/k8-operator -go 1.19 +go 1.21 require ( + github.com/infisical/go-sdk v0.1.9 github.com/onsi/ginkgo/v2 v2.6.0 github.com/onsi/gomega v1.24.1 k8s.io/apimachinery v0.26.1 @@ -10,26 +11,62 @@ require ( sigs.k8s.io/controller-runtime v0.14.4 ) +require ( + cloud.google.com/go/auth v0.5.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect + cloud.google.com/go/compute/metadata v0.3.0 // indirect + cloud.google.com/go/iam v1.1.8 // indirect + github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect + github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect + github.com/aws/smithy-go v1.20.2 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/s2a-go v0.1.7 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect + github.com/googleapis/gax-go/v2 v2.12.4 // indirect + go.opencensus.io v0.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + golang.org/x/sync v0.7.0 // indirect + google.golang.org/api v0.183.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/grpc v1.64.0 // indirect +) + require ( github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.9.0 // indirect github.com/evanphx/json-patch/v5 v5.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect - github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/zapr v1.2.3 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.19.14 // indirect - github.com/go-resty/resty/v2 v2.10.0 + github.com/go-resty/resty/v2 v2.13.1 github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic v0.5.7-v3refs // indirect - github.com/google/go-cmp v0.5.9 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.1.0 // indirect - github.com/google/uuid v1.1.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -47,16 +84,15 @@ require ( go.uber.org/atomic v1.7.0 // indirect go.uber.org/multierr v1.6.0 // indirect go.uber.org/zap v1.24.0 // indirect - golang.org/x/crypto v0.14.0 - golang.org/x/net v0.17.0 // indirect - golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect - golang.org/x/time v0.3.0 // indirect + golang.org/x/crypto v0.23.0 + golang.org/x/net v0.25.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/term v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.28.1 // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/k8-operator/go.sum b/k8-operator/go.sum index 52e2e46b2..bf3bb421e 100644 --- a/k8-operator/go.sum +++ b/k8-operator/go.sum @@ -13,14 +13,22 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/auth v0.5.1 h1:0QNO7VThG54LUzKiQxv8C6x1YX7lUrzlAa1nVLF8CIw= +cloud.google.com/go/auth v0.5.1/go.mod h1:vbZT8GjzDf3AVqCcQmqeeM32U9HBFc32vVVAbwDsa6s= +cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= +cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/iam v1.1.8 h1:r7umDwhj+BQyz0ScZMp4QrGXjSTI3ZINnpgU2nlB/K0= +cloud.google.com/go/iam v1.1.8/go.mod h1:GvE6lyMmfxXauzNq8NbgJbeVQNspG+tcdL/W8QO1+zE= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -38,6 +46,32 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= +github.com/aws/aws-sdk-go-v2 v1.27.2/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.18 h1:wFvAnwOKKe7QAyIxziwSKjmer9JBMH1vzIL6W+fYuKk= +github.com/aws/aws-sdk-go-v2/config v1.27.18/go.mod h1:0xz6cgdX55+kmppvPm2IaKzIXOheGJhAufacPJaXZ7c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18 h1:D/ALDWqK4JdY3OFgA2thcPO1c9aYTT5STS/CvnkqY1c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18/go.mod h1:JuitCWq+F5QGUrmMPsk945rop6bB57jdscu+Glozdnc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 h1:dDgptDO9dxeFkXy+tEgVkzSClHZje/6JkPW5aZyEvrQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5/go.mod h1:gjvE2KBUgUQhcv89jqxrIxH9GaKs1JbZzWejj/DaHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 h1:cy8ahBJuhtM8GTTSyOkfy6WVPV1IE+SS5/wfXUYuulw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9/go.mod h1:CZBXGLaJnEZI6EVNcPd7a6B5IC5cA/GkRWtu9fp3S6Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 h1:A4SYk07ef04+vxZToz9LWvAXl9LW0NClpPpMsi31cz0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9/go.mod h1:5jJcHuwDagxN+ErjQ3PU3ocf6Ylc/p9x+BLO/+X4iXw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 h1:o4T+fKxA3gTMcluBNZZXE9DNaMkJuUL1O3mffCUjoJo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11/go.mod h1:84oZdJ+VjuJKs9v1UTC9NaodRZRseOXCTgku+vQJWR8= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 h1:gEYM2GSpr4YNWc6hCd5nod4+d4kd9vWIAWrmGuLdlMw= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11/go.mod h1:gVvwPdPNYehHSP9Rs7q27U1EU+3Or2ZpXvzAYJNh63w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 h1:iXjh3uaH3vsVcnyZX7MqCoCfcyxIrVE9iOQruRaWPrQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5/go.mod h1:5ZXesEuy/QcO0WUnt+4sDkxhdXRHTu2yG0uCSH8B6os= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 h1:M/1u4HBpwLuMtjlxuI2y6HoVLzF5e2mfxHCg7ZVMYmk= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12/go.mod h1:kcfd+eTdEi/40FIbLq4Hif3XMXnl5b/+t/KTfLt9xIk= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -46,8 +80,9 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -66,8 +101,11 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84= +github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -83,8 +121,10 @@ github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= -github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A= github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -95,8 +135,8 @@ github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXym github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng= github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= -github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo= -github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/go-resty/resty/v2 v2.13.1 h1:x+LHXBI2nMB1vqndymf26quycC4aggYJ7DECYbiz03g= +github.com/go-resty/resty/v2 v2.13.1/go.mod h1:GznXlLxkq6Nh4sU59rPmUw3VtgpO3aS96ORAI6Q7d+0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -129,8 +169,9 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54= @@ -142,10 +183,11 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -159,15 +201,24 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= +github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= +github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= +github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg= +github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/infisical/go-sdk v0.1.9 h1:o9LUj0Tyn6OHusTEKEKQ4+PulJViAxgOrFa+SlwGJFc= +github.com/infisical/go-sdk v0.1.9/go.mod h1:vHTDVw3k+wfStXab513TGk1n53kaKF2xgLqpw/xvtl4= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= @@ -257,13 +308,19 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -274,10 +331,23 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= @@ -290,8 +360,9 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -353,6 +424,7 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= @@ -360,16 +432,18 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg= golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -382,6 +456,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -426,14 +502,16 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -443,13 +521,14 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -517,14 +596,14 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.183.0 h1:PNMeRDwo1pJdgNcFQ9GstuLe/noWKIc89pRWRLMvLwE= +google.golang.org/api v0.183.0/go.mod h1:q43adC5/pHoSZTx5h2mSmdF7NcyfW9JuDyIOJAgS9ZQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -555,6 +634,10 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e h1:SkdGTrROJl2jRGT/Fxv5QUf9jtdKCQh4KQJXbXVLAi0= +google.golang.org/genproto/googleapis/api v0.0.0-20240521202816-d264139d666e/go.mod h1:LweJcLbyVij6rCex8YunD8DYR5VDonap/jYl3ZRxcIU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -567,6 +650,9 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -579,8 +665,8 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/k8-operator/packages/crypto/crypto.go b/k8-operator/packages/crypto/crypto.go index e8633e273..810382af1 100644 --- a/k8-operator/packages/crypto/crypto.go +++ b/k8-operator/packages/crypto/crypto.go @@ -3,6 +3,8 @@ package crypto import ( "crypto/aes" "crypto/cipher" + "fmt" + "hash/crc32" "golang.org/x/crypto/nacl/box" ) @@ -33,3 +35,8 @@ func DecryptAsymmetric(ciphertext []byte, nonce []byte, publicKey []byte, privat plainTextToReturn, _ := box.Open(nil, ciphertext, (*[24]byte)(nonce), (*[32]byte)(publicKey), (*[32]byte)(privateKey)) return plainTextToReturn } + +func ComputeEtag(data []byte) string { + crc := crc32.ChecksumIEEE(data) + return fmt.Sprintf(`W/"secrets-%d-%08X"`, len(data), crc) +} diff --git a/k8-operator/packages/util/auth.go b/k8-operator/packages/util/auth.go new file mode 100644 index 000000000..d3ee0ce3b --- /dev/null +++ b/k8-operator/packages/util/auth.go @@ -0,0 +1,34 @@ +package util + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func GetServiceAccountToken(k8sClient client.Client, namespace string, serviceAccountName string) (string, error) { + + serviceAccount := &corev1.ServiceAccount{} + err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: serviceAccountName, Namespace: namespace}, serviceAccount) + if err != nil { + return "", err + } + + if len(serviceAccount.Secrets) == 0 { + return "", fmt.Errorf("no secrets found for service account %s", serviceAccountName) + } + + secretName := serviceAccount.Secrets[0].Name + + secret := &corev1.Secret{} + err = k8sClient.Get(context.TODO(), client.ObjectKey{Name: secretName, Namespace: namespace}, secret) + if err != nil { + return "", err + } + + token := secret.Data["token"] + + return string(token), nil +} diff --git a/k8-operator/packages/util/machine-identity-token.go b/k8-operator/packages/util/machine-identity-token.go deleted file mode 100644 index 055e869ad..000000000 --- a/k8-operator/packages/util/machine-identity-token.go +++ /dev/null @@ -1,170 +0,0 @@ -package util - -import ( - "fmt" - "os" - "sync" - "time" - - "github.com/Infisical/infisical/k8-operator/packages/api" - "github.com/go-resty/resty/v2" -) - -type MachineIdentityToken struct { - accessTokenTTL time.Duration - accessTokenMaxTTL time.Duration - accessTokenFetchedTime time.Time - accessTokenRefreshedTime time.Time - - mutex sync.Mutex - - accessToken string - clientSecret string - clientId string -} - -func NewMachineIdentityToken(clientId string, clientSecret string) *MachineIdentityToken { - - token := MachineIdentityToken{ - clientSecret: clientSecret, - clientId: clientId, - } - - go token.HandleTokenLifecycle() - - return &token -} - -func (t *MachineIdentityToken) HandleTokenLifecycle() error { - - for { - accessTokenMaxTTLExpiresInTime := t.accessTokenFetchedTime.Add(t.accessTokenMaxTTL - (5 * time.Second)) - accessTokenRefreshedTime := t.accessTokenRefreshedTime - - if accessTokenRefreshedTime.IsZero() { - accessTokenRefreshedTime = t.accessTokenFetchedTime - } - - nextAccessTokenExpiresInTime := accessTokenRefreshedTime.Add(t.accessTokenTTL - (5 * time.Second)) - - if t.accessTokenFetchedTime.IsZero() && t.accessTokenRefreshedTime.IsZero() { - // case: init login to get access token - fmt.Println("\nInfisical Authentication: attempting to authenticate...") - err := t.FetchNewAccessToken() - if err != nil { - fmt.Printf("\nInfisical Authentication: unable to authenticate universal auth because %v. Will retry in 30 seconds", err) - - // wait a bit before trying again - time.Sleep((30 * time.Second)) - continue - } - } else if time.Now().After(accessTokenMaxTTLExpiresInTime) { - fmt.Printf("\nInfisical Authentication: machine identity access token has reached max ttl, attempting to re authenticate...") - err := t.FetchNewAccessToken() - if err != nil { - fmt.Printf("\nInfisical Authentication: unable to authenticate universal auth because %v. Will retry in 30 seconds", err) - - // wait a bit before trying again - time.Sleep((30 * time.Second)) - continue - } - } else { - err := t.RefreshAccessToken() - if err != nil { - fmt.Printf("\nInfisical Authentication: unable to refresh universal auth token because %v. Will retry in 30 seconds", err) - - // wait a bit before trying again - time.Sleep((30 * time.Second)) - continue - } - } - - if accessTokenRefreshedTime.IsZero() { - accessTokenRefreshedTime = t.accessTokenFetchedTime - } else { - accessTokenRefreshedTime = t.accessTokenRefreshedTime - } - - nextAccessTokenExpiresInTime = accessTokenRefreshedTime.Add(t.accessTokenTTL - (5 * time.Second)) - accessTokenMaxTTLExpiresInTime = t.accessTokenFetchedTime.Add(t.accessTokenMaxTTL - (5 * time.Second)) - - if nextAccessTokenExpiresInTime.After(accessTokenMaxTTLExpiresInTime) { - // case: Refreshed so close that the next refresh would occur beyond max ttl (this is because currently, token renew tries to add +access-token-ttl amount of time) - // example: access token ttl is 11 sec and max ttl is 30 sec. So it will start with 11 seconds, then 22 seconds but the next time you call refresh it would try to extend it to 33 but max ttl only allows 30, so the token will be valid until 30 before we need to reauth - time.Sleep(t.accessTokenTTL - nextAccessTokenExpiresInTime.Sub(accessTokenMaxTTLExpiresInTime)) - } else { - time.Sleep(t.accessTokenTTL - (5 * time.Second)) - } - } -} - -func (t *MachineIdentityToken) RefreshAccessToken() error { - httpClient := resty.New() - httpClient.SetRetryCount(10000). - SetRetryMaxWaitTime(20 * time.Second). - SetRetryWaitTime(5 * time.Second) - - accessToken, err := t.GetToken() - - if err != nil { - return err - } - - response, err := api.CallUniversalMachineIdentityRefreshAccessToken(api.MachineIdentityUniversalAuthRefreshRequest{AccessToken: accessToken}) - if err != nil { - return err - } - - accessTokenTTL := time.Duration(response.ExpiresIn * int(time.Second)) - accessTokenMaxTTL := time.Duration(response.AccessTokenMaxTTL * int(time.Second)) - t.accessTokenRefreshedTime = time.Now() - - t.SetToken(response.AccessToken, accessTokenTTL, accessTokenMaxTTL) - - return nil -} - -// Fetches a new access token using client credentials -func (t *MachineIdentityToken) FetchNewAccessToken() error { - - loginResponse, err := api.CallUniversalMachineIdentityLogin(api.MachineIdentityUniversalAuthLoginRequest{ - ClientId: t.clientId, - ClientSecret: t.clientSecret, - }) - if err != nil { - return err - } - - accessTokenTTL := time.Duration(loginResponse.ExpiresIn * int(time.Second)) - accessTokenMaxTTL := time.Duration(loginResponse.AccessTokenMaxTTL * int(time.Second)) - - if accessTokenTTL <= time.Duration(5)*time.Second { - fmt.Println("\nInfisical Authentication: At this time, k8 operator does not support refresh of tokens with 5 seconds or less ttl. Please increase access token ttl and try again") - os.Exit(1) - } - - t.accessTokenFetchedTime = time.Now() - t.SetToken(loginResponse.AccessToken, accessTokenTTL, accessTokenMaxTTL) - - return nil -} - -func (t *MachineIdentityToken) SetToken(token string, accessTokenTTL time.Duration, accessTokenMaxTTL time.Duration) { - t.mutex.Lock() - defer t.mutex.Unlock() - - t.accessToken = token - t.accessTokenTTL = accessTokenTTL - t.accessTokenMaxTTL = accessTokenMaxTTL -} - -func (t *MachineIdentityToken) GetToken() (string, error) { - t.mutex.Lock() - defer t.mutex.Unlock() - - if t.accessToken == "" { - return "", fmt.Errorf("no machine identity access token available") - } - - return t.accessToken, nil -} diff --git a/k8-operator/packages/util/secrets.go b/k8-operator/packages/util/secrets.go index ee0666ea8..c46617a23 100644 --- a/k8-operator/packages/util/secrets.go +++ b/k8-operator/packages/util/secrets.go @@ -12,6 +12,7 @@ import ( "github.com/Infisical/infisical/k8-operator/packages/crypto" "github.com/Infisical/infisical/k8-operator/packages/model" "github.com/go-resty/resty/v2" + infisical "github.com/infisical/go-sdk" ) type DecodedSymmetricEncryptionDetails = struct { @@ -51,29 +52,26 @@ func GetServiceTokenDetails(infisicalToken string) (api.GetServiceTokenDetailsRe return serviceTokenDetails, nil } -func GetPlainTextSecretsViaUniversalAuth(accessToken string, etag string, secretScope v1alpha1.MachineIdentityScopeInWorkspace) ([]model.SingleEnvironmentVariable, model.RequestUpdateUpdateDetails, error) { +func GetPlainTextSecretsViaMachineIdentity(infisicalClient infisical.InfisicalClientInterface, etag string, secretScope v1alpha1.MachineIdentityScopeInWorkspace) ([]model.SingleEnvironmentVariable, model.RequestUpdateUpdateDetails, error) { - httpClient := resty.New() - httpClient.SetAuthScheme("Bearer") - httpClient.SetAuthToken(accessToken) - - secretsResponse, err := api.CallGetDecryptedSecretsV3(httpClient, api.GetDecryptedSecretsV3Request{ + secrets, err := infisicalClient.Secrets().List(infisical.ListSecretsOptions{ ProjectSlug: secretScope.ProjectSlug, Environment: secretScope.EnvSlug, Recursive: secretScope.Recursive, SecretPath: secretScope.SecretsPath, + IncludeImports: true, ExpandSecretReferences: true, - ETag: etag, }) if err != nil { return nil, model.RequestUpdateUpdateDetails{}, err } - var secrets []model.SingleEnvironmentVariable + var environmentVariables []model.SingleEnvironmentVariable - for _, secret := range secretsResponse.Secrets { - secrets = append(secrets, model.SingleEnvironmentVariable{ + for _, secret := range secrets { + + environmentVariables = append(environmentVariables, model.SingleEnvironmentVariable{ Key: secret.SecretKey, Value: secret.SecretValue, Type: secret.Type, @@ -81,15 +79,11 @@ func GetPlainTextSecretsViaUniversalAuth(accessToken string, etag string, secret }) } - // No need to do expansion for Machine Identity auth as this is handled on server-side. - mergedSecrets := MergeRawImportedSecrets(secrets, secretsResponse.Imports) - if err != nil { - return nil, model.RequestUpdateUpdateDetails{}, err - } + newEtag := crypto.ComputeEtag([]byte(fmt.Sprintf("%v", environmentVariables))) - return mergedSecrets, model.RequestUpdateUpdateDetails{ - Modified: secretsResponse.Modified, - ETag: secretsResponse.ETag, + return environmentVariables, model.RequestUpdateUpdateDetails{ + Modified: etag != newEtag, + ETag: newEtag, }, nil }