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 2086601a8..b6d698af4 100644 --- a/.github/workflows/check-api-for-breaking-changes.yml +++ b/.github/workflows/check-api-for-breaking-changes.yml @@ -35,11 +35,12 @@ 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 JWT_AUTH_SECRET: something-random + ENCRYPTION_KEY: 4bnfe4e407b8921c104518903515b218 - uses: actions/setup-go@v5 with: go-version: '1.21.5' @@ -73,4 +74,4 @@ jobs: run: | docker-compose -f "docker-compose.dev.yml" down docker stop infisical-api - docker remove infisical-api \ No newline at end of file + docker remove infisical-api 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 737067534..8ffe7e3de 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -1,7 +1,7 @@ ARG POSTHOG_HOST=https://app.posthog.com ARG POSTHOG_API_KEY=posthog-api-key ARG INTERCOM_ID=intercom-id -ARG SAML_ORG_SLUG=saml-org-slug-default +ARG CAPTCHA_SITE_KEY=captcha-site-key FROM node:20-alpine AS base @@ -36,8 +36,8 @@ ARG INTERCOM_ID ENV NEXT_PUBLIC_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION ENV NEXT_PUBLIC_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION -ARG SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG +ARG CAPTCHA_SITE_KEY +ENV NEXT_PUBLIC_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY # Build RUN npm run build @@ -55,6 +55,7 @@ VOLUME /app/.next/cache/images COPY --chown=non-root-user:nodejs --chmod=555 frontend/scripts ./scripts COPY --from=frontend-builder /app/public ./public RUN chown non-root-user:nodejs ./public/data + COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/standalone ./ COPY --from=frontend-builder --chown=non-root-user:nodejs /app/.next/static ./.next/static @@ -93,9 +94,18 @@ RUN mkdir frontend-build # Production stage FROM base AS production +RUN apk add --upgrade --no-cache ca-certificates RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 non-root-user +# Give non-root-user permission to update SSL certs +RUN chown -R non-root-user /etc/ssl/certs +RUN chown non-root-user /etc/ssl/certs/ca-certificates.crt +RUN chmod -R u+rwx /etc/ssl/certs +RUN chmod u+rw /etc/ssl/certs/ca-certificates.crt +RUN chown non-root-user /usr/sbin/update-ca-certificates +RUN chmod u+rx /usr/sbin/update-ca-certificates + ## set pre baked keys ARG POSTHOG_API_KEY ENV NEXT_PUBLIC_POSTHOG_API_KEY=$POSTHOG_API_KEY \ @@ -103,9 +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 SAML_ORG_SLUG -ENV NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG \ - BAKED_NEXT_PUBLIC_SAML_ORG_SLUG=$SAML_ORG_SLUG +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..e3e0ae521 100644 --- a/README.md +++ b/README.md @@ -48,25 +48,26 @@ ## Introduction -**[Infisical](https://infisical.com)** is the open source secret management platform that teams use to centralize their secrets like API keys, database credentials, and configurations. +**[Infisical](https://infisical.com)** is the open source secret management platform that teams use to centralize their application configuration and secrets like API keys and database credentials as well as manage their internal PKI. -We're on a mission to make secret management more accessible to everyone, not just security teams, and that means redesigning the entire developer experience from ground up. +We're on a mission to make security tooling more accessible to everyone, not just security teams, and that means redesigning the entire developer experience from ground up. ## Features -- **[User-friendly dashboard](https://infisical.com/docs/documentation/platform/project)** to manage secrets across projects and environments (e.g. development, production, etc.). -- **[Client SDKs](https://infisical.com/docs/sdks/overview)** to fetch secrets for your apps and infrastructure on demand. -- **[Infisical CLI](https://infisical.com/docs/cli/overview)** to fetch and inject secrets into any framework in local development and CI/CD. -- **[Infisical API](https://infisical.com/docs/api-reference/overview/introduction)** to perform CRUD operation on secrets, users, projects, and any other resource in Infisical. -- **[Native integrations](https://infisical.com/docs/integrations/overview)** with platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more. +- **[User-friendly dashboard](https://infisical.com/docs/documentation/platform/project)** to manage secrets across projects and environments (e.g. development, production, etc.). +- **[Client SDKs](https://infisical.com/docs/sdks/overview)** to fetch secrets for your apps and infrastructure on demand. +- **[Infisical CLI](https://infisical.com/docs/cli/overview)** to fetch and inject secrets into any framework in local development and CI/CD. +- **[Infisical API](https://infisical.com/docs/api-reference/overview/introduction)** to perform CRUD operation on secrets, users, projects, and any other resource in Infisical. +- **[Native integrations](https://infisical.com/docs/integrations/overview)** with platforms like [GitHub](https://infisical.com/docs/integrations/cicd/githubactions), [Vercel](https://infisical.com/docs/integrations/cloud/vercel), [AWS](https://infisical.com/docs/integrations/cloud/aws-secret-manager), and tools like [Terraform](https://infisical.com/docs/integrations/frameworks/terraform), [Ansible](https://infisical.com/docs/integrations/platforms/ansible), and more. - **[Infisical Kubernetes operator](https://infisical.com/docs/documentation/getting-started/kubernetes)** to managed secrets in k8s, automatically reload deployments, and more. -- **[Infisical Agent](https://infisical.com/docs/infisical-agent/overview)** to inject secrets into your applications without modifying any code logic. +- **[Infisical Agent](https://infisical.com/docs/infisical-agent/overview)** to inject secrets into your applications without modifying any code logic. - **[Self-hosting and on-prem](https://infisical.com/docs/self-hosting/overview)** to get complete control over your data. -- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)** to version every secret and project state. -- **[Audit logs](https://infisical.com/docs/documentation/platform/audit-logs)** to record every action taken in a project. -- **[Role-based Access Controls](https://infisical.com/docs/documentation/platform/role-based-access-controls)** to create permission sets on any resource in Infisica and assign those to user or machine identities. +- **[Secret versioning](https://infisical.com/docs/documentation/platform/secret-versioning)** and **[Point-in-Time Recovery](https://infisical.com/docs/documentation/platform/pit-recovery)** to version every secret and project state. +- **[Audit logs](https://infisical.com/docs/documentation/platform/audit-logs)** to record every action taken in a project. +- **[Role-based Access Controls](https://infisical.com/docs/documentation/platform/role-based-access-controls)** to create permission sets on any resource in Infisica and assign those to user or machine identities. - **[Simple on-premise deployments](https://infisical.com/docs/self-hosting/overview)** to AWS, Digital Ocean, and more. -- **[Secret Scanning and Leak Prevention](https://infisical.com/docs/cli/scanning-overview)** to prevent secrets from leaking to git. +- **[Internal PKI](https://infisical.com/docs/documentation/platform/pki/private-ca)** to create Private CA hierarchies and start issuing and managing X.509 digital certificates. +- **[Secret Scanning and Leak Prevention](https://infisical.com/docs/cli/scanning-overview)** to prevent secrets from leaking to git. And much more. @@ -74,9 +75,9 @@ And much more. Check out the [Quickstart Guides](https://infisical.com/docs/getting-started/introduction) -| Use Infisical Cloud | Deploy Infisical on premise | -| ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| The fastest and most reliable way to
get started with Infisical is signing up
for free to [Infisical Cloud](https://app.infisical.com/login). |
View all [deployment options](https://infisical.com/docs/self-hosting/overview) | +| Use Infisical Cloud | Deploy Infisical on premise | +| ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| The fastest and most reliable way to
get started with Infisical is signing up
for free to [Infisical Cloud](https://app.infisical.com/login). |
View all [deployment options](https://infisical.com/docs/self-hosting/overview) | ### Run Infisical locally @@ -85,13 +86,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/e2e-test/mocks/keystore.ts b/backend/e2e-test/mocks/keystore.ts index c85244129..05753995c 100644 --- a/backend/e2e-test/mocks/keystore.ts +++ b/backend/e2e-test/mocks/keystore.ts @@ -1,4 +1,5 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { Lock } from "@app/lib/red-lock"; export const mockKeyStore = (): TKeyStoreFactory => { const store: Record = {}; @@ -25,6 +26,12 @@ export const mockKeyStore = (): TKeyStoreFactory => { }, incrementBy: async () => { return 1; - } + }, + acquireLock: () => { + return Promise.resolve({ + release: () => {} + }) as Promise; + }, + waitTillReady: async () => {} }; }; diff --git a/backend/package-lock.json b/backend/package-lock.json index cdaee75f4..8bd4a98a0 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", @@ -51,7 +54,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.7", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.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", @@ -6295,12 +6459,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -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", @@ -7942,9 +8115,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -10290,9 +10463,10 @@ } }, "node_modules/mysql2": { - "version": "3.9.7", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.7.tgz", - "integrity": "sha512-KnJT8vYRcNAZv73uf9zpXqNbvBG7DJrs+1nACsjZP1HMJ1TgXEy8wnNilXAn/5i57JizXKtrUtwDB7HxT9DDpw==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz", + "integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==", + "license": "MIT", "dependencies": { "denque": "^2.1.0", "generate-function": "^2.3.1", @@ -11701,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", @@ -11882,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", @@ -13665,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 85538d45a..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", @@ -112,7 +115,7 @@ "libsodium-wrappers": "^0.7.13", "lodash.isequal": "^4.5.0", "ms": "^2.1.3", - "mysql2": "^3.9.7", + "mysql2": "^3.9.8", "nanoid": "^5.0.4", "nodemailer": "^6.9.9", "ora": "^7.0.1", diff --git a/backend/scripts/generate-schema-types.ts b/backend/scripts/generate-schema-types.ts index 8c913991f..43984ecfa 100644 --- a/backend/scripts/generate-schema-types.ts +++ b/backend/scripts/generate-schema-types.ts @@ -35,6 +35,8 @@ const getZodPrimitiveType = (type: string) => { return "z.coerce.number()"; case "text": return "z.string()"; + case "bytea": + return "zodBuffer"; default: throw new Error(`Invalid type: ${type}`); } @@ -96,10 +98,15 @@ const main = async () => { const columnNames = Object.keys(columns); let schema = ""; + const zodImportSet = new Set(); for (let colNum = 0; colNum < columnNames.length; colNum++) { const columnName = columnNames[colNum]; const colInfo = columns[columnName]; let ztype = getZodPrimitiveType(colInfo.type); + if (["zodBuffer"].includes(ztype)) { + zodImportSet.add(ztype); + } + // don't put optional on id if (colInfo.defaultValue && columnName !== "id") { const { defaultValue } = colInfo; @@ -121,6 +128,8 @@ const main = async () => { .split("_") .reduce((prev, curr) => prev + `${curr.at(0)?.toUpperCase()}${curr.slice(1).toLowerCase()}`, ""); + const zodImports = Array.from(zodImportSet); + // the insert and update are changed to zod input type to use default cases writeFileSync( path.join(__dirname, "../src/db/schemas", `${dashcase}.ts`), @@ -131,6 +140,8 @@ const main = async () => { import { z } from "zod"; +${zodImports.length ? `import { ${zodImports.join(",")} } from \"@app/lib/zod\";` : ""} + import { TImmutableDBKeys } from "./models"; export const ${pascalCase}Schema = z.object({${schema}}); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 4776e26dc..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,10 +31,13 @@ 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"; import { TIdentityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { TIdentityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { TIdentityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { TIdentityKubernetesAuthServiceFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-service"; import { TIdentityProjectServiceFactory } from "@app/services/identity-project/identity-project-service"; @@ -51,6 +56,8 @@ import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { TSecretReplicationServiceFactory } from "@app/services/secret-replication/secret-replication-service"; +import { TSecretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { TSecretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { TServiceTokenServiceFactory } from "@app/services/service-token/service-token-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; @@ -106,6 +113,7 @@ declare module "fastify" { projectKey: TProjectKeyServiceFactory; projectRole: TProjectRoleServiceFactory; secret: TSecretServiceFactory; + secretReplication: TSecretReplicationServiceFactory; secretTag: TSecretTagServiceFactory; secretImport: TSecretImportServiceFactory; projectBot: TProjectBotServiceFactory; @@ -121,6 +129,7 @@ declare module "fastify" { identityKubernetesAuth: TIdentityKubernetesAuthServiceFactory; identityGcpAuth: TIdentityGcpAuthServiceFactory; identityAwsAuth: TIdentityAwsAuthServiceFactory; + identityAzureAuth: TIdentityAzureAuthServiceFactory; accessApprovalPolicy: TAccessApprovalPolicyServiceFactory; accessApprovalRequest: TAccessApprovalRequestServiceFactory; secretApprovalPolicy: TSecretApprovalPolicyServiceFactory; @@ -132,6 +141,9 @@ declare module "fastify" { ldap: TLdapConfigServiceFactory; auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; + certificate: TCertificateServiceFactory; + certificateAuthority: TCertificateAuthorityServiceFactory; + certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; @@ -141,6 +153,8 @@ declare module "fastify" { dynamicSecretLease: TDynamicSecretLeaseServiceFactory; 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 291197b0b..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, @@ -62,6 +83,9 @@ import { TIdentityAwsAuths, TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate, + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate, TIdentityGcpAuths, TIdentityGcpAuthsInsert, TIdentityGcpAuthsUpdate, @@ -95,6 +119,15 @@ import { TIntegrations, TIntegrationsInsert, TIntegrationsUpdate, + TKmsKeys, + TKmsKeysInsert, + TKmsKeysUpdate, + TKmsKeyVersions, + TKmsKeyVersionsInsert, + TKmsKeyVersionsUpdate, + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate, TLdapConfigs, TLdapConfigsInsert, TLdapConfigsUpdate, @@ -137,6 +170,9 @@ import { TProjectUserMembershipRoles, TProjectUserMembershipRolesInsert, TProjectUserMembershipRolesUpdate, + TRateLimit, + TRateLimitInsert, + TRateLimitUpdate, TSamlConfigs, TSamlConfigsInsert, TSamlConfigsUpdate, @@ -173,6 +209,9 @@ import { TSecretImports, TSecretImportsInsert, TSecretImportsUpdate, + TSecretReferences, + TSecretReferencesInsert, + TSecretReferencesUpdate, TSecretRotationOutputs, TSecretRotationOutputsInsert, TSecretRotationOutputsUpdate, @@ -183,6 +222,9 @@ import { TSecretScanningGitRisks, TSecretScanningGitRisksInsert, TSecretScanningGitRisksUpdate, + TSecretSharing, + TSecretSharingInsert, + TSecretSharingUpdate, TSecretsInsert, TSecretSnapshotFolders, TSecretSnapshotFoldersInsert, @@ -234,12 +276,42 @@ import { TWebhooksInsert, TWebhooksUpdate } from "@app/db/schemas"; -import { TSecretReferences, TSecretReferencesInsert, TSecretReferencesUpdate } from "@app/db/schemas/secret-references"; 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, @@ -325,6 +397,8 @@ declare module "knex/types/tables" { TSecretFolderVersionsInsert, TSecretFolderVersionsUpdate >; + [TableName.SecretSharing]: Knex.CompositeTableType; + [TableName.RateLimit]: Knex.CompositeTableType; [TableName.SecretTag]: Knex.CompositeTableType; [TableName.SecretImport]: Knex.CompositeTableType; [TableName.Integration]: Knex.CompositeTableType; @@ -356,6 +430,11 @@ declare module "knex/types/tables" { TIdentityAwsAuthsInsert, TIdentityAwsAuthsUpdate >; + [TableName.IdentityAzureAuth]: Knex.CompositeTableType< + TIdentityAzureAuths, + TIdentityAzureAuthsInsert, + TIdentityAzureAuthsUpdate + >; [TableName.IdentityUaClientSecret]: Knex.CompositeTableType< TIdentityUaClientSecrets, TIdentityUaClientSecretsInsert, @@ -502,5 +581,13 @@ declare module "knex/types/tables" { TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionUpdate >; + // KMS service + [TableName.KmsServerRootConfig]: Knex.CompositeTableType< + TKmsRootConfig, + TKmsRootConfigInsert, + TKmsRootConfigUpdate + >; + [TableName.KmsKey]: Knex.CompositeTableType; + [TableName.KmsKeyVersion]: Knex.CompositeTableType; } } diff --git a/backend/src/db/migrations/20240527073740_identity-azure-auth.ts b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts new file mode 100644 index 000000000..3d91b2f9c --- /dev/null +++ b/backend/src/db/migrations/20240527073740_identity-azure-auth.ts @@ -0,0 +1,29 @@ +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.IdentityAzureAuth))) { + await knex.schema.createTable(TableName.IdentityAzureAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.bigInteger("accessTokenTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenMaxTTL").defaultTo(7200).notNullable(); + t.bigInteger("accessTokenNumUsesLimit").defaultTo(0).notNullable(); + t.jsonb("accessTokenTrustedIps").notNullable(); + t.timestamps(true, true, true); + t.uuid("identityId").notNullable().unique(); + t.foreign("identityId").references("id").inTable(TableName.Identity).onDelete("CASCADE"); + t.string("tenantId").notNullable(); + t.string("resource").notNullable(); + t.string("allowedServicePrincipalIds").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.IdentityAzureAuth); + await dropOnUpdateTrigger(knex, TableName.IdentityAzureAuth); +} diff --git a/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts new file mode 100644 index 000000000..2b2ecd783 --- /dev/null +++ b/backend/src/db/migrations/20240528153905_add-user-account-mfa-locking.ts @@ -0,0 +1,43 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (!hasConsecutiveFailedMfaAttempts) { + t.integer("consecutiveFailedMfaAttempts").defaultTo(0); + } + + if (!hasIsLocked) { + t.boolean("isLocked").defaultTo(false); + } + + if (!hasTemporaryLockDateEnd) { + t.dateTime("temporaryLockDateEnd").nullable(); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasConsecutiveFailedMfaAttempts = await knex.schema.hasColumn(TableName.Users, "consecutiveFailedMfaAttempts"); + const hasIsLocked = await knex.schema.hasColumn(TableName.Users, "isLocked"); + const hasTemporaryLockDateEnd = await knex.schema.hasColumn(TableName.Users, "temporaryLockDateEnd"); + + await knex.schema.alterTable(TableName.Users, (t) => { + if (hasConsecutiveFailedMfaAttempts) { + t.dropColumn("consecutiveFailedMfaAttempts"); + } + + if (hasIsLocked) { + t.dropColumn("isLocked"); + } + + if (hasTemporaryLockDateEnd) { + t.dropColumn("temporaryLockDateEnd"); + } + }); +} diff --git a/backend/src/db/migrations/20240528190137_secret_sharing.ts b/backend/src/db/migrations/20240528190137_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240528190137_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts new file mode 100644 index 000000000..8d4322b5a --- /dev/null +++ b/backend/src/db/migrations/20240529060752_snap-shot-secret-index-secretversionid.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.index("secretVersionId"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSecretVersionIdExist = await knex.schema.hasColumn(TableName.SnapshotSecret, "secretVersionId"); + if (await knex.schema.hasTable(TableName.SnapshotSecret)) { + await knex.schema.alterTable(TableName.SnapshotSecret, (t) => { + if (doesSecretVersionIdExist) t.dropIndex("secretVersionId"); + }); + } +} diff --git a/backend/src/db/migrations/20240529203152_secret_sharing.ts b/backend/src/db/migrations/20240529203152_secret_sharing.ts new file mode 100644 index 000000000..c1eab2ea6 --- /dev/null +++ b/backend/src/db/migrations/20240529203152_secret_sharing.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretSharing))) { + await knex.schema.createTable(TableName.SecretSharing, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("encryptedValue").notNullable(); + t.text("iv").notNullable(); + t.text("tag").notNullable(); + t.text("hashedHex").notNullable(); + t.timestamp("expiresAt").notNullable(); + t.uuid("userId").notNullable(); + t.uuid("orgId").notNullable(); + t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.SecretSharing); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretSharing); +} diff --git a/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts new file mode 100644 index 000000000..e23d134db --- /dev/null +++ b/backend/src/db/migrations/20240530044702_universal-text-in-secret-sharing.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (!hasExpiresAfterViewsColumn) { + t.integer("expiresAfterViews"); + } + + if (hasSecretNameColumn) { + t.dropColumn("name"); + } + }); +} + +export async function down(knex: Knex): Promise { + const hasExpiresAfterViewsColumn = await knex.schema.hasColumn(TableName.SecretSharing, "expiresAfterViews"); + const hasSecretNameColumn = await knex.schema.hasColumn(TableName.SecretSharing, "name"); + + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasExpiresAfterViewsColumn) { + t.dropColumn("expiresAfterViews"); + } + + if (!hasSecretNameColumn) { + t.string("name").notNullable(); + } + }); +} diff --git a/backend/src/db/migrations/20240531220007_secret-replication.ts b/backend/src/db/migrations/20240531220007_secret-replication.ts new file mode 100644 index 000000000..ddb965df4 --- /dev/null +++ b/backend/src/db/migrations/20240531220007_secret-replication.ts @@ -0,0 +1,85 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication"); + const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn( + TableName.SecretImport, + "isReplicationSuccess" + ); + const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn( + TableName.SecretImport, + "replicationStatus" + ); + const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated"); + const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved"); + + if (await knex.schema.hasTable(TableName.SecretImport)) { + await knex.schema.alterTable(TableName.SecretImport, (t) => { + if (!doesSecretImportIsReplicationExist) t.boolean("isReplication").defaultTo(false); + if (!doesSecretImportIsReplicationSuccessExist) t.boolean("isReplicationSuccess").nullable(); + if (!doesSecretImportReplicationStatusExist) t.text("replicationStatus").nullable(); + if (!doesSecretImportLastReplicatedExist) t.datetime("lastReplicated").nullable(); + if (!doesSecretImportIsReservedExist) t.boolean("isReserved").defaultTo(false); + }); + } + + const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved"); + if (await knex.schema.hasTable(TableName.SecretFolder)) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + if (!doesSecretFolderReservedExist) t.boolean("isReserved").defaultTo(false); + }); + } + + const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn( + TableName.SecretApprovalRequest, + "isReplicated" + ); + if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) { + await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => { + if (!doesSecretApprovalRequestIsReplicatedExist) t.boolean("isReplicated"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesSecretImportIsReplicationExist = await knex.schema.hasColumn(TableName.SecretImport, "isReplication"); + const doesSecretImportIsReplicationSuccessExist = await knex.schema.hasColumn( + TableName.SecretImport, + "isReplicationSuccess" + ); + const doesSecretImportReplicationStatusExist = await knex.schema.hasColumn( + TableName.SecretImport, + "replicationStatus" + ); + const doesSecretImportLastReplicatedExist = await knex.schema.hasColumn(TableName.SecretImport, "lastReplicated"); + const doesSecretImportIsReservedExist = await knex.schema.hasColumn(TableName.SecretImport, "isReserved"); + + if (await knex.schema.hasTable(TableName.SecretImport)) { + await knex.schema.alterTable(TableName.SecretImport, (t) => { + if (doesSecretImportIsReplicationExist) t.dropColumn("isReplication"); + if (doesSecretImportIsReplicationSuccessExist) t.dropColumn("isReplicationSuccess"); + if (doesSecretImportReplicationStatusExist) t.dropColumn("replicationStatus"); + if (doesSecretImportLastReplicatedExist) t.dropColumn("lastReplicated"); + if (doesSecretImportIsReservedExist) t.dropColumn("isReserved"); + }); + } + + const doesSecretFolderReservedExist = await knex.schema.hasColumn(TableName.SecretFolder, "isReserved"); + if (await knex.schema.hasTable(TableName.SecretFolder)) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + if (doesSecretFolderReservedExist) t.dropColumn("isReserved"); + }); + } + + const doesSecretApprovalRequestIsReplicatedExist = await knex.schema.hasColumn( + TableName.SecretApprovalRequest, + "isReplicated" + ); + if (await knex.schema.hasTable(TableName.SecretApprovalRequest)) { + await knex.schema.alterTable(TableName.SecretApprovalRequest, (t) => { + if (doesSecretApprovalRequestIsReplicatedExist) t.dropColumn("isReplicated"); + }); + } +} diff --git a/backend/src/db/migrations/20240603075514_kms.ts b/backend/src/db/migrations/20240603075514_kms.ts new file mode 100644 index 000000000..3531682d5 --- /dev/null +++ b/backend/src/db/migrations/20240603075514_kms.ts @@ -0,0 +1,56 @@ +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.KmsServerRootConfig))) { + await knex.schema.createTable(TableName.KmsServerRootConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedRootKey").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + if (!(await knex.schema.hasTable(TableName.KmsKey))) { + await knex.schema.createTable(TableName.KmsKey, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.string("encryptionAlgorithm").notNullable(); + t.integer("version").defaultTo(1).notNullable(); + t.string("description"); + t.boolean("isDisabled").defaultTo(false); + t.boolean("isReserved").defaultTo(true); + t.string("projectId"); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("orgId"); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKey); + + if (!(await knex.schema.hasTable(TableName.KmsKeyVersion))) { + await knex.schema.createTable(TableName.KmsKeyVersion, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedKey").notNullable(); + t.integer("version").notNullable(); + t.uuid("kmsKeyId").notNullable(); + t.foreign("kmsKeyId").references("id").inTable(TableName.KmsKey).onDelete("CASCADE"); + }); + } + + await createOnUpdateTrigger(knex, TableName.KmsKeyVersion); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.KmsServerRootConfig); + await dropOnUpdateTrigger(knex, TableName.KmsServerRootConfig); + + await knex.schema.dropTableIfExists(TableName.KmsKeyVersion); + await dropOnUpdateTrigger(knex, TableName.KmsKeyVersion); + + await knex.schema.dropTableIfExists(TableName.KmsKey); + await dropOnUpdateTrigger(knex, TableName.KmsKey); +} diff --git a/backend/src/db/migrations/20240609133400_private-key-handoff.ts b/backend/src/db/migrations/20240609133400_private-key-handoff.ts new file mode 100644 index 000000000..9741c6029 --- /dev/null +++ b/backend/src/db/migrations/20240609133400_private-key-handoff.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const doesPasswordFieldExist = await knex.schema.hasColumn(TableName.UserEncryptionKey, "hashedPassword"); + const doesPrivateKeyFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKey" + ); + const doesPrivateKeyIVFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyIV" + ); + const doesPrivateKeyTagFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyTag" + ); + const doesPrivateKeyEncodingFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyEncoding" + ); + if (await knex.schema.hasTable(TableName.UserEncryptionKey)) { + await knex.schema.alterTable(TableName.UserEncryptionKey, (t) => { + if (!doesPasswordFieldExist) t.string("hashedPassword"); + if (!doesPrivateKeyFieldExist) t.text("serverEncryptedPrivateKey"); + if (!doesPrivateKeyIVFieldExist) t.text("serverEncryptedPrivateKeyIV"); + if (!doesPrivateKeyTagFieldExist) t.text("serverEncryptedPrivateKeyTag"); + if (!doesPrivateKeyEncodingFieldExist) t.text("serverEncryptedPrivateKeyEncoding"); + }); + } +} + +export async function down(knex: Knex): Promise { + const doesPasswordFieldExist = await knex.schema.hasColumn(TableName.UserEncryptionKey, "hashedPassword"); + const doesPrivateKeyFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKey" + ); + const doesPrivateKeyIVFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyIV" + ); + const doesPrivateKeyTagFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyTag" + ); + const doesPrivateKeyEncodingFieldExist = await knex.schema.hasColumn( + TableName.UserEncryptionKey, + "serverEncryptedPrivateKeyEncoding" + ); + if (await knex.schema.hasTable(TableName.UserEncryptionKey)) { + await knex.schema.alterTable(TableName.UserEncryptionKey, (t) => { + if (doesPasswordFieldExist) t.dropColumn("hashedPassword"); + if (doesPrivateKeyFieldExist) t.dropColumn("serverEncryptedPrivateKey"); + if (doesPrivateKeyIVFieldExist) t.dropColumn("serverEncryptedPrivateKeyIV"); + if (doesPrivateKeyTagFieldExist) t.dropColumn("serverEncryptedPrivateKeyTag"); + if (doesPrivateKeyEncodingFieldExist) t.dropColumn("serverEncryptedPrivateKeyEncoding"); + }); + } +} 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/migrations/20240614184133_make-secret-sharing-public.ts b/backend/src/db/migrations/20240614184133_make-secret-sharing-public.ts new file mode 100644 index 000000000..dc2756b74 --- /dev/null +++ b/backend/src/db/migrations/20240614184133_make-secret-sharing-public.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId"); + const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId"); + + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasOrgIdColumn) t.uuid("orgId").nullable().alter(); + if (hasUserIdColumn) t.uuid("userId").nullable().alter(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasOrgIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "orgId"); + const hasUserIdColumn = await knex.schema.hasColumn(TableName.SecretSharing, "userId"); + + if (await knex.schema.hasTable(TableName.SecretSharing)) { + await knex.schema.alterTable(TableName.SecretSharing, (t) => { + if (hasOrgIdColumn) t.uuid("orgId").notNullable().alter(); + if (hasUserIdColumn) t.uuid("userId").notNullable().alter(); + }); + } +} 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/identity-azure-auths.ts b/backend/src/db/schemas/identity-azure-auths.ts new file mode 100644 index 000000000..856f7b8f1 --- /dev/null +++ b/backend/src/db/schemas/identity-azure-auths.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 IdentityAzureAuthsSchema = z.object({ + id: z.string().uuid(), + accessTokenTTL: z.coerce.number().default(7200), + accessTokenMaxTTL: z.coerce.number().default(7200), + accessTokenNumUsesLimit: z.coerce.number().default(0), + accessTokenTrustedIps: z.unknown(), + createdAt: z.date(), + updatedAt: z.date(), + identityId: z.string().uuid(), + tenantId: z.string(), + resource: z.string(), + allowedServicePrincipalIds: z.string() +}); + +export type TIdentityAzureAuths = z.infer; +export type TIdentityAzureAuthsInsert = Omit, TImmutableDBKeys>; +export type TIdentityAzureAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index cffa4f492..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"; @@ -18,6 +25,7 @@ export * from "./groups"; export * from "./identities"; export * from "./identity-access-tokens"; export * from "./identity-aws-auths"; +export * from "./identity-azure-auths"; export * from "./identity-gcp-auths"; export * from "./identity-kubernetes-auths"; export * from "./identity-org-memberships"; @@ -29,6 +37,9 @@ export * from "./identity-universal-auths"; export * from "./incident-contacts"; export * from "./integration-auths"; export * from "./integrations"; +export * from "./kms-key-versions"; +export * from "./kms-keys"; +export * from "./kms-root-config"; export * from "./ldap-configs"; export * from "./ldap-group-maps"; export * from "./models"; @@ -44,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"; @@ -56,9 +68,11 @@ export * from "./secret-blind-indexes"; export * from "./secret-folder-versions"; export * from "./secret-folders"; export * from "./secret-imports"; +export * from "./secret-references"; export * from "./secret-rotation-outputs"; export * from "./secret-rotations"; export * from "./secret-scanning-git-risks"; +export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; export * from "./secret-snapshots"; diff --git a/backend/src/db/schemas/kms-key-versions.ts b/backend/src/db/schemas/kms-key-versions.ts new file mode 100644 index 000000000..52a8069df --- /dev/null +++ b/backend/src/db/schemas/kms-key-versions.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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeyVersionsSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + version: z.number(), + kmsKeyId: z.string().uuid() +}); + +export type TKmsKeyVersions = z.infer; +export type TKmsKeyVersionsInsert = Omit, TImmutableDBKeys>; +export type TKmsKeyVersionsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-keys.ts b/backend/src/db/schemas/kms-keys.ts new file mode 100644 index 000000000..503c270d9 --- /dev/null +++ b/backend/src/db/schemas/kms-keys.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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const KmsKeysSchema = z.object({ + id: z.string().uuid(), + encryptedKey: zodBuffer, + encryptionAlgorithm: z.string(), + version: z.number().default(1), + description: z.string().nullable().optional(), + isDisabled: z.boolean().default(false).nullable().optional(), + isReserved: z.boolean().default(true).nullable().optional(), + projectId: z.string().nullable().optional(), + orgId: z.string().uuid().nullable().optional() +}); + +export type TKmsKeys = z.infer; +export type TKmsKeysInsert = Omit, TImmutableDBKeys>; +export type TKmsKeysUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts new file mode 100644 index 000000000..d2c0edbc5 --- /dev/null +++ b/backend/src/db/schemas/kms-root-config.ts @@ -0,0 +1,19 @@ +// 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 KmsRootConfigSchema = z.object({ + id: z.string().uuid(), + encryptedRootKey: zodBuffer +}); + +export type TKmsRootConfig = z.infer; +export type TKmsRootConfigInsert = Omit, TImmutableDBKeys>; +export type TKmsRootConfigUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 28a6973b7..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", @@ -29,6 +37,7 @@ export enum TableName { ProjectKeys = "project_keys", Secret = "secrets", SecretReference = "secret_references", + SecretSharing = "secret_sharing", SecretBlindIndex = "secret_blind_indexes", SecretVersion = "secret_versions", SecretFolder = "secret_folders", @@ -47,6 +56,7 @@ export enum TableName { IdentityUniversalAuth = "identity_universal_auths", IdentityKubernetesAuth = "identity_kubernetes_auths", IdentityGcpAuth = "identity_gcp_auths", + IdentityAzureAuth = "identity_azure_auths", IdentityUaClientSecret = "identity_ua_client_secrets", IdentityAwsAuth = "identity_aws_auths", IdentityOrgMembership = "identity_org_memberships", @@ -79,7 +89,11 @@ export enum TableName { DynamicSecretLease = "dynamic_secret_leases", // junction tables with tags JnSecretTag = "secret_tag_junction", - SecretVersionTag = "secret_version_tag_junction" + SecretVersionTag = "secret_version_tag_junction", + // KMS Service + KmsServerRootConfig = "kms_root_config", + KmsKey = "kms_keys", + KmsKeyVersion = "kms_key_versions" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -149,5 +163,6 @@ export enum IdentityAuthMethod { Univeral = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } 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-approval-requests.ts b/backend/src/db/schemas/secret-approval-requests.ts index 6ee97fbb6..77ad370b7 100644 --- a/backend/src/db/schemas/secret-approval-requests.ts +++ b/backend/src/db/schemas/secret-approval-requests.ts @@ -18,7 +18,8 @@ export const SecretApprovalRequestsSchema = z.object({ statusChangeBy: z.string().uuid().nullable().optional(), committerId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isReplicated: z.boolean().nullable().optional() }); export type TSecretApprovalRequests = z.infer; diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index 0f9684d0e..ad43ed1ad 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -14,7 +14,8 @@ export const SecretFoldersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), envId: z.string().uuid(), - parentId: z.string().uuid().nullable().optional() + parentId: z.string().uuid().nullable().optional(), + isReserved: z.boolean().default(false).nullable().optional() }); export type TSecretFolders = z.infer; diff --git a/backend/src/db/schemas/secret-imports.ts b/backend/src/db/schemas/secret-imports.ts index 9d42d8da5..4bb1e669d 100644 --- a/backend/src/db/schemas/secret-imports.ts +++ b/backend/src/db/schemas/secret-imports.ts @@ -15,7 +15,12 @@ export const SecretImportsSchema = z.object({ position: z.number(), createdAt: z.date(), updatedAt: z.date(), - folderId: z.string().uuid() + folderId: z.string().uuid(), + isReplication: z.boolean().default(false).nullable().optional(), + isReplicationSuccess: z.boolean().nullable().optional(), + replicationStatus: z.string().nullable().optional(), + lastReplicated: z.date().nullable().optional(), + isReserved: z.boolean().default(false).nullable().optional() }); export type TSecretImports = z.infer; diff --git a/backend/src/db/schemas/secret-sharing.ts b/backend/src/db/schemas/secret-sharing.ts new file mode 100644 index 000000000..c8d938861 --- /dev/null +++ b/backend/src/db/schemas/secret-sharing.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 SecretSharingSchema = z.object({ + id: z.string().uuid(), + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.date(), + userId: z.string().uuid().nullable().optional(), + orgId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + expiresAfterViews: z.number().nullable().optional() +}); + +export type TSecretSharing = z.infer; +export type TSecretSharingInsert = Omit, TImmutableDBKeys>; +export type TSecretSharingUpdate = 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/user-encryption-keys.ts b/backend/src/db/schemas/user-encryption-keys.ts index 693b73b4c..fd9d21a9d 100644 --- a/backend/src/db/schemas/user-encryption-keys.ts +++ b/backend/src/db/schemas/user-encryption-keys.ts @@ -21,7 +21,12 @@ export const UserEncryptionKeysSchema = z.object({ tag: z.string(), salt: z.string(), verifier: z.string(), - userId: z.string().uuid() + userId: z.string().uuid(), + hashedPassword: z.string().nullable().optional(), + serverEncryptedPrivateKey: z.string().nullable().optional(), + serverEncryptedPrivateKeyIV: z.string().nullable().optional(), + serverEncryptedPrivateKeyTag: z.string().nullable().optional(), + serverEncryptedPrivateKeyEncoding: z.string().nullable().optional() }); export type TUserEncryptionKeys = z.infer; diff --git a/backend/src/db/schemas/users.ts b/backend/src/db/schemas/users.ts index d5a4d5b49..5134f3ee6 100644 --- a/backend/src/db/schemas/users.ts +++ b/backend/src/db/schemas/users.ts @@ -22,7 +22,11 @@ export const UsersSchema = z.object({ updatedAt: z.date(), isGhost: z.boolean().default(false), username: z.string(), - isEmailVerified: z.boolean().default(false).nullable().optional() + 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(), + 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/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts index 9a1a91672..58c6793d7 100644 --- a/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v1/identity-project-additional-privilege-router.ts @@ -5,10 +5,15 @@ import { z } from "zod"; import { IdentityProjectAdditionalPrivilegeTemporaryMode } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-types"; import { IDENTITY_ADDITIONAL_PRIVILEGE } from "@app/lib/api-docs"; +import { BadRequestError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; -import { ProjectPermissionSchema, SanitizedIdentityPrivilegeSchema } from "@app/server/routes/sanitizedSchemas"; +import { + ProjectPermissionSchema, + ProjectSpecificPrivilegePermissionSchema, + SanitizedIdentityPrivilegeSchema +} from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: FastifyZodProvider) => { @@ -39,7 +44,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional() }), response: { 200: z.object({ @@ -49,6 +59,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -57,7 +79,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: false, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -90,7 +112,12 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .optional() .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.slug), - permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions), + permissions: ProjectPermissionSchema.array() + .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.permissions) + .optional(), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.privilegePermission + ).optional(), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.CREATE.temporaryMode), @@ -111,6 +138,19 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const { permissions, privilegePermission } = req.body; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; + const privilege = await server.services.identityProjectAdditionalPrivilege.create({ actorId: req.permission.id, actor: req.permission.type, @@ -119,7 +159,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F ...req.body, slug: req.body.slug ? slugify(req.body.slug) : slugify(alphaNumericNanoId(12)), isTemporary: true, - permissions: JSON.stringify(packRules(req.body.permissions)) + permissions: JSON.stringify(packRules(permission)) }); return { privilege }; } @@ -156,13 +196,16 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.newSlug), permissions: ProjectPermissionSchema.array().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.permissions), + privilegePermission: ProjectSpecificPrivilegePermissionSchema.describe( + IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.privilegePermission + ).optional(), isTemporary: z.boolean().describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.isTemporary), temporaryMode: z .nativeEnum(IdentityProjectAdditionalPrivilegeTemporaryMode) .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryMode), temporaryRange: z .string() - .refine((val) => ms(val) > 0, "Temporary range must be a positive number") + .refine((val) => typeof val === "undefined" || ms(val) > 0, "Temporary range must be a positive number") .describe(IDENTITY_ADDITIONAL_PRIVILEGE.UPDATE.temporaryRange), temporaryAccessStartTime: z .string() @@ -179,7 +222,18 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const updatedInfo = req.body.privilegeDetails; + const { permissions, privilegePermission, ...updatedInfo } = req.body.privilegeDetails; + if (!permissions && !privilegePermission) { + throw new BadRequestError({ message: "Permission or privilegePermission must be provided" }); + } + + const permission = privilegePermission + ? privilegePermission.actions.map((action) => ({ + action, + subject: privilegePermission.subject, + conditions: privilegePermission.conditions + })) + : permissions!; const privilege = await server.services.identityProjectAdditionalPrivilege.updateBySlug({ actorId: req.permission.id, actor: req.permission.type, @@ -190,7 +244,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F projectSlug: req.body.projectSlug, data: { ...updatedInfo, - permissions: updatedInfo?.permissions ? JSON.stringify(packRules(updatedInfo.permissions)) : undefined + permissions: permission ? JSON.stringify(packRules(permission)) : undefined } }); return { privilege }; 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/ldap-router.ts b/backend/src/ee/routes/v1/ldap-router.ts index e146668c2..8cccbaac6 100644 --- a/backend/src/ee/routes/v1/ldap-router.ts +++ b/backend/src/ee/routes/v1/ldap-router.ts @@ -53,7 +53,7 @@ export const registerLdapRouter = async (server: FastifyZodProvider) => { // eslint-disable-next-line async (req: IncomingMessage, user, cb) => { try { - if (!user.email) throw new BadRequestError({ message: "Invalid request. Missing email." }); + if (!user.mail) throw new BadRequestError({ message: "Invalid request. Missing mail attribute on user." }); const ldapConfig = (req as unknown as FastifyRequest).ldapConfig as TLDAPConfig; let groups: { dn: string; cn: string }[] | undefined; diff --git a/backend/src/ee/routes/v1/org-role-router.ts b/backend/src/ee/routes/v1/org-role-router.ts index 380f61e23..6691032a8 100644 --- a/backend/src/ee/routes/v1/org-role-router.ts +++ b/backend/src/ee/routes/v1/org-role-router.ts @@ -23,7 +23,7 @@ export const registerOrgRoleRouter = async (server: FastifyZodProvider) => { .min(1) .trim() .refine( - (val) => !Object.keys(OrgMembershipRole).includes(val), + (val) => !Object.values(OrgMembershipRole).includes(val as OrgMembershipRole), "Please choose a different slug, the slug you have entered is reserved" ) .refine((v) => slugify(v) === v, { diff --git a/backend/src/ee/routes/v1/project-role-router.ts b/backend/src/ee/routes/v1/project-role-router.ts index bb4d2fa8e..69038a057 100644 --- a/backend/src/ee/routes/v1/project-role-router.ts +++ b/backend/src/ee/routes/v1/project-role-router.ts @@ -1,146 +1,232 @@ +import { packRules } from "@casl/ability/extra"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { ProjectMembershipRole, ProjectMembershipsSchema, ProjectRolesSchema } from "@app/db/schemas"; +import { PROJECT_ROLE } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { ProjectPermissionSchema, SanitizedRoleSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; export const registerProjectRoleRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", - url: "/:projectId/roles", + url: "/:projectSlug/roles", config: { rateLimit: writeLimit }, schema: { + description: "Create a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.CREATE.projectSlug) }), body: z.object({ - slug: z.string().trim(), - name: z.string().trim(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .min(1) + .refine( + (val) => !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid" + }) + .describe(PROJECT_ROLE.CREATE.slug), + name: z.string().min(1).trim().describe(PROJECT_ROLE.CREATE.name), + description: z.string().trim().optional().describe(PROJECT_ROLE.CREATE.description), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.CREATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.createRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.createRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "PATCH", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Update a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.UPDATE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.UPDATE.roleId) }), body: z.object({ - slug: z.string().trim().optional(), - name: z.string().trim().optional(), - description: z.string().trim().optional(), - permissions: z.any().array() + slug: z + .string() + .toLowerCase() + .trim() + .optional() + .describe(PROJECT_ROLE.UPDATE.slug) + .refine( + (val) => + typeof val === "undefined" || + !Object.values(ProjectMembershipRole).includes(val as ProjectMembershipRole), + "Please choose a different slug, the slug you have entered is reserved" + ) + .refine((val) => typeof val === "undefined" || slugify(val) === val, { + message: "Slug must be a valid" + }), + name: z.string().trim().optional().describe(PROJECT_ROLE.UPDATE.name), + permissions: ProjectPermissionSchema.array().describe(PROJECT_ROLE.UPDATE.permissions) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.updateRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.body, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.updateRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId, + data: { + ...req.body, + permissions: JSON.stringify(packRules(req.body.permissions)) + } + }); return { role }; } }); server.route({ method: "DELETE", - url: "/:projectId/roles/:roleId", + url: "/:projectSlug/roles/:roleId", config: { rateLimit: writeLimit }, schema: { + description: "Delete a project role", + security: [ + { + bearerAuth: [] + } + ], params: z.object({ - projectId: z.string().trim(), - roleId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.DELETE.projectSlug), + roleId: z.string().trim().describe(PROJECT_ROLE.DELETE.roleId) }), response: { 200: z.object({ - role: ProjectRolesSchema + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const role = await server.services.projectRole.deleteRole( - req.permission.type, - req.permission.id, - req.params.projectId, - req.params.roleId, - req.permission.authMethod, - req.permission.orgId - ); + const role = await server.services.projectRole.deleteRole({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleId: req.params.roleId + }); return { role }; } }); server.route({ method: "GET", - url: "/:projectId/roles", + url: "/:projectSlug/roles", + config: { + rateLimit: readLimit + }, + schema: { + description: "List project role", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim().describe(PROJECT_ROLE.LIST.projectSlug) + }), + response: { + 200: z.object({ + roles: ProjectRolesSchema.omit({ permissions: true }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const roles = await server.services.projectRole.listRoles({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug + }); + return { roles }; + } + }); + + server.route({ + method: "GET", + url: "/:projectSlug/roles/slug/:slug", config: { rateLimit: readLimit }, schema: { params: z.object({ - projectId: z.string().trim() + projectSlug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.projectSlug), + slug: z.string().trim().describe(PROJECT_ROLE.GET_ROLE_BY_SLUG.roleSlug) }), response: { 200: z.object({ - data: z.object({ - roles: ProjectRolesSchema.omit({ permissions: true }) - .merge(z.object({ permissions: z.unknown() })) - .array() - }) + role: SanitizedRoleSchema }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - const roles = await server.services.projectRole.listRoles( - req.permission.type, - req.permission.id, - req.params.projectId, - req.permission.authMethod, - req.permission.orgId - ); - return { data: { roles } }; + const role = await server.services.projectRole.getRoleBySlug({ + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actor: req.permission.type, + projectSlug: req.params.projectSlug, + roleSlug: req.params.slug + }); + return { role }; } }); diff --git a/backend/src/ee/routes/v1/project-router.ts b/backend/src/ee/routes/v1/project-router.ts index 9795aaf86..8639ef1a1 100644 --- a/backend/src/ee/routes/v1/project-router.ts +++ b/backend/src/ee/routes/v1/project-router.ts @@ -143,7 +143,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorAuthMethod: req.permission.authMethod, projectId: req.params.workspaceId, ...req.query, - startDate: req.query.endDate || getLastMidnightDateISO(), + endDate: req.query.endDate, + startDate: req.query.startDate || getLastMidnightDateISO(), auditLogActor: req.query.actor, actor: req.permission.type }); 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/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 2a9cc405d..b7204f72e 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -32,22 +32,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv }), response: { 200: z.object({ - approvals: SecretApprovalRequestsSchema.merge( - z.object({ - // secretPath: z.string(), - policy: z.object({ - id: z.string(), - name: z.string(), - approvals: z.number(), - approvers: z.string().array(), - secretPath: z.string().optional().nullable() - }), - commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), - environment: z.string(), - reviewers: z.object({ member: z.string(), status: z.string() }).array(), - approvers: z.string().array() - }) - ).array() + approvals: SecretApprovalRequestsSchema.extend({ + // secretPath: z.string(), + policy: z.object({ + id: z.string(), + name: z.string(), + approvals: z.number(), + approvers: z.string().array(), + secretPath: z.string().optional().nullable() + }), + commits: z.object({ op: z.string(), secretId: z.string().nullable().optional() }).array(), + environment: z.string(), + reviewers: z.object({ member: z.string(), status: z.string() }).array(), + approvers: z.string().array() + }).array() }) } }, diff --git a/backend/src/ee/services/audit-log/audit-log-queue.ts b/backend/src/ee/services/audit-log/audit-log-queue.ts index 6c563b573..f93b391a5 100644 --- a/backend/src/ee/services/audit-log/audit-log-queue.ts +++ b/backend/src/ee/services/audit-log/audit-log-queue.ts @@ -3,7 +3,6 @@ import { RawAxiosRequestHeaders } from "axios"; import { SecretKeyEncoding } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; -import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -113,35 +112,7 @@ export const auditLogQueueServiceFactory = ({ ); }); - queueService.start(QueueName.AuditLogPrune, async () => { - logger.info(`${QueueName.AuditLogPrune}: queue task started`); - await auditLogDAL.pruneAuditLog(); - logger.info(`${QueueName.AuditLogPrune}: queue task completed`); - }); - - // we do a repeat cron job in utc timezone at 12 Midnight each day - const startAuditLogPruneJob = async () => { - // clear previous job - await queueService.stopRepeatableJob( - QueueName.AuditLogPrune, - QueueJobs.AuditLogPrune, - { pattern: "0 0 * * *", utc: true }, - QueueName.AuditLogPrune // just a job id - ); - - await queueService.queue(QueueName.AuditLogPrune, QueueJobs.AuditLogPrune, undefined, { - delay: 5000, - jobId: QueueName.AuditLogPrune, - repeat: { pattern: "0 0 * * *", utc: true } - }); - }; - - queueService.listen(QueueName.AuditLogPrune, "failed", (err) => { - logger.error(err?.failedReason, `${QueueName.AuditLogPrune}: log pruning failed`); - }); - return { - pushToLog, - startAuditLogPruneJob + pushToLog }; }; 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 e512389d7..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 = { @@ -79,6 +80,10 @@ export enum EventType { ADD_IDENTITY_AWS_AUTH = "add-identity-aws-auth", UPDATE_IDENTITY_AWS_AUTH = "update-identity-aws-auth", GET_IDENTITY_AWS_AUTH = "get-identity-aws-auth", + LOGIN_IDENTITY_AZURE_AUTH = "login-identity-azure-auth", + ADD_IDENTITY_AZURE_AUTH = "add-identity-azure-auth", + UPDATE_IDENTITY_AZURE_AUTH = "update-identity-azure-auth", + GET_IDENTITY_AZURE_AUTH = "get-identity-azure-auth", CREATE_ENVIRONMENT = "create-environment", UPDATE_ENVIRONMENT = "update-environment", DELETE_ENVIRONMENT = "delete-environment", @@ -100,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 { @@ -572,6 +591,48 @@ interface GetIdentityAwsAuthEvent { }; } +interface LoginIdentityAzureAuthEvent { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + identityAzureAuthId: string; + identityAccessTokenId: string; + }; +} + +interface AddIdentityAzureAuthEvent { + type: EventType.ADD_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId: string; + resource: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: Array; + }; +} + +interface UpdateIdentityAzureAuthEvent { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + tenantId?: string; + resource?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: Array; + }; +} + +interface GetIdentityAzureAuthEvent { + type: EventType.GET_IDENTITY_AZURE_AUTH; + metadata: { + identityId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -797,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 @@ -839,6 +1019,10 @@ export type Event = | AddIdentityAwsAuthEvent | UpdateIdentityAwsAuthEvent | GetIdentityAwsAuthEvent + | LoginIdentityAzureAuthEvent + | AddIdentityAzureAuthEvent + | UpdateIdentityAzureAuthEvent + | GetIdentityAzureAuthEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent @@ -860,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..8027f5907 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -73,11 +73,17 @@ type TLdapConfigServiceFactoryDep = { >; userDAL: Pick< TUserDALFactory, - "create" | "findOne" | "transaction" | "updateById" | "findUserEncKeyByUserIdsBatch" | "find" + | "create" + | "findOne" + | "transaction" + | "updateById" + | "findUserEncKeyByUserIdsBatch" + | "find" + | "findUserEncKeyByUserId" >; userAliasDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; }; export type TLdapConfigServiceFactory = ReturnType; @@ -510,6 +516,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); @@ -591,12 +598,14 @@ export const ldapConfigServiceFactory = ({ }); const isUserCompleted = Boolean(user.isAccepted); + const userEnc = await userDAL.findUserEncKeyByUserId(user.id); const providerAuthToken = jwt.sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, + hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), firstName, lastName, 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-dal.ts b/backend/src/ee/services/license/license-dal.ts index 4e70dfb5a..cf7048801 100644 --- a/backend/src/ee/services/license/license-dal.ts +++ b/backend/src/ee/services/license/license-dal.ts @@ -16,6 +16,8 @@ export const licenseDALFactory = (db: TDbClient) => { void bd.where({ orgId }); } }) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .where(`${TableName.Users}.isGhost`, false) .count(); return doc?.[0].count; } catch (error) { 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..3cc51e1c2 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -41,7 +41,10 @@ import { TCreateSamlCfgDTO, TGetSamlCfgDTO, TSamlLoginDTO, TUpdateSamlCfgDTO } f type TSamlConfigServiceFactoryDep = { samlConfigDAL: Pick; - userDAL: Pick; + userDAL: Pick< + TUserDALFactory, + "create" | "findOne" | "transaction" | "updateById" | "findById" | "findUserEncKeyByUserId" + >; userAliasDAL: Pick; orgDAL: Pick< TOrgDALFactory, @@ -50,7 +53,7 @@ type TSamlConfigServiceFactoryDep = { orgMembershipDAL: Pick; orgBotDAL: Pick; permissionService: Pick; - licenseService: Pick; + licenseService: Pick; tokenService: Pick; smtpService: Pick; }; @@ -449,8 +452,10 @@ export const samlConfigServiceFactory = ({ return newUser; }); } + await licenseService.updateSubscriptionOrgMemberCount(organization.id); const isUserCompleted = Boolean(user.isAccepted); + const userEnc = await userDAL.findUserEncKeyByUserId(user.id); const providerAuthToken = jwt.sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, @@ -463,6 +468,7 @@ export const samlConfigServiceFactory = ({ organizationId: organization.id, organizationSlug: organization.slug, authMethod: authProvider, + hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), authType: UserAliasType.SAML, isUserCompleted, ...(relayState 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-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 690d308d2..5d0977134 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -15,9 +15,16 @@ import { ActorType } from "@app/services/auth/auth-type"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretDALFactory } from "@app/services/secret/secret-dal"; -import { getAllNestedSecretReferences } from "@app/services/secret/secret-fns"; +import { + fnSecretBlindIndexCheck, + fnSecretBlindIndexCheckV2, + fnSecretBulkDelete, + fnSecretBulkInsert, + fnSecretBulkUpdate, + getAllNestedSecretReferences +} from "@app/services/secret/secret-fns"; import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; -import { TSecretServiceFactory } from "@app/services/secret/secret-service"; +import { SecretOperations } from "@app/services/secret/secret-types"; import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; @@ -32,7 +39,6 @@ import { TSecretApprovalRequestReviewerDALFactory } from "./secret-approval-requ import { TSecretApprovalRequestSecretDALFactory } from "./secret-approval-request-secret-dal"; import { ApprovalStatus, - CommitType, RequestState, TApprovalRequestCountDTO, TGenerateSecretApprovalRequestDTO, @@ -45,10 +51,11 @@ import { type TSecretApprovalRequestServiceFactoryDep = { permissionService: Pick; + projectBotService: Pick; secretApprovalRequestDAL: TSecretApprovalRequestDALFactory; secretApprovalRequestSecretDAL: TSecretApprovalRequestSecretDALFactory; secretApprovalRequestReviewerDAL: TSecretApprovalRequestReviewerDALFactory; - folderDAL: Pick; + folderDAL: Pick; secretDAL: TSecretDALFactory; secretTagDAL: Pick; secretBlindIndexDAL: Pick; @@ -56,16 +63,7 @@ type TSecretApprovalRequestServiceFactoryDep = { secretVersionDAL: Pick; secretVersionTagDAL: Pick; projectDAL: Pick; - projectBotService: Pick; - secretService: Pick< - TSecretServiceFactory, - | "fnSecretBulkInsert" - | "fnSecretBulkUpdate" - | "fnSecretBlindIndexCheck" - | "fnSecretBulkDelete" - | "fnSecretBlindIndexCheckV2" - >; - secretQueueService: Pick; + secretQueueService: Pick; }; export type TSecretApprovalRequestServiceFactory = ReturnType; @@ -82,7 +80,6 @@ export const secretApprovalRequestServiceFactory = ({ projectDAL, permissionService, snapshotService, - secretService, secretVersionDAL, secretQueueService, projectBotService @@ -302,11 +299,12 @@ export const secretApprovalRequestServiceFactory = ({ const secretApprovalSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); if (!secretApprovalSecrets) throw new BadRequestError({ message: "No secrets found" }); - const conflicts: Array<{ secretId: string; op: CommitType }> = []; - let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Create); + const conflicts: Array<{ secretId: string; op: SecretOperations }> = []; + let secretCreationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Create); if (secretCreationCommits.length) { - const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ + const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({ folderId, + secretDAL, inputSecrets: secretCreationCommits.map(({ secretBlindIndex }) => { if (!secretBlindIndex) { throw new BadRequestError({ @@ -319,17 +317,19 @@ export const secretApprovalRequestServiceFactory = ({ secretCreationCommits .filter(({ secretBlindIndex }) => conflictGroupByBlindIndex[secretBlindIndex || ""]) .forEach((el) => { - conflicts.push({ op: CommitType.Create, secretId: el.id }); + conflicts.push({ op: SecretOperations.Create, secretId: el.id }); }); secretCreationCommits = secretCreationCommits.filter( ({ secretBlindIndex }) => !conflictGroupByBlindIndex[secretBlindIndex || ""] ); } - let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Update); + let secretUpdationCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Update); if (secretUpdationCommits.length) { - const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await secretService.fnSecretBlindIndexCheckV2({ + const { secsGroupedByBlindIndex: conflictGroupByBlindIndex } = await fnSecretBlindIndexCheckV2({ folderId, + secretDAL, + userId: "", inputSecrets: secretUpdationCommits .filter(({ secretBlindIndex, secret }) => secret && secret.secretBlindIndex !== secretBlindIndex) .map(({ secretBlindIndex }) => { @@ -347,7 +347,7 @@ export const secretApprovalRequestServiceFactory = ({ (secretBlindIndex && conflictGroupByBlindIndex[secretBlindIndex]) || !secretId ) .forEach((el) => { - conflicts.push({ op: CommitType.Update, secretId: el.id }); + conflicts.push({ op: SecretOperations.Update, secretId: el.id }); }); secretUpdationCommits = secretUpdationCommits.filter( @@ -356,11 +356,11 @@ export const secretApprovalRequestServiceFactory = ({ ); } - const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === CommitType.Delete); + const secretDeletionCommits = secretApprovalSecrets.filter(({ op }) => op === SecretOperations.Delete); const botKey = await projectBotService.getBotKey(projectId).catch(() => null); const mergeStatus = await secretApprovalRequestDAL.transaction(async (tx) => { const newSecrets = secretCreationCommits.length - ? await secretService.fnSecretBulkInsert({ + ? await fnSecretBulkInsert({ tx, folderId, inputSecrets: secretCreationCommits.map((el) => ({ @@ -403,7 +403,7 @@ export const secretApprovalRequestServiceFactory = ({ }) : []; const updatedSecrets = secretUpdationCommits.length - ? await secretService.fnSecretBulkUpdate({ + ? await fnSecretBulkUpdate({ folderId, projectId, tx, @@ -449,11 +449,13 @@ export const secretApprovalRequestServiceFactory = ({ }) : []; const deletedSecret = secretDeletionCommits.length - ? await secretService.fnSecretBulkDelete({ + ? await fnSecretBulkDelete({ projectId, folderId, tx, actorId: "", + secretDAL, + secretQueueService, inputSecrets: secretDeletionCommits.map(({ secretBlindIndex }) => { if (!secretBlindIndex) { throw new BadRequestError({ @@ -480,12 +482,14 @@ export const secretApprovalRequestServiceFactory = ({ }; }); await snapshotService.performSnapshot(folderId); - const folder = await folderDAL.findById(folderId); - // TODO(akhilmhdh-pg): change query to do secret path from folder + const [folder] = await folderDAL.findSecretPathByFolderIds(projectId, [folderId]); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); await secretQueueService.syncSecrets({ projectId, - secretPath: "/", - environment: folder?.environment.envSlug as string + secretPath: folder.path, + environmentSlug: folder.environmentSlug, + actorId, + actor }); return mergeStatus; }; @@ -533,9 +537,9 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; // for created secret approval change - const createdSecrets = data[CommitType.Create]; + const createdSecrets = data[SecretOperations.Create]; if (createdSecrets && createdSecrets?.length) { - const { keyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex } = await fnSecretBlindIndexCheck({ inputSecrets: createdSecrets, folderId, isNew: true, @@ -546,7 +550,7 @@ export const secretApprovalRequestServiceFactory = ({ commits.push( ...createdSecrets.map(({ secretName, ...el }) => ({ ...el, - op: CommitType.Create as const, + op: SecretOperations.Create as const, version: 1, secretBlindIndex: keyName2BlindIndex[secretName], algorithm: SecretEncryptionAlgo.AES_256_GCM, @@ -558,12 +562,12 @@ export const secretApprovalRequestServiceFactory = ({ }); } // not secret approval for update operations - const updatedSecrets = data[CommitType.Update]; + const updatedSecrets = data[SecretOperations.Update]; if (updatedSecrets && updatedSecrets?.length) { // get all blind index // Find all those secrets // if not throw not found - const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex, secrets: secretsToBeUpdated } = await fnSecretBlindIndexCheck({ inputSecrets: updatedSecrets, folderId, isNew: false, @@ -574,8 +578,8 @@ export const secretApprovalRequestServiceFactory = ({ // now find any secret that needs to update its name // same process as above const nameUpdatedSecrets = updatedSecrets.filter(({ newSecretName }) => Boolean(newSecretName)); - const { keyName2BlindIndex: newKeyName2BlindIndex } = await secretService.fnSecretBlindIndexCheck({ - inputSecrets: nameUpdatedSecrets, + const { keyName2BlindIndex: newKeyName2BlindIndex } = await fnSecretBlindIndexCheck({ + inputSecrets: nameUpdatedSecrets.map(({ newSecretName }) => ({ secretName: newSecretName as string })), folderId, isNew: true, blindIndexCfg, @@ -592,14 +596,14 @@ export const secretApprovalRequestServiceFactory = ({ const secretId = secsGroupedByBlindIndex[keyName2BlindIndex[secretName]][0].id; const secretBlindIndex = newSecretName && newKeyName2BlindIndex[newSecretName] - ? newKeyName2BlindIndex?.[secretName] + ? newKeyName2BlindIndex?.[newSecretName] : keyName2BlindIndex[secretName]; // add tags if (tagIds?.length) commitTagIds[keyName2BlindIndex[secretName]] = tagIds; return { ...latestSecretVersions[secretId], ...el, - op: CommitType.Update as const, + op: SecretOperations.Update as const, secret: secretId, secretVersion: latestSecretVersions[secretId].id, secretBlindIndex, @@ -609,12 +613,12 @@ export const secretApprovalRequestServiceFactory = ({ ); } // deleted secrets - const deletedSecrets = data[CommitType.Delete]; + const deletedSecrets = data[SecretOperations.Delete]; if (deletedSecrets && deletedSecrets.length) { // get all blind index // Find all those secrets // if not throw not found - const { keyName2BlindIndex, secrets } = await secretService.fnSecretBlindIndexCheck({ + const { keyName2BlindIndex, secrets } = await fnSecretBlindIndexCheck({ inputSecrets: deletedSecrets, folderId, isNew: false, @@ -635,7 +639,7 @@ export const secretApprovalRequestServiceFactory = ({ if (!latestSecretVersions[secretId].secretBlindIndex) throw new BadRequestError({ message: "Failed to find secret blind index" }); return { - op: CommitType.Delete as const, + op: SecretOperations.Delete as const, ...latestSecretVersions[secretId], secretBlindIndex: latestSecretVersions[secretId].secretBlindIndex as string, secret: secretId, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 008b977e6..1fbb75418 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -1,11 +1,6 @@ import { TImmutableDBKeys, TSecretApprovalPolicies, TSecretApprovalRequestsSecrets } from "@app/db/schemas"; import { TProjectPermission } from "@app/lib/types"; - -export enum CommitType { - Create = "create", - Update = "update", - Delete = "delete" -} +import { SecretOperations } from "@app/services/secret/secret-types"; export enum RequestState { Open = "open", @@ -18,14 +13,14 @@ export enum ApprovalStatus { REJECTED = "rejected" } -type TApprovalCreateSecret = Omit< +export type TApprovalCreateSecret = Omit< TSecretApprovalRequestsSecrets, TImmutableDBKeys | "version" | "algorithm" | "keyEncoding" | "requestId" | "op" | "secretVersion" | "secretBlindIndex" > & { secretName: string; tagIds?: string[]; }; -type TApprovalUpdateSecret = Partial & { +export type TApprovalUpdateSecret = Partial & { secretName: string; newSecretName?: string; tagIds?: string[]; @@ -36,9 +31,9 @@ export type TGenerateSecretApprovalRequestDTO = { secretPath: string; policy: TSecretApprovalPolicies; data: { - [CommitType.Create]?: TApprovalCreateSecret[]; - [CommitType.Update]?: TApprovalUpdateSecret[]; - [CommitType.Delete]?: { secretName: string }[]; + [SecretOperations.Create]?: TApprovalCreateSecret[]; + [SecretOperations.Update]?: TApprovalUpdateSecret[]; + [SecretOperations.Delete]?: { secretName: string }[]; }; } & TProjectPermission; diff --git a/backend/src/ee/services/secret-replication/secret-replication-constants.ts b/backend/src/ee/services/secret-replication/secret-replication-constants.ts new file mode 100644 index 000000000..88c9ee166 --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-constants.ts @@ -0,0 +1 @@ +export const MAX_REPLICATION_DEPTH = 5; diff --git a/backend/src/ee/services/secret-replication/secret-replication-dal.ts b/backend/src/ee/services/secret-replication/secret-replication-dal.ts new file mode 100644 index 000000000..3c4c021fd --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-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 TSecretReplicationDALFactory = ReturnType; + +export const secretReplicationDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.SecretVersion); + return orm; +}; diff --git a/backend/src/ee/services/secret-replication/secret-replication-service.ts b/backend/src/ee/services/secret-replication/secret-replication-service.ts new file mode 100644 index 000000000..fd2f7cc1a --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-service.ts @@ -0,0 +1,485 @@ +import { SecretType, TSecrets } from "@app/db/schemas"; +import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; +import { TSecretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; +import { TSecretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto"; +import { BadRequestError } from "@app/lib/errors"; +import { groupBy, unique } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { QueueName, TQueueServiceFactory } from "@app/queue"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TSecretDALFactory } from "@app/services/secret/secret-dal"; +import { fnSecretBulkInsert, fnSecretBulkUpdate } from "@app/services/secret/secret-fns"; +import { TSecretQueueFactory, uniqueSecretQueueKey } from "@app/services/secret/secret-queue"; +import { SecretOperations } from "@app/services/secret/secret-types"; +import { TSecretVersionDALFactory } from "@app/services/secret/secret-version-dal"; +import { TSecretVersionTagDALFactory } from "@app/services/secret/secret-version-tag-dal"; +import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/secret-blind-index-dal"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { ReservedFolders } from "@app/services/secret-folder/secret-folder-types"; +import { TSecretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; +import { fnSecretsFromImports } from "@app/services/secret-import/secret-import-fns"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; + +import { MAX_REPLICATION_DEPTH } from "./secret-replication-constants"; + +type TSecretReplicationServiceFactoryDep = { + secretDAL: Pick< + TSecretDALFactory, + "find" | "findByBlindIndexes" | "insertMany" | "bulkUpdate" | "delete" | "upsertSecretReferences" | "transaction" + >; + secretVersionDAL: Pick; + secretImportDAL: Pick; + folderDAL: Pick< + TSecretFolderDALFactory, + "findSecretPathByFolderIds" | "findBySecretPath" | "create" | "findOne" | "findByManySecretPath" + >; + secretVersionTagDAL: Pick; + secretQueueService: Pick; + queueService: Pick; + secretApprovalPolicyService: Pick; + keyStore: Pick; + secretBlindIndexDAL: Pick; + secretTagDAL: Pick; + secretApprovalRequestDAL: Pick; + projectMembershipDAL: Pick; + secretApprovalRequestSecretDAL: Pick< + TSecretApprovalRequestSecretDALFactory, + "insertMany" | "insertApprovalSecretTags" + >; + projectBotService: Pick; +}; + +export type TSecretReplicationServiceFactory = ReturnType; +const SECRET_IMPORT_SUCCESS_LOCK = 10; + +const keystoreReplicationSuccessKey = (jobId: string, secretImportId: string) => `${jobId}-${secretImportId}`; +const getReplicationKeyLockPrefix = (projectId: string, environmentSlug: string, secretPath: string) => + `REPLICATION_SECRET_${projectId}-${environmentSlug}-${secretPath}`; +export const getReplicationFolderName = (importId: string) => `${ReservedFolders.SecretReplication}${importId}`; + +const getDecryptedKeyValue = (key: string, secret: TSecrets) => { + const secretKey = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key + }); + return { key: secretKey, value: secretValue }; +}; + +export const secretReplicationServiceFactory = ({ + secretDAL, + queueService, + secretVersionDAL, + secretImportDAL, + keyStore, + secretVersionTagDAL, + secretTagDAL, + folderDAL, + secretApprovalPolicyService, + secretApprovalRequestSecretDAL, + secretApprovalRequestDAL, + secretQueueService, + projectMembershipDAL, + projectBotService +}: TSecretReplicationServiceFactoryDep) => { + const getReplicatedSecrets = ( + botKey: string, + localSecrets: TSecrets[], + importedSecrets: { secrets: TSecrets[] }[] + ) => { + const deDupe = new Set(); + const secrets = localSecrets + .filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)) + .map((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + deDupe.add(decryptedSecret.key); + return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }; + }); + + for (let i = importedSecrets.length - 1; i >= 0; i = -1) { + importedSecrets[i].secrets.forEach((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + if (deDupe.has(decryptedSecret.key) || !el.secretBlindIndex) { + return; + } + deDupe.add(decryptedSecret.key); + secrets.push({ ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }); + }); + } + return secrets; + }; + + // IMPORTANT NOTE BEFORE READING THE FUNCTION + // SOURCE - Where secrets are copied from + // DESTINATION - Where the replicated imports that points to SOURCE from Destination + queueService.start(QueueName.SecretReplication, async (job) => { + logger.info(job.data, "Replication started"); + const { + secretPath, + environmentSlug, + projectId, + actorId, + actor, + pickOnlyImportIds, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue, + _depth: depth = 0 + } = job.data; + if (depth > MAX_REPLICATION_DEPTH) return; + + const folder = await folderDAL.findBySecretPath(projectId, environmentSlug, secretPath); + if (!folder) return; + + // the the replicated imports made to the source. These are the destinations + const destinationSecretImports = await secretImportDAL.find({ + importPath: secretPath, + importEnv: folder.envId + }); + + // CASE: normal mode <- link import <- replicated import + const nonReplicatedDestinationImports = destinationSecretImports.filter(({ isReplication }) => !isReplication); + if (nonReplicatedDestinationImports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(nonReplicatedDestinationImports, (i) => i.folderId).map( + ({ folderId }) => folderId + ); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); + await Promise.all( + nonReplicatedDestinationImports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue?.[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + secretQueueService.replicateSecrets({ + projectId, + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + actorId, + actor, + _depth: depth + 1, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue + }) + ) + ); + } + + let destinationReplicatedSecretImports = destinationSecretImports.filter(({ isReplication }) => + Boolean(isReplication) + ); + destinationReplicatedSecretImports = pickOnlyImportIds + ? destinationReplicatedSecretImports.filter(({ id }) => pickOnlyImportIds?.includes(id)) + : destinationReplicatedSecretImports; + if (!destinationReplicatedSecretImports.length) return; + + const botKey = await projectBotService.getBotKey(projectId); + + // these are the secrets to be added in replicated folders + const sourceLocalSecrets = await secretDAL.find({ folderId: folder.id, type: SecretType.Shared }); + const sourceSecretImports = await secretImportDAL.find({ folderId: folder.id }); + const sourceImportedSecrets = await fnSecretsFromImports({ + allowedImports: sourceSecretImports, + secretDAL, + folderDAL, + secretImportDAL + }); + // secrets that gets replicated across imports + const sourceSecrets = getReplicatedSecrets(botKey, sourceLocalSecrets, sourceImportedSecrets); + const sourceSecretsGroupByBlindIndex = groupBy(sourceSecrets, (i) => i.secretBlindIndex as string); + + const lock = await keyStore.acquireLock( + [getReplicationKeyLockPrefix(projectId, environmentSlug, secretPath)], + 5000 + ); + + try { + /* eslint-disable no-await-in-loop */ + for (const destinationSecretImport of destinationReplicatedSecretImports) { + try { + const hasJobCompleted = await keyStore.getItem( + keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id), + KeyStorePrefixes.SecretReplication + ); + if (hasJobCompleted) { + logger.info( + { jobId: job.id, importId: destinationSecretImport.id }, + "Skipping this job as this has been successfully replicated." + ); + // eslint-disable-next-line + continue; + } + + const [destinationFolder] = await folderDAL.findSecretPathByFolderIds(projectId, [ + destinationSecretImport.folderId + ]); + if (!destinationFolder) throw new BadRequestError({ message: "Imported folder not found" }); + + let destinationReplicationFolder = await folderDAL.findOne({ + parentId: destinationFolder.id, + name: getReplicationFolderName(destinationSecretImport.id), + isReserved: true + }); + if (!destinationReplicationFolder) { + destinationReplicationFolder = await folderDAL.create({ + parentId: destinationFolder.id, + name: getReplicationFolderName(destinationSecretImport.id), + envId: destinationFolder.envId, + isReserved: true + }); + } + const destinationReplicationFolderId = destinationReplicationFolder.id; + + const destinationLocalSecretsFromDB = await secretDAL.find({ + folderId: destinationReplicationFolderId + }); + const destinationLocalSecrets = destinationLocalSecretsFromDB.map((el) => { + const decryptedSecret = getDecryptedKeyValue(botKey, el); + return { ...el, secretKey: decryptedSecret.key, secretValue: decryptedSecret.value }; + }); + + const destinationLocalSecretsGroupedByBlindIndex = groupBy( + destinationLocalSecrets.filter(({ secretBlindIndex }) => Boolean(secretBlindIndex)), + (i) => i.secretBlindIndex as string + ); + + const locallyCreatedSecrets = sourceSecrets + .filter( + ({ secretBlindIndex }) => !destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] + ) + .map((el) => ({ ...el, operation: SecretOperations.Create })); // rewrite update ops to create + + const locallyUpdatedSecrets = sourceSecrets + .filter( + ({ secretBlindIndex, secretKey, secretValue }) => + destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0] && + // if key or value changed + (destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretKey !== secretKey || + destinationLocalSecretsGroupedByBlindIndex[secretBlindIndex as string]?.[0]?.secretValue !== + secretValue) + ) + .map((el) => ({ ...el, operation: SecretOperations.Update })); // rewrite update ops to create + + const locallyDeletedSecrets = destinationLocalSecrets + .filter(({ secretBlindIndex }) => !sourceSecretsGroupByBlindIndex[secretBlindIndex as string]?.[0]) + .map((el) => ({ ...el, operation: SecretOperations.Delete })); + + const isEmtpy = + locallyCreatedSecrets.length + locallyUpdatedSecrets.length + locallyDeletedSecrets.length === 0; + // eslint-disable-next-line + if (isEmtpy) continue; + + const policy = await secretApprovalPolicyService.getSecretApprovalPolicy( + projectId, + destinationFolder.environmentSlug, + destinationFolder.path + ); + // this means it should be a approval request rather than direct replication + if (policy && actor === ActorType.USER) { + const membership = await projectMembershipDAL.findOne({ projectId, userId: actorId }); + if (!membership) { + logger.error("Project membership not found in %s for user %s", projectId, actorId); + return; + } + + const localSecretsLatestVersions = destinationLocalSecrets.map(({ id }) => id); + const latestSecretVersions = await secretVersionDAL.findLatestVersionMany( + destinationReplicationFolderId, + localSecretsLatestVersions + ); + await secretApprovalRequestDAL.transaction(async (tx) => { + const approvalRequestDoc = await secretApprovalRequestDAL.create( + { + folderId: destinationReplicationFolderId, + slug: alphaNumericNanoId(), + policyId: policy.id, + status: "open", + hasMerged: false, + committerId: membership.id, + isReplicated: true + }, + tx + ); + const commits = locallyCreatedSecrets + .concat(locallyUpdatedSecrets) + .concat(locallyDeletedSecrets) + .map((doc) => { + const { operation } = doc; + const localSecret = destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string]?.[0]; + + return { + op: operation, + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + requestId: approvalRequestDoc.id, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding, + // except create operation other two needs the secret id and version id + ...(operation !== SecretOperations.Create + ? { secretId: localSecret.id, secretVersion: latestSecretVersions[localSecret.id].id } + : {}) + }; + }); + const approvalCommits = await secretApprovalRequestSecretDAL.insertMany(commits, tx); + + return { ...approvalRequestDoc, commits: approvalCommits }; + }); + } else { + await secretDAL.transaction(async (tx) => { + if (locallyCreatedSecrets.length) { + await fnSecretBulkInsert({ + folderId: destinationReplicationFolderId, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyCreatedSecrets.map((doc) => { + return { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + }; + }) + }); + } + if (locallyUpdatedSecrets.length) { + await fnSecretBulkUpdate({ + projectId, + folderId: destinationReplicationFolderId, + secretVersionDAL, + secretDAL, + tx, + secretTagDAL, + secretVersionTagDAL, + inputSecrets: locallyUpdatedSecrets.map((doc) => { + return { + filter: { + folderId: destinationReplicationFolderId, + id: destinationLocalSecretsGroupedByBlindIndex[doc.secretBlindIndex as string][0].id + }, + data: { + keyEncoding: doc.keyEncoding, + algorithm: doc.algorithm, + type: doc.type, + metadata: doc.metadata, + secretKeyIV: doc.secretKeyIV, + secretKeyTag: doc.secretKeyTag, + secretKeyCiphertext: doc.secretKeyCiphertext, + secretValueIV: doc.secretValueIV, + secretValueTag: doc.secretValueTag, + secretValueCiphertext: doc.secretValueCiphertext, + secretBlindIndex: doc.secretBlindIndex, + secretCommentIV: doc.secretCommentIV, + secretCommentTag: doc.secretCommentTag, + secretCommentCiphertext: doc.secretCommentCiphertext, + skipMultilineEncoding: doc.skipMultilineEncoding + } + }; + }) + }); + } + if (locallyDeletedSecrets.length) { + await secretDAL.delete( + { + $in: { + id: locallyDeletedSecrets.map(({ id }) => id) + }, + folderId: destinationReplicationFolderId + }, + tx + ); + } + }); + + await secretQueueService.syncSecrets({ + projectId, + secretPath: destinationFolder.path, + environmentSlug: destinationFolder.environmentSlug, + actorId, + actor, + _depth: depth + 1, + _deDupeReplicationQueue: deDupeReplicationQueue, + _deDupeQueue: deDupeQueue + }); + } + + // this is used to avoid multiple times generating secret approval by failed one + await keyStore.setItemWithExpiry( + keystoreReplicationSuccessKey(job.id as string, destinationSecretImport.id), + SECRET_IMPORT_SUCCESS_LOCK, + 1, + KeyStorePrefixes.SecretReplication + ); + + await secretImportDAL.updateById(destinationSecretImport.id, { + lastReplicated: new Date(), + replicationStatus: null, + isReplicationSuccess: true + }); + } catch (err) { + logger.error( + err, + `Failed to replicate secret with import id=[${destinationSecretImport.id}] env=[${destinationSecretImport.importEnv.slug}] path=[${destinationSecretImport.importPath}]` + ); + await secretImportDAL.updateById(destinationSecretImport.id, { + lastReplicated: new Date(), + replicationStatus: (err as Error)?.message.slice(0, 500), + isReplicationSuccess: false + }); + } + } + /* eslint-enable no-await-in-loop */ + } finally { + await lock.release(); + logger.info(job.data, "Replication finished"); + } + }); + + queueService.listen(QueueName.SecretReplication, "failed", (job, err) => { + logger.error(err, "Failed to replicate secret", job?.data); + }); +}; diff --git a/backend/src/ee/services/secret-replication/secret-replication-types.ts b/backend/src/ee/services/secret-replication/secret-replication-types.ts new file mode 100644 index 000000000..1b32f1f4a --- /dev/null +++ b/backend/src/ee/services/secret-replication/secret-replication-types.ts @@ -0,0 +1,3 @@ +export type TSyncSecretReplicationDTO = { + id: string; +}; 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 0e71ad126..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 ({ @@ -220,7 +219,7 @@ export const secretSnapshotServiceFactory = ({ const deletedTopLevelSecsGroupById = groupBy(deletedTopLevelSecs, (item) => item.id); // this will remove all secrets and folders on child // due to sql foreign key and link list connection removing the folders removes everything below too - const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId }, tx); + const deletedFolders = await folderDAL.delete({ parentId: snapshot.folderId, isReserved: false }, tx); const deletedTopLevelFolders = groupBy( deletedFolders.filter(({ parentId }) => parentId === snapshot.folderId), (item) => item.id diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index cdd5a999b..4092bf356 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,151 @@ 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 current folders. + * 2. Pruning snapshots from non-current folders (versioned ones). + * 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 current 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`) + .whereRaw(`snapshot_cte.row_num > ${TableName.Project}."pitVersionLimit"`) + .delete(); + } catch (err) { + logger.error( + `Failed to prune snapshots from current folders in range ${batchEntries[0]}:${ + batchEntries[batchEntries.length - 1] + }` + ); + } finally { + uuidOffset = batchEntries[batchEntries.length - 1]; + } + } else { + break; + } + } + + // cleanup snapshots from non-current 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 non-current 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/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 5e2c3aab3..ce752a1e5 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -1,20 +1,75 @@ import { Redis } from "ioredis"; +import { Redlock, Settings } from "@app/lib/red-lock"; + export type TKeyStoreFactory = ReturnType; +// all the key prefixes used must be set here to avoid conflict +export enum KeyStorePrefixes { + SecretReplication = "secret-replication-import-lock" +} + +type TWaitTillReady = { + key: string; + waitingCb?: () => void; + keyCheckCb: (val: string | null) => boolean; + waitIteration?: number; + delay?: number; + jitter?: number; +}; + export const keyStoreFactory = (redisUrl: string) => { const redis = new Redis(redisUrl); + const redisLock = new Redlock([redis], { retryCount: 2, retryDelay: 200 }); - const setItem = async (key: string, value: string | number | Buffer) => redis.set(key, value); + const setItem = async (key: string, value: string | number | Buffer, prefix?: string) => + redis.set(prefix ? `${prefix}:${key}` : key, value); - const getItem = async (key: string) => redis.get(key); + const getItem = async (key: string, prefix?: string) => redis.get(prefix ? `${prefix}:${key}` : key); - const setItemWithExpiry = async (key: string, exp: number | string, value: string | number | Buffer) => - redis.setex(key, exp, value); + const setItemWithExpiry = async ( + key: string, + exp: number | string, + value: string | number | Buffer, + prefix?: string + ) => redis.setex(prefix ? `${prefix}:${key}` : key, exp, value); const deleteItem = async (key: string) => redis.del(key); const incrementBy = async (key: string, value: number) => redis.incrby(key, value); - return { setItem, getItem, setItemWithExpiry, deleteItem, incrementBy }; + const waitTillReady = async ({ + key, + waitingCb, + keyCheckCb, + waitIteration = 10, + delay = 1000, + jitter = 200 + }: TWaitTillReady) => { + let attempts = 0; + let isReady = keyCheckCb(await getItem(key)); + while (!isReady) { + if (attempts > waitIteration) return; + // eslint-disable-next-line + await new Promise((resolve) => { + waitingCb?.(); + setTimeout(resolve, Math.max(0, delay + Math.floor((Math.random() * 2 - 1) * jitter))); + }); + attempts += 1; + // eslint-disable-next-line + isReady = keyCheckCb(await getItem(key, "wait_till_ready")); + } + }; + + return { + setItem, + getItem, + setItemWithExpiry, + deleteItem, + incrementBy, + acquireLock(resources: string[], duration: number, settings?: Partial) { + return redisLock.acquire(resources, duration, settings); + }, + waitTillReady + }; }; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 6ae5a9d33..d768066fa 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -225,7 +225,8 @@ export const PROJECT_IDENTITIES = { roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", - isTemporary: "Whether the assigned role is temporary.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", temporaryMode: "Type of temporary expiry.", temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", temporaryAccessStartTime: "Time to which the temporary access starts" @@ -242,7 +243,8 @@ export const PROJECT_IDENTITIES = { roles: { description: "A list of role slugs to assign to the newly created identity project membership.", role: "The role slug to assign to the newly created identity project membership.", - isTemporary: "Whether the assigned role is temporary.", + isTemporary: + "Whether the assigned role is temporary. If isTemporary is set true, must provide temporaryMode, temporaryRange and temporaryAccessStartTime.", temporaryMode: "Type of temporary expiry.", temporaryRange: "Expiry time for temporary access. In relative mode it could be 1s,2m,3h", temporaryAccessStartTime: "Time to which the temporary access starts" @@ -341,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.", @@ -362,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.", @@ -384,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." @@ -502,12 +508,27 @@ export const SECRET_TAGS = { LIST: { projectId: "The ID of the project to list tags from." }, + GET_TAG_BY_ID: { + projectId: "The ID of the project to get tags from.", + tagId: "The ID of the tag to get details" + }, + GET_TAG_BY_SLUG: { + projectId: "The ID of the project to get tags from.", + tagSlug: "The slug of the tag to get details" + }, CREATE: { projectId: "The ID of the project to create the tag in.", name: "The name of the tag to create.", slug: "The slug of the tag to create.", color: "The color of the tag to create." }, + UPDATE: { + projectId: "The ID of the project to update the tag in.", + tagId: "The ID of the tag to get details", + name: "The name of the tag to update.", + slug: "The slug of the tag to update.", + color: "The color of the tag to update." + }, DELETE: { tagId: "The ID of the tag to delete.", projectId: "The ID of the project to delete the tag from." @@ -519,7 +540,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { projectSlug: "The slug of the project of the identity in.", identityId: "The ID of the identity to create.", slug: "The slug of the privilege to create.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -533,6 +555,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isPackPermission: "Whether the server should pack(compact) the permission object.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", @@ -544,7 +567,8 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { identityId: "The ID of the identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", - permissions: `The permission object for the privilege. + permissions: `@deprecated - use privilegePermission +The permission object for the privilege. - Read secrets \`\`\` { "permissions": [{"action": "read", "subject": "secrets"]} @@ -558,6 +582,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE = { - { "permissions": [{"action": "read", "subject": "secrets", "conditions": { "environment": "dev", "secretPath": { "$glob": "/" } }}] } \`\`\` `, + privilegePermission: "The permission object for the privilege.", isTemporary: "Whether the privilege is temporary.", temporaryMode: "Type of temporary access given. Types: relative", temporaryRange: "TTL for the temporay time. Eg: 1m, 1h, 1d", @@ -655,6 +680,7 @@ export const INTEGRATION = { targetServiceId: "The service based grouping identifier ID of the external provider. Used in Terraform cloud, Checkly, Railway and NorthFlank", owner: "External integration providers service entity owner. Used in Github.", + url: "The self-hosted URL of the platform to integrate with", path: "Path to save the synced secrets. Used by Gitlab, AWS Parameter Store, Vault", region: "AWS region to sync secrets to.", scope: "Scope of the provider. Used by Github, Qovery", @@ -667,7 +693,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: { @@ -715,3 +744,128 @@ export const AUDIT_LOG_STREAMS = { id: "The ID of the audit log stream to get details." } }; + +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.", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + UPDATE: { + projectSlug: "Slug of the project to update the role for.", + roleId: "The ID of the role to update", + slug: "The slug of the role.", + name: "The name of the role.", + description: "The description for the role.", + permissions: "The permissions assigned to the role." + }, + DELETE: { + projectSlug: "Slug of the project to delete this role for.", + roleId: "The ID of the role to update" + }, + GET_ROLE_BY_SLUG: { + projectSlug: "The slug of the project.", + roleSlug: "The slug of the role to get details" + }, + LIST: { + projectSlug: "The slug of the project to list the roles of." + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 6ae8bf02d..f4da71293 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -29,7 +29,7 @@ const envSchema = z DB_USER: zpStr(z.string().describe("Postgres database username").optional()), DB_PASSWORD: zpStr(z.string().describe("Postgres database password").optional()), DB_NAME: zpStr(z.string().describe("Postgres database name").optional()), - + BCRYPT_SALT_ROUND: z.number().default(12), NODE_ENV: z.enum(["development", "test", "production"]).default("production"), SALT_ROUNDS: z.coerce.number().default(10), INITIAL_ORGANIZATION_NAME: zpStr(z.string().optional()), @@ -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()), @@ -75,6 +77,7 @@ const envSchema = z .optional() .default(process.env.URL_GITLAB_LOGIN ?? GITLAB_URL) ), // fallback since URL_GITLAB_LOGIN has been renamed + DEFAULT_SAML_ORG_SLUG: zpStr(z.string().optional()).default(process.env.NEXT_PUBLIC_SAML_ORG_SLUG), // integration client secrets // heroku CLIENT_ID_HEROKU: zpStr(z.string().optional()), @@ -119,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, @@ -131,7 +135,8 @@ const envSchema = z isSecretScanningConfigured: Boolean(data.SECRET_SCANNING_GIT_APP_ID) && Boolean(data.SECRET_SCANNING_PRIVATE_KEY) && - Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET) + Boolean(data.SECRET_SCANNING_WEBHOOK_SECRET), + samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG })); let envCfg: Readonly>; @@ -150,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/lib/crypto/cipher/cipher.ts b/backend/src/lib/crypto/cipher/cipher.ts new file mode 100644 index 000000000..7bc16b470 --- /dev/null +++ b/backend/src/lib/crypto/cipher/cipher.ts @@ -0,0 +1,49 @@ +import crypto from "crypto"; + +import { SymmetricEncryption, TSymmetricEncryptionFns } from "./types"; + +const getIvLength = () => { + return 12; +}; + +const getTagLength = () => { + return 16; +}; + +export const symmetricCipherService = (type: SymmetricEncryption): TSymmetricEncryptionFns => { + const IV_LENGTH = getIvLength(); + const TAG_LENGTH = getTagLength(); + + const encrypt = (text: Buffer, key: Buffer) => { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(type, key, iv); + + let encrypted = cipher.update(text); + encrypted = Buffer.concat([encrypted, cipher.final()]); + + // Get the authentication tag + const tag = cipher.getAuthTag(); + + // Concatenate IV, encrypted text, and tag into a single buffer + const ciphertextBlob = Buffer.concat([iv, encrypted, tag]); + return ciphertextBlob; + }; + + const decrypt = (ciphertextBlob: Buffer, key: Buffer) => { + // Extract the IV, encrypted text, and tag from the buffer + const iv = ciphertextBlob.subarray(0, IV_LENGTH); + const tag = ciphertextBlob.subarray(-TAG_LENGTH); + const encrypted = ciphertextBlob.subarray(IV_LENGTH, -TAG_LENGTH); + + const decipher = crypto.createDecipheriv(type, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); + return decrypted; + }; + + return { + encrypt, + decrypt + }; +}; diff --git a/backend/src/lib/crypto/cipher/index.ts b/backend/src/lib/crypto/cipher/index.ts new file mode 100644 index 000000000..41dbcf639 --- /dev/null +++ b/backend/src/lib/crypto/cipher/index.ts @@ -0,0 +1,2 @@ +export { symmetricCipherService } from "./cipher"; +export { SymmetricEncryption } from "./types"; diff --git a/backend/src/lib/crypto/cipher/types.ts b/backend/src/lib/crypto/cipher/types.ts new file mode 100644 index 000000000..f490d6a66 --- /dev/null +++ b/backend/src/lib/crypto/cipher/types.ts @@ -0,0 +1,9 @@ +export enum SymmetricEncryption { + AES_GCM_256 = "aes-256-gcm", + AES_GCM_128 = "aes-128-gcm" +} + +export type TSymmetricEncryptionFns = { + encrypt: (text: Buffer, key: Buffer) => Buffer; + decrypt: (blob: Buffer, key: Buffer) => Buffer; +}; diff --git a/backend/src/lib/crypto/encryption.ts b/backend/src/lib/crypto/encryption.ts index 16a7f42e7..6af20862b 100644 --- a/backend/src/lib/crypto/encryption.ts +++ b/backend/src/lib/crypto/encryption.ts @@ -11,6 +11,8 @@ import { getConfig } from "../config/env"; export const decodeBase64 = (s: string) => naclUtils.decodeBase64(s); export const encodeBase64 = (u: Uint8Array) => naclUtils.encodeBase64(u); +export const randomSecureBytes = (length = 32) => crypto.randomBytes(length); + export type TDecryptSymmetricInput = { ciphertext: string; iv: string; diff --git a/backend/src/lib/crypto/index.ts b/backend/src/lib/crypto/index.ts index db3d91fc8..cc6acfb80 100644 --- a/backend/src/lib/crypto/index.ts +++ b/backend/src/lib/crypto/index.ts @@ -9,7 +9,8 @@ export { encryptAsymmetric, encryptSymmetric, encryptSymmetric128BitHexKeyUTF8, - generateAsymmetricKeyPair + generateAsymmetricKeyPair, + randomSecureBytes } from "./encryption"; export { decryptIntegrationAuths, diff --git a/backend/src/lib/crypto/srp.ts b/backend/src/lib/crypto/srp.ts index bc29cdb3f..8d7ea656a 100644 --- a/backend/src/lib/crypto/srp.ts +++ b/backend/src/lib/crypto/srp.ts @@ -6,7 +6,7 @@ import tweetnacl from "tweetnacl-util"; import { TUserEncryptionKeys } from "@app/db/schemas"; -import { decryptSymmetric, encryptAsymmetric, encryptSymmetric } from "./encryption"; +import { decryptSymmetric128BitHexKeyUTF8, encryptAsymmetric, encryptSymmetric } from "./encryption"; export const generateSrpServerKey = async (salt: string, verifier: string) => { // eslint-disable-next-line new-cap @@ -97,7 +97,13 @@ export const generateUserSrpKeys = async (email: string, password: string) => { }; }; -export const getUserPrivateKey = async (password: string, user: TUserEncryptionKeys) => { +export const getUserPrivateKey = async ( + password: string, + user: Pick< + TUserEncryptionKeys, + "protectedKeyTag" | "protectedKey" | "protectedKeyIV" | "encryptedPrivateKey" | "iv" | "salt" | "tag" + > +) => { const derivedKey = await argon2.hash(password, { salt: Buffer.from(user.salt), memoryCost: 65536, @@ -108,17 +114,18 @@ export const getUserPrivateKey = async (password: string, user: TUserEncryptionK raw: true }); if (!derivedKey) throw new Error("Failed to derive key from password"); - const key = decryptSymmetric({ - ciphertext: user.protectedKey!, - iv: user.protectedKeyIV!, - tag: user.protectedKeyTag!, - key: derivedKey.toString("base64") + const key = decryptSymmetric128BitHexKeyUTF8({ + ciphertext: user.protectedKey as string, + iv: user.protectedKeyIV as string, + tag: user.protectedKeyTag as string, + key: derivedKey }); - const privateKey = decryptSymmetric({ + + const privateKey = decryptSymmetric128BitHexKeyUTF8({ ciphertext: user.encryptedPrivateKey, iv: user.iv, tag: user.tag, - key + key: Buffer.from(key, "hex") }); return privateKey; }; diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index 18b40acfd..0a7cb8014 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -59,6 +59,18 @@ export class BadRequestError extends Error { } } +export class NotFoundError extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message?: string; name?: string; error?: unknown }) { + super(message ?? "The requested entity is not found"); + this.name = name || "NotFound"; + this.error = error; + } +} + export class DisableRotationErrors extends Error { name: string; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index d78020809..0faeba290 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -104,24 +104,68 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Create" }); } }, - updateById: async (id: string, data: Tables[Tname]["update"], tx?: Knex) => { + updateById: async ( + id: string, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const [res] = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where({ id } as never) .update(data as never) .returning("*"); - return res; + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.decrement(incrementField, incrementValue); + }); + } + const [docs] = await query; + return docs; } catch (error) { throw new DatabaseError({ error, name: "Update by id" }); } }, - update: async (filter: TFindFilter, data: Tables[Tname]["update"], tx?: Knex) => { + update: async ( + filter: TFindFilter, + { + $incr, + $decr, + ...data + }: Tables[Tname]["update"] & { + $incr?: { [x in keyof Partial]: number }; + $decr?: { [x in keyof Partial]: number }; + }, + tx?: Knex + ) => { try { - const res = await (tx || db)(tableName) + const query = (tx || db)(tableName) .where(buildFindFilter(filter)) .update(data as never) .returning("*"); - return res; + // increment and decrement operation in update + if ($incr) { + Object.entries($incr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + if ($decr) { + Object.entries($decr).forEach(([incrementField, incrementValue]) => { + void query.increment(incrementField, incrementValue); + }); + } + return await query; } catch (error) { throw new DatabaseError({ error, name: "Update" }); } diff --git a/backend/src/lib/red-lock/index.ts b/backend/src/lib/red-lock/index.ts new file mode 100644 index 000000000..e1cc4f587 --- /dev/null +++ b/backend/src/lib/red-lock/index.ts @@ -0,0 +1,682 @@ +/* eslint-disable */ +// Source code credits: https://github.com/mike-marcacci/node-redlock +// Taken to avoid external dependency +import { randomBytes, createHash } from "crypto"; +import { EventEmitter } from "events"; + +// AbortController became available as a global in node version 16. Once version +// 14 reaches its end-of-life, this can be removed. + +import { Redis as IORedisClient, Cluster as IORedisCluster } from "ioredis"; + +type Client = IORedisClient | IORedisCluster; + +// Define script constants. +const ACQUIRE_SCRIPT = ` + -- Return 0 if an entry already exists. + for i, key in ipairs(KEYS) do + if redis.call("exists", key) == 1 then + return 0 + end + end + + -- Create an entry for each provided key. + for i, key in ipairs(KEYS) do + redis.call("set", key, ARGV[1], "PX", ARGV[2]) + end + + -- Return the number of entries added. + return #KEYS +`; + +const EXTEND_SCRIPT = ` + -- Return 0 if an entry exists with a *different* lock value. + for i, key in ipairs(KEYS) do + if redis.call("get", key) ~= ARGV[1] then + return 0 + end + end + + -- Update the entry for each provided key. + for i, key in ipairs(KEYS) do + redis.call("set", key, ARGV[1], "PX", ARGV[2]) + end + + -- Return the number of entries updated. + return #KEYS +`; + +const RELEASE_SCRIPT = ` + local count = 0 + for i, key in ipairs(KEYS) do + -- Only remove entries for *this* lock value. + if redis.call("get", key) == ARGV[1] then + redis.pcall("del", key) + count = count + 1 + end + end + + -- Return the number of entries removed. + return count +`; + +export type ClientExecutionResult = + | { + client: Client; + vote: "for"; + value: number; + } + | { + client: Client; + vote: "against"; + error: Error; + }; + +/* + * This object contains a summary of results. + */ +export type ExecutionStats = { + readonly membershipSize: number; + readonly quorumSize: number; + readonly votesFor: Set; + readonly votesAgainst: Map; +}; + +/* + * This object contains a summary of results. Because the result of an attempt + * can sometimes be determined before all requests are finished, each attempt + * contains a Promise that will resolve ExecutionStats once all requests are + * finished. A rejection of these promises should be considered undefined + * behavior and should cause a crash. + */ +export type ExecutionResult = { + attempts: ReadonlyArray>; + start: number; +}; + +/** + * + */ +export interface Settings { + readonly driftFactor: number; + readonly retryCount: number; + readonly retryDelay: number; + readonly retryJitter: number; + readonly automaticExtensionThreshold: number; +} + +// Define default settings. +const defaultSettings: Readonly = { + driftFactor: 0.01, + retryCount: 10, + retryDelay: 200, + retryJitter: 100, + automaticExtensionThreshold: 500 +}; + +// Modifyng this object is forbidden. +Object.freeze(defaultSettings); + +/* + * This error indicates a failure due to the existence of another lock for one + * or more of the requested resources. + */ +export class ResourceLockedError extends Error { + constructor(public readonly message: string) { + super(); + this.name = "ResourceLockedError"; + } +} + +/* + * This error indicates a failure of an operation to pass with a quorum. + */ +export class ExecutionError extends Error { + constructor( + public readonly message: string, + public readonly attempts: ReadonlyArray> + ) { + super(); + this.name = "ExecutionError"; + } +} + +/* + * An object of this type is returned when a resource is successfully locked. It + * contains convenience methods `release` and `extend` which perform the + * associated Redlock method on itself. + */ +export class Lock { + constructor( + public readonly redlock: Redlock, + public readonly resources: string[], + public readonly value: string, + public readonly attempts: ReadonlyArray>, + public expiration: number + ) {} + + async release(): Promise { + return this.redlock.release(this); + } + + async extend(duration: number): Promise { + return this.redlock.extend(this, duration); + } +} + +export type RedlockAbortSignal = AbortSignal & { error?: Error }; + +/** + * A redlock object is instantiated with an array of at least one redis client + * and an optional `options` object. Properties of the Redlock object should NOT + * be changed after it is first used, as doing so could have unintended + * consequences for live locks. + */ +export class Redlock extends EventEmitter { + public readonly clients: Set; + public readonly settings: Settings; + public readonly scripts: { + readonly acquireScript: { value: string; hash: string }; + readonly extendScript: { value: string; hash: string }; + readonly releaseScript: { value: string; hash: string }; + }; + + public constructor( + clients: Iterable, + settings: Partial = {}, + scripts: { + readonly acquireScript?: string | ((script: string) => string); + readonly extendScript?: string | ((script: string) => string); + readonly releaseScript?: string | ((script: string) => string); + } = {} + ) { + super(); + + // Prevent crashes on error events. + this.on("error", () => { + // Because redlock is designed for high availability, it does not care if + // a minority of redis instances/clusters fail at an operation. + // + // However, it can be helpful to monitor and log such cases. Redlock emits + // an "error" event whenever it encounters an error, even if the error is + // ignored in its normal operation. + // + // This function serves to prevent node's default behavior of crashing + // when an "error" event is emitted in the absence of listeners. + }); + + // Create a new array of client, to ensure no accidental mutation. + this.clients = new Set(clients); + if (this.clients.size === 0) { + throw new Error("Redlock must be instantiated with at least one redis client."); + } + + // Customize the settings for this instance. + this.settings = { + driftFactor: typeof settings.driftFactor === "number" ? settings.driftFactor : defaultSettings.driftFactor, + retryCount: typeof settings.retryCount === "number" ? settings.retryCount : defaultSettings.retryCount, + retryDelay: typeof settings.retryDelay === "number" ? settings.retryDelay : defaultSettings.retryDelay, + retryJitter: typeof settings.retryJitter === "number" ? settings.retryJitter : defaultSettings.retryJitter, + automaticExtensionThreshold: + typeof settings.automaticExtensionThreshold === "number" + ? settings.automaticExtensionThreshold + : defaultSettings.automaticExtensionThreshold + }; + + // Use custom scripts and script modifiers. + const acquireScript = + typeof scripts.acquireScript === "function" ? scripts.acquireScript(ACQUIRE_SCRIPT) : ACQUIRE_SCRIPT; + const extendScript = + typeof scripts.extendScript === "function" ? scripts.extendScript(EXTEND_SCRIPT) : EXTEND_SCRIPT; + const releaseScript = + typeof scripts.releaseScript === "function" ? scripts.releaseScript(RELEASE_SCRIPT) : RELEASE_SCRIPT; + + this.scripts = { + acquireScript: { + value: acquireScript, + hash: this._hash(acquireScript) + }, + extendScript: { + value: extendScript, + hash: this._hash(extendScript) + }, + releaseScript: { + value: releaseScript, + hash: this._hash(releaseScript) + } + }; + } + + /** + * Generate a sha1 hash compatible with redis evalsha. + */ + private _hash(value: string): string { + return createHash("sha1").update(value).digest("hex"); + } + + /** + * Generate a cryptographically random string. + */ + private _random(): string { + return randomBytes(16).toString("hex"); + } + + /** + * This method runs `.quit()` on all client connections. + */ + public async quit(): Promise { + const results = []; + for (const client of this.clients) { + results.push(client.quit()); + } + + await Promise.all(results); + } + + /** + * This method acquires a locks on the resources for the duration specified by + * the `duration`. + */ + public async acquire(resources: string[], duration: number, settings?: Partial): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + const value = this._random(); + + try { + const { attempts, start } = await this._execute( + this.scripts.acquireScript, + resources, + [value, duration], + settings + ); + + // Add 2 milliseconds to the drift to account for Redis expires precision, + // which is 1 ms, plus the configured allowable drift factor. + const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; + + return new Lock(this, resources, value, attempts, start + duration - drift); + } catch (error) { + // If there was an error acquiring the lock, release any partial lock + // state that may exist on a minority of clients. + await this._execute(this.scripts.releaseScript, resources, [value], { + retryCount: 0 + }).catch(() => { + // Any error here will be ignored. + }); + + throw error; + } + } + + /** + * This method unlocks the provided lock from all servers still persisting it. + * It will fail with an error if it is unable to release the lock on a quorum + * of nodes, but will make no attempt to restore the lock in the case of a + * failure to release. It is safe to re-attempt a release or to ignore the + * error, as the lock will automatically expire after its timeout. + */ + public async release(lock: Lock, settings?: Partial): Promise { + // Immediately invalidate the lock. + lock.expiration = 0; + + // Attempt to release the lock. + return this._execute(this.scripts.releaseScript, lock.resources, [lock.value], settings); + } + + /** + * This method extends a valid lock by the provided `duration`. + */ + public async extend(existing: Lock, duration: number, settings?: Partial): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + // The lock has already expired. + if (existing.expiration < Date.now()) { + throw new ExecutionError("Cannot extend an already-expired lock.", []); + } + + const { attempts, start } = await this._execute( + this.scripts.extendScript, + existing.resources, + [existing.value, duration], + settings + ); + + // Invalidate the existing lock. + existing.expiration = 0; + + // Add 2 milliseconds to the drift to account for Redis expires precision, + // which is 1 ms, plus the configured allowable drift factor. + const drift = Math.round((settings?.driftFactor ?? this.settings.driftFactor) * duration) + 2; + + const replacement = new Lock(this, existing.resources, existing.value, attempts, start + duration - drift); + + return replacement; + } + + /** + * Execute a script on all clients. The resulting promise is resolved or + * rejected as soon as this quorum is reached; the resolution or rejection + * will contains a `stats` property that is resolved once all votes are in. + */ + private async _execute( + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[], + _settings?: Partial + ): Promise { + const settings = _settings + ? { + ...this.settings, + ..._settings + } + : this.settings; + + // For the purpose of easy config serialization, we treat a retryCount of + // -1 a equivalent to Infinity. + const maxAttempts = settings.retryCount === -1 ? Infinity : settings.retryCount + 1; + + const attempts: Promise[] = []; + + while (true) { + const { vote, stats, start } = await this._attemptOperation(script, keys, args); + + attempts.push(stats); + + // The operation achieved a quorum in favor. + if (vote === "for") { + return { attempts, start }; + } + + // Wait before reattempting. + if (attempts.length < maxAttempts) { + await new Promise((resolve) => { + setTimeout( + resolve, + Math.max(0, settings.retryDelay + Math.floor((Math.random() * 2 - 1) * settings.retryJitter)), + undefined + ); + }); + } else { + throw new ExecutionError("The operation was unable to achieve a quorum during its retry window.", attempts); + } + } + } + + private async _attemptOperation( + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[] + ): Promise< + | { vote: "for"; stats: Promise; start: number } + | { vote: "against"; stats: Promise; start: number } + > { + const start = Date.now(); + + return await new Promise((resolve) => { + const clientResults = []; + for (const client of this.clients) { + clientResults.push(this._attemptOperationOnClient(client, script, keys, args)); + } + + const stats: ExecutionStats = { + membershipSize: clientResults.length, + quorumSize: Math.floor(clientResults.length / 2) + 1, + votesFor: new Set(), + votesAgainst: new Map() + }; + + let done: () => void; + const statsPromise = new Promise((resolve) => { + done = () => resolve(stats); + }); + + // This is the expected flow for all successful and unsuccessful requests. + const onResultResolve = (clientResult: ClientExecutionResult): void => { + switch (clientResult.vote) { + case "for": + stats.votesFor.add(clientResult.client); + break; + case "against": + stats.votesAgainst.set(clientResult.client, clientResult.error); + break; + } + + // A quorum has determined a success. + if (stats.votesFor.size === stats.quorumSize) { + resolve({ + vote: "for", + stats: statsPromise, + start + }); + } + + // A quorum has determined a failure. + if (stats.votesAgainst.size === stats.quorumSize) { + resolve({ + vote: "against", + stats: statsPromise, + start + }); + } + + // All votes are in. + if (stats.votesFor.size + stats.votesAgainst.size === stats.membershipSize) { + done(); + } + }; + + // This is unexpected and should crash to prevent undefined behavior. + const onResultReject = (error: Error): void => { + throw error; + }; + + for (const result of clientResults) { + result.then(onResultResolve, onResultReject); + } + }); + } + + private async _attemptOperationOnClient( + client: Client, + script: { value: string; hash: string }, + keys: string[], + args: (string | number)[] + ): Promise { + try { + let result: number; + try { + // Attempt to evaluate the script by its hash. + // @ts-expect-error + const shaResult = (await client.evalsha(script.hash, keys.length, [...keys, ...args])) as unknown; + + if (typeof shaResult !== "number") { + throw new Error(`Unexpected result of type ${typeof shaResult} returned from redis.`); + } + + result = shaResult; + } catch (error) { + // If the redis server does not already have the script cached, + // reattempt the request with the script's raw text. + if (!(error instanceof Error) || !error.message.startsWith("NOSCRIPT")) { + throw error; + } + // @ts-expect-error + const rawResult = (await client.eval(script.value, keys.length, [...keys, ...args])) as unknown; + + if (typeof rawResult !== "number") { + throw new Error(`Unexpected result of type ${typeof rawResult} returned from redis.`); + } + + result = rawResult; + } + + // One or more of the resources was already locked. + if (result !== keys.length) { + throw new ResourceLockedError( + `The operation was applied to: ${result} of the ${keys.length} requested resources.` + ); + } + + return { + vote: "for", + client, + value: result + }; + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`Unexpected type ${typeof error} thrown with value: ${error}`); + } + + // Emit the error on the redlock instance for observability. + this.emit("error", error); + + return { + vote: "against", + client, + error + }; + } + } + + /** + * Wrap and execute a routine in the context of an auto-extending lock, + * returning a promise of the routine's value. In the case that auto-extension + * fails, an AbortSignal will be updated to indicate that abortion of the + * routine is in order, and to pass along the encountered error. + * + * @example + * ```ts + * await redlock.using([senderId, recipientId], 5000, { retryCount: 5 }, async (signal) => { + * const senderBalance = await getBalance(senderId); + * const recipientBalance = await getBalance(recipientId); + * + * if (senderBalance < amountToSend) { + * throw new Error("Insufficient balance."); + * } + * + * // The abort signal will be true if: + * // 1. the above took long enough that the lock needed to be extended + * // 2. redlock was unable to extend the lock + * // + * // In such a case, exclusivity can no longer be guaranteed for further + * // operations, and should be handled as an exceptional case. + * if (signal.aborted) { + * throw signal.error; + * } + * + * await setBalances([ + * {id: senderId, balance: senderBalance - amountToSend}, + * {id: recipientId, balance: recipientBalance + amountToSend}, + * ]); + * }); + * ``` + */ + + public async using( + resources: string[], + duration: number, + settings: Partial, + routine?: (signal: RedlockAbortSignal) => Promise + ): Promise; + + public async using( + resources: string[], + duration: number, + routine: (signal: RedlockAbortSignal) => Promise + ): Promise; + + public async using( + resources: string[], + duration: number, + settingsOrRoutine: undefined | Partial | ((signal: RedlockAbortSignal) => Promise), + optionalRoutine?: (signal: RedlockAbortSignal) => Promise + ): Promise { + if (Math.floor(duration) !== duration) { + throw new Error("Duration must be an integer value in milliseconds."); + } + + const settings = + settingsOrRoutine && typeof settingsOrRoutine !== "function" + ? { + ...this.settings, + ...settingsOrRoutine + } + : this.settings; + + const routine = optionalRoutine ?? settingsOrRoutine; + if (typeof routine !== "function") { + throw new Error("INVARIANT: routine is not a function."); + } + + if (settings.automaticExtensionThreshold > duration - 100) { + throw new Error( + "A lock `duration` must be at least 100ms greater than the `automaticExtensionThreshold` setting." + ); + } + + // The AbortController/AbortSignal pattern allows the routine to be notified + // of a failure to extend the lock, and subsequent expiration. In the event + // of an abort, the error object will be made available at `signal.error`. + const controller = new AbortController(); + + const signal = controller.signal as RedlockAbortSignal; + + function queue(): void { + timeout = setTimeout( + () => (extension = extend()), + lock.expiration - Date.now() - settings.automaticExtensionThreshold + ); + } + + async function extend(): Promise { + timeout = undefined; + + try { + lock = await lock.extend(duration); + queue(); + } catch (error) { + if (!(error instanceof Error)) { + throw new Error(`Unexpected thrown ${typeof error}: ${error}.`); + } + + if (lock.expiration > Date.now()) { + return (extension = extend()); + } + + signal.error = error instanceof Error ? error : new Error(`${error}`); + controller.abort(); + } + } + + let timeout: undefined | NodeJS.Timeout; + let extension: undefined | Promise; + let lock = await this.acquire(resources, duration, settings); + queue(); + + try { + return await routine(signal); + } finally { + // Clean up the timer. + if (timeout) { + clearTimeout(timeout); + timeout = undefined; + } + + // Wait for an in-flight extension to finish. + if (extension) { + await extension.catch(() => { + // An error here doesn't matter at all, because the routine has + // already completed, and a release will be attempted regardless. The + // only reason for waiting here is to prevent possible contention + // between the extension and release. + }); + } + + await lock.release(); + } + } +} diff --git a/backend/src/lib/zod/index.ts b/backend/src/lib/zod/index.ts index a3cded66b..4d3fea8c7 100644 --- a/backend/src/lib/zod/index.ts +++ b/backend/src/lib/zod/index.ts @@ -7,3 +7,7 @@ export const zpStr = (schema: T, opt: { stripNull: boolean if (typeof val !== "string") return val; return val.trim() || undefined; }, schema); + +export const zodBuffer = z.custom((data) => Buffer.isBuffer(data) || data instanceof Uint8Array, { + message: "Expected binary data (Buffer Or Uint8Array)" +}); diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index bc8ac88ff..d51a8e683 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -7,33 +7,44 @@ import { TScanFullRepoEventPayload, TScanPushEventPayload } from "@app/ee/services/secret-scanning/secret-scanning-queue/secret-scanning-queue-types"; +import { TSyncSecretsDTO } from "@app/services/secret/secret-types"; export enum QueueName { SecretRotation = "secret-rotation", SecretReminder = "secret-reminder", AuditLog = "audit-log", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune", + DailyResourceCleanUp = "daily-resource-cleanup", TelemetryInstanceStats = "telemtry-self-hosted-stats", IntegrationSync = "sync-integrations", SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", - DynamicSecretRevocation = "dynamic-secret-revocation" + DynamicSecretRevocation = "dynamic-secret-revocation", + CaCrlRotation = "ca-crl-rotation", + SecretReplication = "secret-replication", + SecretSync = "secret-sync" // parent queue to push integration sync, webhook, and secret replication } export enum QueueJobs { SecretReminder = "secret-reminder-job", SecretRotation = "secret-rotation-job", AuditLog = "audit-log-job", + // TODO(akhilmhdh): This will get removed later. For now this is kept to stop the repeatable queue AuditLogPrune = "audit-log-prune-job", + DailyResourceCleanUp = "daily-resource-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", SecretScan = "secret-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost-job", DynamicSecretRevocation = "dynamic-secret-revocation", - DynamicSecretPruning = "dynamic-secret-pruning" + 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 } export type TQueueJobTypes = { @@ -46,7 +57,6 @@ export type TQueueJobTypes = { }; name: QueueJobs.SecretReminder; }; - [QueueName.SecretRotation]: { payload: { rotationId: string }; name: QueueJobs.SecretRotation; @@ -55,6 +65,10 @@ export type TQueueJobTypes = { name: QueueJobs.AuditLog; payload: TCreateAuditLogDTO; }; + [QueueName.DailyResourceCleanUp]: { + name: QueueJobs.DailyResourceCleanUp; + payload: undefined; + }; [QueueName.AuditLogPrune]: { name: QueueJobs.AuditLogPrune; payload: undefined; @@ -108,6 +122,20 @@ export type TQueueJobTypes = { dynamicSecretCfgId: string; }; }; + [QueueName.CaCrlRotation]: { + name: QueueJobs.CaCrlRotation; + payload: { + caId: string; + }; + }; + [QueueName.SecretReplication]: { + name: QueueJobs.SecretReplication; + payload: TSyncSecretsDTO; + }; + [QueueName.SecretSync]: { + name: QueueJobs.SecretSync; + payload: TSyncSecretsDTO; + }; }; export type TQueueServiceFactory = ReturnType; @@ -124,7 +152,7 @@ export const queueServiceFactory = (redisUrl: string) => { const start = ( name: T, - jobFn: (job: Job) => Promise, + jobFn: (job: Job, token?: string) => Promise, queueSettings: Omit = {} ) => { if (queueContainer[name]) { @@ -158,7 +186,7 @@ export const queueServiceFactory = (redisUrl: string) => { name: T, job: TQueueJobTypes[T]["name"], data: TQueueJobTypes[T]["payload"], - opts: JobsOptions & { jobId?: string } + opts?: JobsOptions & { jobId?: string } ) => { const q = queueContainer[name]; @@ -172,7 +200,9 @@ export const queueServiceFactory = (redisUrl: string) => { jobId?: string ) => { const q = queueContainer[name]; - return q.removeRepeatable(job, repeatOpt, jobId); + if (q) { + return q.removeRepeatable(job, repeatOpt, jobId); + } }; const stopRepeatableJobByJobId = async (name: T, jobId: string) => { 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 6c92de62c..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: 50, + max: () => getRateLimiterConfig().writeLimit, keyGenerator: (req) => req.realIp }; @@ -36,25 +37,48 @@ 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: () => getRateLimiterConfig().mfaRateLimit, + keyGenerator: (req) => { + return req.headers.authorization?.split(" ")[1] || req.realIp; + } +}; + export const creationLimit: RateLimitOptions = { // identity, project, org timeWindow: 60 * 1000, - max: 30, + max: () => getRateLimiterConfig().creationLimit, + keyGenerator: (req) => req.realIp +}; + +// Public endpoints to avoid brute force attacks +export const publicEndpointLimit: RateLimitOptions = { + // Read Shared Secrets + timeWindow: 60 * 1000, + max: () => getRateLimiterConfig().publicEndpointLimit, + keyGenerator: (req) => req.realIp +}; + +export const publicSecretShareCreationLimit: RateLimitOptions = { + // Create Shared Secrets + timeWindow: 60 * 1000, + max: 5, keyGenerator: (req) => req.realIp }; diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index c8da4077a..3320c7d87 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -6,6 +6,7 @@ import { BadRequestError, DatabaseError, InternalServerError, + NotFoundError, ScimRequestError, UnauthorizedError } from "@app/lib/errors"; @@ -15,6 +16,8 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider req.log.error(error); if (error instanceof BadRequestError) { void res.status(400).send({ statusCode: 400, message: error.message, error: error.name }); + } else if (error instanceof NotFoundError) { + void res.status(404).send({ statusCode: 404, message: error.message, error: error.name }); } else if (error instanceof UnauthorizedError) { void res.status(403).send({ statusCode: 403, message: error.message, error: error.name }); } else if (error instanceof DatabaseError || error instanceof InternalServerError) { diff --git a/backend/src/server/plugins/ip.ts b/backend/src/server/plugins/ip.ts index b3c8171af..7b5838d57 100644 --- a/backend/src/server/plugins/ip.ts +++ b/backend/src/server/plugins/ip.ts @@ -6,6 +6,7 @@ const headersOrder = [ "cf-connecting-ip", // Cloudflare "Cf-Pseudo-IPv4", // Cloudflare "x-client-ip", // Most common + "x-envoy-external-address", // for envoy "x-forwarded-for", // Mostly used by proxies "fastly-client-ip", "true-client-ip", // Akamai and Cloudflare @@ -23,7 +24,21 @@ export const fastifyIp = fp(async (fastify) => { const forwardedIpHeader = headersOrder.find((header) => Boolean(req.headers[header])); const forwardedIp = forwardedIpHeader ? req.headers[forwardedIpHeader] : undefined; if (forwardedIp) { - req.realIp = Array.isArray(forwardedIp) ? forwardedIp[0] : forwardedIp; + if (Array.isArray(forwardedIp)) { + // eslint-disable-next-line + req.realIp = forwardedIp[0]; + return; + } + + if (forwardedIp.includes(",")) { + // the ip header when placed with load balancers that proxy request + // will attach the internal ips to header by appending with comma + // https://github.com/go-chi/chi/blob/master/middleware/realip.go + const clientIPFromProxy = forwardedIp.slice(0, forwardedIp.indexOf(",")).trim(); + req.realIp = clientIPFromProxy; + return; + } + req.realIp = forwardedIp; } else { req.realIp = req.ip; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 75c43a9aa..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"; @@ -44,6 +49,7 @@ import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approva import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal"; import { secretApprovalRequestSecretDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-secret-dal"; import { secretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; +import { secretReplicationServiceFactory } from "@app/ee/services/secret-replication/secret-replication-service"; import { secretRotationDALFactory } from "@app/ee/services/secret-rotation/secret-rotation-dal"; import { secretRotationQueueFactory } from "@app/ee/services/secret-rotation/secret-rotation-queue"; import { secretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; @@ -70,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"; @@ -80,6 +94,8 @@ import { identityAccessTokenDALFactory } from "@app/services/identity-access-tok import { identityAccessTokenServiceFactory } from "@app/services/identity-access-token/identity-access-token-service"; import { identityAwsAuthDALFactory } from "@app/services/identity-aws-auth/identity-aws-auth-dal"; import { identityAwsAuthServiceFactory } from "@app/services/identity-aws-auth/identity-aws-auth-service"; +import { identityAzureAuthDALFactory } from "@app/services/identity-azure-auth/identity-azure-auth-dal"; +import { identityAzureAuthServiceFactory } from "@app/services/identity-azure-auth/identity-azure-auth-service"; import { identityGcpAuthDALFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-dal"; import { identityGcpAuthServiceFactory } from "@app/services/identity-gcp-auth/identity-gcp-auth-service"; import { identityKubernetesAuthDALFactory } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-dal"; @@ -94,6 +110,9 @@ import { integrationDALFactory } from "@app/services/integration/integration-dal import { integrationServiceFactory } from "@app/services/integration/integration-service"; import { integrationAuthDALFactory } from "@app/services/integration-auth/integration-auth-dal"; import { integrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; +import { kmsDALFactory } from "@app/services/kms/kms-dal"; +import { kmsRootConfigDALFactory } from "@app/services/kms/kms-root-config-dal"; +import { kmsServiceFactory } from "@app/services/kms/kms-service"; import { incidentContactDALFactory } from "@app/services/org/incident-contacts-dal"; import { orgBotDALFactory } from "@app/services/org/org-bot-dal"; import { orgDALFactory } from "@app/services/org/org-dal"; @@ -115,6 +134,7 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal"; import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal"; import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service"; +import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue"; import { secretDALFactory } from "@app/services/secret/secret-dal"; import { secretQueueFactory } from "@app/services/secret/secret-queue"; import { secretServiceFactory } from "@app/services/secret/secret-service"; @@ -127,6 +147,8 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; +import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; +import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; import { secretTagServiceFactory } from "@app/services/secret-tag/secret-tag-service"; import { serviceTokenDALFactory } from "@app/services/service-token/service-token-dal"; @@ -176,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); @@ -212,8 +235,8 @@ export const registerRoutes = async ( const identityKubernetesAuthDAL = identityKubernetesAuthDALFactory(db); const identityUaClientSecretDAL = identityUaClientSecretDALFactory(db); const identityAwsAuthDAL = identityAwsAuthDALFactory(db); - const identityGcpAuthDAL = identityGcpAuthDALFactory(db); + const identityAzureAuthDAL = identityAzureAuthDALFactory(db); const auditLogDAL = auditLogDALFactory(db); const auditLogStreamDAL = auditLogStreamDALFactory(db); @@ -235,8 +258,8 @@ export const registerRoutes = async ( const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); - const sarReviewerDAL = secretApprovalRequestReviewerDALFactory(db); - const sarSecretDAL = secretApprovalRequestSecretDALFactory(db); + const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); + const secretApprovalRequestSecretDAL = secretApprovalRequestSecretDALFactory(db); const secretRotationDAL = secretRotationDALFactory(db); const snapshotDAL = snapshotDALFactory(db); @@ -250,10 +273,14 @@ export const registerRoutes = async ( const groupProjectMembershipRoleDAL = groupProjectMembershipRoleDALFactory(db); const userGroupMembershipDAL = userGroupMembershipDALFactory(db); const secretScanningDAL = secretScanningDALFactory(db); + const secretSharingDAL = secretSharingDALFactory(db); const licenseDAL = licenseDALFactory(db); const dynamicSecretDAL = dynamicSecretDALFactory(db); const dynamicSecretLeaseDAL = dynamicSecretLeaseDALFactory(db); + const kmsDAL = kmsDALFactory(db); + const kmsRootConfigDAL = kmsRootConfigDALFactory(db); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -262,6 +289,12 @@ export const registerRoutes = async ( projectDAL }); const licenseService = licenseServiceFactory({ permissionService, orgDAL, licenseDAL, keyStore }); + const kmsService = kmsServiceFactory({ + kmsRootConfigDAL, + keyStore, + kmsDAL + }); + const trustedIpService = trustedIpServiceFactory({ licenseService, projectDAL, @@ -282,7 +315,7 @@ export const registerRoutes = async ( permissionService, auditLogStreamDAL }); - const sapService = secretApprovalPolicyServiceFactory({ + const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectMembershipDAL, projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, @@ -425,6 +458,10 @@ export const registerRoutes = async ( orgService, keyStore }); + const rateLimitService = rateLimitServiceFactory({ + rateLimitDAL, + licenseService + }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); const secretScanningQueue = secretScanningQueueFactory({ @@ -483,10 +520,62 @@ export const registerRoutes = async ( projectBotDAL, projectMembershipDAL, secretApprovalRequestDAL, - secretApprovalSecretDAL: sarSecretDAL, + secretApprovalSecretDAL: secretApprovalRequestSecretDAL, 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, @@ -503,6 +592,8 @@ export const registerRoutes = async ( projectMembershipDAL, folderDAL, licenseService, + certificateAuthorityDAL, + certificateDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore @@ -520,7 +611,8 @@ export const registerRoutes = async ( permissionService, projectRoleDAL, projectUserMembershipRoleDAL, - identityProjectMembershipRoleDAL + identityProjectMembershipRoleDAL, + projectDAL }); const snapshotService = secretSnapshotServiceFactory({ @@ -580,6 +672,7 @@ export const registerRoutes = async ( secretVersionTagDAL }); const secretImportService = secretImportServiceFactory({ + licenseService, projectEnvDAL, folderDAL, permissionService, @@ -608,19 +701,24 @@ export const registerRoutes = async ( projectEnvDAL, projectBotService }); - const sarService = secretApprovalRequestServiceFactory({ + + const secretSharingService = secretSharingServiceFactory({ + permissionService, + secretSharingDAL + }); + + const secretApprovalRequestService = secretApprovalRequestServiceFactory({ permissionService, projectBotService, folderDAL, secretDAL, secretTagDAL, - secretApprovalRequestSecretDAL: sarSecretDAL, - secretApprovalRequestReviewerDAL: sarReviewerDAL, + secretApprovalRequestSecretDAL, + secretApprovalRequestReviewerDAL, projectDAL, secretVersionDAL, secretBlindIndexDAL, secretApprovalRequestDAL, - secretService, snapshotService, secretVersionTagDAL, secretQueueService @@ -649,6 +747,23 @@ export const registerRoutes = async ( accessApprovalPolicyApproverDAL }); + const secretReplicationService = secretReplicationServiceFactory({ + secretTagDAL, + secretVersionTagDAL, + secretDAL, + secretVersionDAL, + secretImportDAL, + keyStore, + queueService, + folderDAL, + secretApprovalPolicyService, + secretBlindIndexDAL, + secretApprovalRequestDAL, + secretApprovalRequestSecretDAL, + secretQueueService, + projectMembershipDAL, + projectBotService + }); const secretRotationQueue = secretRotationQueueFactory({ telemetryService, secretRotationDAL, @@ -742,6 +857,15 @@ export const registerRoutes = async ( permissionService }); + const identityAzureAuthService = identityAzureAuthServiceFactory({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService + }); + const dynamicSecretProviders = buildDynamicSecretProviders(); const dynamicSecretQueueService = dynamicSecretLeaseQueueServiceFactory({ queueService, @@ -769,14 +893,24 @@ export const registerRoutes = async ( folderDAL, licenseService }); + const dailyResourceCleanUp = dailyResourceCleanUpQueueServiceFactory({ + auditLogDAL, + queueService, + secretVersionDAL, + secretFolderVersionDAL: folderVersionDAL, + snapshotDAL, + identityAccessTokenDAL, + secretSharingDAL + }); await superAdminService.initServerCfg(); // // setup the communication with license key server await licenseService.init(); - await auditLogQueue.startAuditLogPruneJob(); await telemetryQueue.startTelemetryCheck(); + await dailyResourceCleanUp.startCleanUp(); + await kmsService.startService(); // inject all services server.decorate("services", { @@ -798,7 +932,9 @@ export const registerRoutes = async ( projectEnv: projectEnvService, projectRole: projectRoleService, secret: secretService, + secretReplication: secretReplicationService, secretTag: secretTagService, + rateLimit: rateLimitService, folder: folderService, secretImport: secretImportService, projectBot: projectBotService, @@ -813,10 +949,11 @@ export const registerRoutes = async ( identityKubernetesAuth: identityKubernetesAuthService, identityGcpAuth: identityGcpAuthService, identityAwsAuth: identityAwsAuthService, - secretApprovalPolicy: sapService, + identityAzureAuth: identityAzureAuthService, accessApprovalPolicy: accessApprovalPolicyService, accessApprovalRequest: accessApprovalRequestService, - secretApprovalRequest: sarService, + secretApprovalPolicy: secretApprovalPolicyService, + secretApprovalRequest: secretApprovalRequestService, secretRotation: secretRotationService, dynamicSecret: dynamicSecretService, dynamicSecretLease: dynamicSecretLeaseService, @@ -825,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, @@ -832,9 +972,18 @@ export const registerRoutes = async ( secretBlindIndex: secretBlindIndexService, telemetry: telemetryService, projectUserAdditionalPrivilege: projectUserAdditionalPrivilegeService, - identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService + identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, + secretSharing: secretSharingService }); + const cronJobs: CronJob[] = []; + if (appCfg.isProductionMode) { + const rateLimitSyncJob = await rateLimitService.initializeBackgroundSync(); + if (rateLimitSyncJob) { + cronJobs.push(rateLimitSyncJob); + } + } + server.decorate("store", { user: userDAL }); @@ -857,7 +1006,8 @@ export const registerRoutes = async ( emailConfigured: z.boolean().optional(), inviteOnlySignup: z.boolean().optional(), redisConfigured: z.boolean().optional(), - secretScanningConfigured: z.boolean().optional() + secretScanningConfigured: z.boolean().optional(), + samlDefaultOrgSlug: z.string().optional() }) } }, @@ -870,7 +1020,8 @@ export const registerRoutes = async ( emailConfigured: cfg.isSmtpConfigured, inviteOnlySignup: Boolean(serverCfg.allowSignUp), redisConfigured: cfg.isRedisConfigured, - secretScanningConfigured: cfg.isSecretScanningConfigured + secretScanningConfigured: cfg.isSecretScanningConfigured, + samlDefaultOrgSlug: cfg.samlDefaultOrgSlug }; } }); @@ -887,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/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index cf9f23851..5b0b754f3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -4,6 +4,7 @@ import { DynamicSecretsSchema, IdentityProjectAdditionalPrivilegeSchema, IntegrationAuthsSchema, + ProjectRolesSchema, SecretApprovalPoliciesSchema, UsersSchema } from "@app/db/schemas"; @@ -88,10 +89,38 @@ export const ProjectPermissionSchema = z.object({ .optional() }); +export const ProjectSpecificPrivilegePermissionSchema = z.object({ + actions: z + .nativeEnum(ProjectPermissionActions) + .describe("Describe what action an entity can take. Possible actions: create, edit, delete, and read") + .array() + .min(1), + subject: z + .enum([ProjectPermissionSub.Secrets]) + .describe("The entity this permission pertains to. Possible options: secrets, environments"), + conditions: z + .object({ + environment: z.string().describe("The environment slug this permission should allow."), + secretPath: z + .object({ + $glob: z + .string() + .min(1) + .describe("The secret path this permission should allow. Can be a glob pattern such as /folder-name/*/** ") + }) + .optional() + }) + .describe("When specified, only matching conditions will be allowed to access given resource.") +}); + export const SanitizedIdentityPrivilegeSchema = IdentityProjectAdditionalPrivilegeSchema.extend({ permissions: UnpackedPermissionSchema.array() }); +export const SanitizedRoleSchema = ProjectRolesSchema.extend({ + permissions: UnpackedPermissionSchema.array() +}); + export const SanitizedDynamicSecretSchema = DynamicSecretsSchema.omit({ inputIV: true, inputTag: true, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 572409d9b..97c3449d9 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -79,6 +79,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ email: z.string().email().trim(), + password: z.string().trim(), firstName: z.string().trim(), lastName: z.string().trim().optional(), protectedKey: z.string().trim(), 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/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts new file mode 100644 index 000000000..d10cd131b --- /dev/null +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -0,0 +1,262 @@ +import { z } from "zod"; + +import { IdentityAzureAuthsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { validateAzureAuthField } from "@app/services/identity-azure-auth/identity-azure-auth-validators"; + +export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/azure-auth/login", + config: { + rateLimit: writeLimit + }, + schema: { + description: "Login with Azure Auth", + body: z.object({ + identityId: z.string(), + jwt: z.string() + }), + response: { + 200: z.object({ + accessToken: z.string(), + expiresIn: z.coerce.number(), + accessTokenMaxTTL: z.coerce.number(), + tokenType: z.literal("Bearer") + }) + } + }, + handler: async (req) => { + const { identityAzureAuth, accessToken, identityAccessToken, identityMembershipOrg } = + await server.services.identityAzureAuth.login(req.body); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityMembershipOrg.orgId, + event: { + type: EventType.LOGIN_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + identityAzureAuthId: identityAzureAuth.id + } + } + }); + + return { + accessToken, + tokenType: "Bearer" as const, + expiresIn: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL + }; + } + }); + + server.route({ + method: "POST", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Attach Azure Auth configuration onto identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim(), + resource: z.string().trim(), + allowedServicePrincipalIds: validateAzureAuthField, + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .default([{ ipAddress: "0.0.0.0/0" }, { ipAddress: "::/0" }]), + accessTokenTTL: z + .number() + .int() + .min(1) + .refine((value) => value !== 0, { + message: "accessTokenTTL must have a non zero number" + }) + .default(2592000), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .default(2592000), + accessTokenNumUsesLimit: z.number().int().min(0).default(0) + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.attachAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.ADD_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "PATCH", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().trim() + }), + body: z.object({ + tenantId: z.string().trim().optional(), + resource: z.string().trim().optional(), + allowedServicePrincipalIds: validateAzureAuthField.optional(), + accessTokenTrustedIps: z + .object({ + ipAddress: z.string().trim() + }) + .array() + .min(1) + .optional(), + accessTokenTTL: z.number().int().min(0).optional(), + accessTokenNumUsesLimit: z.number().int().min(0).optional(), + accessTokenMaxTTL: z + .number() + .int() + .refine((value) => value !== 0, { + message: "accessTokenMaxTTL must have a non zero number" + }) + .optional() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.updateAzureAuth({ + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + ...req.body, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.UPDATE_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId, + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenTrustedIps: identityAzureAuth.accessTokenTrustedIps as TIdentityTrustedIp[], + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + } + } + }); + + return { identityAzureAuth }; + } + }); + + server.route({ + method: "GET", + url: "/azure-auth/identities/:identityId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Retrieve Azure Auth configuration on identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string() + }), + response: { + 200: z.object({ + identityAzureAuth: IdentityAzureAuthsSchema + }) + } + }, + handler: async (req) => { + const identityAzureAuth = await server.services.identityAzureAuth.getAzureAuth({ + identityId: req.params.identityId, + actor: req.permission.type, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: identityAzureAuth.orgId, + event: { + type: EventType.GET_IDENTITY_AZURE_AUTH, + metadata: { + identityId: identityAzureAuth.identityId + } + } + }); + + return { identityAzureAuth }; + } + }); +}; diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 58654f220..34940eb13 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -160,9 +160,9 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) }), body: z.object({ type: z.enum(["iam", "gce"]).optional(), - allowedServiceAccounts: validateGcpAuthField, - allowedProjects: validateGcpAuthField, - allowedZones: validateGcpAuthField, + allowedServiceAccounts: validateGcpAuthField.optional(), + allowedProjects: validateGcpAuthField.optional(), + allowedZones: validateGcpAuthField.optional(), accessTokenTrustedIps: z .object({ ipAddress: z.string().trim() diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index d20ea0edc..227345916 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -198,7 +198,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide }), response: { 200: z.object({ - identityKubernetesAuth: IdentityKubernetesAuthsSchema + identityKubernetesAuth: IdentityKubernetesAuthResponseSchema }) } }, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 262e3cb20..eee7dac65 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,8 +1,11 @@ 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"; import { registerIdentityGcpAuthRouter } from "./identity-gcp-auth-router"; import { registerIdentityKubernetesRouter } from "./identity-kubernetes-auth-router"; import { registerIdentityRouter } from "./identity-router"; @@ -18,6 +21,7 @@ import { registerProjectMembershipRouter } from "./project-membership-router"; import { registerProjectRouter } from "./project-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; +import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; @@ -34,6 +38,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await authRouter.register(registerIdentityGcpAuthRouter); await authRouter.register(registerIdentityAccessTokenRouter); await authRouter.register(registerIdentityAwsAuthRouter); + await authRouter.register(registerIdentityAzureAuthRouter); }, { prefix: "/auth" } ); @@ -58,9 +63,18 @@ 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" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register(registerSecretSharingRouter, { prefix: "/secret-sharing" }); }; diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index d9db7404e..899c1cac8 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -330,7 +330,7 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) teams: z .object({ name: z.string(), - id: z.string().optional() + id: z.string() }) .array() }) diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index f23abc45b..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) => { @@ -42,39 +42,11 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { targetService: z.string().trim().optional().describe(INTEGRATION.CREATE.targetService), targetServiceId: z.string().trim().optional().describe(INTEGRATION.CREATE.targetServiceId), owner: z.string().trim().optional().describe(INTEGRATION.CREATE.owner), + url: z.string().trim().optional().describe(INTEGRATION.CREATE.url), 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({ @@ -160,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/password-router.ts b/backend/src/server/routes/v1/password-router.ts index a8ef3fb77..c94e5d4cb 100644 --- a/backend/src/server/routes/v1/password-router.ts +++ b/backend/src/server/routes/v1/password-router.ts @@ -51,7 +51,8 @@ export const registerPasswordRouter = async (server: FastifyZodProvider) => { encryptedPrivateKeyIV: z.string().trim(), encryptedPrivateKeyTag: z.string().trim(), salt: z.string().trim(), - verifier: z.string().trim() + verifier: z.string().trim(), + password: z.string().trim() }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/project-membership-router.ts b/backend/src/server/routes/v1/project-membership-router.ts index 6bbb8d7ef..4f92783c5 100644 --- a/backend/src/server/routes/v1/project-membership-router.ts +++ b/backend/src/server/routes/v1/project-membership-router.ts @@ -309,4 +309,32 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider return { membership }; } }); + + server.route({ + method: "DELETE", + url: "/:workspaceId/leave", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const membership = await server.services.projectMembership.leaveProject({ + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.workspaceId + }); + return { membership }; + } + }); }; 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 d036fdbdd..ca604e738 100644 --- a/backend/src/server/routes/v1/secret-import-router.ts +++ b/backend/src/server/routes/v1/secret-import-router.ts @@ -29,7 +29,8 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => import: z.object({ 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).describe(SECRET_IMPORTS.CREATE.isReplication) }), response: { 200: z.object({ @@ -210,6 +211,49 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => } }); + server.route({ + method: "POST", + url: "/:secretImportId/replication-resync", + config: { + rateLimit: secretsLimit + }, + schema: { + description: "Resync secret replication of secret imports", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + secretImportId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.secretImportId) + }), + body: z.object({ + workspaceId: z.string().trim().describe(SECRET_IMPORTS.UPDATE.workspaceId), + environment: z.string().trim().describe(SECRET_IMPORTS.UPDATE.environment), + path: z.string().trim().default("/").transform(removeTrailingSlash).describe(SECRET_IMPORTS.UPDATE.path) + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { message } = await server.services.secretImport.resyncSecretImportReplication({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.secretImportId, + ...req.body, + projectId: req.body.workspaceId + }); + + return { message }; + } + }); + server.route({ method: "GET", url: "/", @@ -232,11 +276,9 @@ export const registerSecretImportRouter = async (server: FastifyZodProvider) => 200: z.object({ message: z.string(), secretImports: SecretImportsSchema.omit({ importEnv: true }) - .merge( - z.object({ - importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) - }) - ) + .extend({ + importEnv: z.object({ name: z.string(), slug: z.string(), id: z.string() }) + }) .array() }) } diff --git a/backend/src/server/routes/v1/secret-sharing-router.ts b/backend/src/server/routes/v1/secret-sharing-router.ts new file mode 100644 index 000000000..4ec2737fb --- /dev/null +++ b/backend/src/server/routes/v1/secret-sharing-router.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; + +import { SecretSharingSchema } from "@app/db/schemas"; +import { + publicEndpointLimit, + publicSecretShareCreationLimit, + readLimit, + writeLimit +} from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretSharingRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.array(SecretSharingSchema) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const sharedSecrets = await req.server.services.secretSharing.getSharedSecrets({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + return sharedSecrets; + } + }); + + server.route({ + method: "GET", + url: "/public/:id", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + hashedHex: z.string() + }), + response: { + 200: SecretSharingSchema.pick({ + encryptedValue: true, + iv: true, + tag: true, + expiresAt: true, + expiresAfterViews: true + }) + } + }, + handler: async (req) => { + const sharedSecret = await req.server.services.secretSharing.getActiveSharedSecretByIdAndHashedHex( + req.params.id, + req.query.hashedHex + ); + if (!sharedSecret) return undefined; + return { + encryptedValue: sharedSecret.encryptedValue, + iv: sharedSecret.iv, + tag: sharedSecret.tag, + expiresAt: sharedSecret.expiresAt, + expiresAfterViews: sharedSecret.expiresAfterViews + }; + } + }); + + server.route({ + method: "POST", + url: "/public", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.string(), + expiresAfterViews: z.number() + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + handler: async (req) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; + const sharedSecret = await req.server.services.secretSharing.createPublicSharedSecret({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt: new Date(expiresAt), + expiresAfterViews + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: publicSecretShareCreationLimit + }, + schema: { + body: z.object({ + encryptedValue: z.string(), + iv: z.string(), + tag: z.string(), + hashedHex: z.string(), + expiresAt: z.string(), + expiresAfterViews: z.number() + }), + response: { + 200: z.object({ + id: z.string().uuid() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = req.body; + const sharedSecret = await req.server.services.secretSharing.createSharedSecret({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt: new Date(expiresAt), + expiresAfterViews + }); + return { id: sharedSecret.id }; + } + }); + + server.route({ + method: "DELETE", + url: "/:sharedSecretId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + sharedSecretId: z.string().uuid() + }), + response: { + 200: SecretSharingSchema + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { sharedSecretId } = req.params; + const deletedSharedSecret = await req.server.services.secretSharing.deleteSharedSecretById({ + actor: req.permission.type, + actorId: req.permission.id, + orgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + sharedSecretId + }); + + return { ...deletedSharedSecret }; + } + }); +}; diff --git a/backend/src/server/routes/v1/secret-tag-router.ts b/backend/src/server/routes/v1/secret-tag-router.ts index 1715aa3c3..ce92409f6 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, @@ -36,6 +36,67 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_ID.tagId) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + + server.route({ + method: "GET", + url: "/:projectId/tags/slug/:tagSlug", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.projectId), + tagSlug: z.string().trim().describe(SECRET_TAGS.GET_TAG_BY_SLUG.tagSlug) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.getTagBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + slug: req.params.tagSlug, + projectId: req.params.projectId + }); + return { workspaceTag }; + } + }); + server.route({ method: "POST", url: "/:projectId/tags", @@ -57,7 +118,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, @@ -71,6 +132,42 @@ export const registerSecretTagRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "PATCH", + url: "/:projectId/tags/:tagId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim().describe(SECRET_TAGS.UPDATE.projectId), + tagId: z.string().trim().describe(SECRET_TAGS.UPDATE.tagId) + }), + body: z.object({ + name: z.string().trim().describe(SECRET_TAGS.UPDATE.name), + slug: z.string().trim().describe(SECRET_TAGS.UPDATE.slug), + color: z.string().trim().describe(SECRET_TAGS.UPDATE.color) + }), + response: { + 200: z.object({ + workspaceTag: SecretTagsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const workspaceTag = await server.services.secretTag.updateTag({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body, + id: req.params.tagId + }); + return { workspaceTag }; + } + }); + server.route({ method: "DELETE", url: "/:projectId/tags/:tagId", @@ -88,7 +185,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/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 60bbec7db..3d8d02e6f 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -259,4 +259,50 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { ); } }); + + server.route({ + url: "/token-exchange", + method: "POST", + schema: { + body: z.object({ + providerAuthToken: z.string(), + email: z.string() + }) + }, + handler: async (req, res) => { + const userAgent = req.headers["user-agent"]; + if (!userAgent) throw new Error("user agent header is required"); + + const data = await server.services.login.oauth2TokenExchange({ + email: req.body.email, + ip: req.realIp, + userAgent, + providerAuthToken: req.body.providerAuthToken + }); + + if (data.isMfaEnabled) { + return { mfaEnabled: true, token: data.token } as const; // for discriminated union + } + + void res.setCookie("jid", data.token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + return { + mfaEnabled: false, + encryptionVersion: data.user.encryptionVersion, + token: data.token.access, + publicKey: data.user.publicKey, + encryptedPrivateKey: data.user.encryptedPrivateKey, + iv: data.user.iv, + tag: data.user.tag, + protectedKey: data.user.protectedKey || null, + protectedKeyIV: data.user.protectedKeyIV || null, + protectedKeyTag: data.user.protectedKeyTag || null + } as const; + } + }); }; diff --git a/backend/src/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index bdede8a3a..b9269d66e 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -1,11 +1,15 @@ import { z } from "zod"; import { UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; -import { readLimit } from "@app/server/config/rateLimiter"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { authRateLimit, 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 registerUserRouter = async (server: FastifyZodProvider) => { + const appCfg = getConfig(); + server.route({ method: "GET", url: "/", @@ -15,7 +19,23 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { schema: { response: { 200: z.object({ - user: UsersSchema.merge(UserEncryptionKeysSchema.omit({ verifier: true })) + user: UsersSchema.merge( + UserEncryptionKeysSchema.pick({ + clientPublicKey: true, + serverPrivateKey: true, + encryptionVersion: true, + protectedKey: true, + protectedKeyIV: true, + protectedKeyTag: true, + publicKey: true, + encryptedPrivateKey: true, + iv: true, + tag: true, + salt: true, + verifier: true, + userId: true + }) + ) }) } }, @@ -25,4 +45,49 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { return { user }; } }); + + server.route({ + method: "GET", + url: "/private-key", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + privateKey: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), + handler: async (req) => { + const privateKey = await server.services.user.getUserPrivateKey(req.permission.id); + return { privateKey }; + } + }); + + server.route({ + method: "GET", + url: "/:userId/unlock", + config: { + rateLimit: authRateLimit + }, + schema: { + querystring: z.object({ + token: z.string().trim() + }), + params: z.object({ + userId: z.string() + }) + }, + handler: async (req, res) => { + try { + await server.services.user.unlockUser(req.params.userId, req.query.token); + } catch (err) { + logger.error(`User unlock failed for ${req.params.userId}`); + logger.error(err); + } + return res.redirect(`${appCfg.SITE_URL}/login`); + } + }); }; diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index 973804c7c..1c685866d 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; import { z } from "zod"; import { getConfig } from "@app/lib/config/env"; -import { writeLimit } from "@app/server/config/rateLimiter"; +import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { AuthModeMfaJwtTokenPayload, AuthTokenType } from "@app/services/auth/auth-type"; export const registerMfaRouter = async (server: FastifyZodProvider) => { @@ -34,7 +34,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/mfa/send", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { response: { @@ -53,7 +53,7 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { url: "/mfa/verify", method: "POST", config: { - rateLimit: writeLimit + rateLimit: mfaRateLimit }, schema: { body: z.object({ 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/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 1f15008c7..21dd32021 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -255,7 +255,23 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { description: "Retrieve the current user on the request", response: { 200: z.object({ - user: UsersSchema.merge(UserEncryptionKeysSchema.omit({ verifier: true })) + user: UsersSchema.merge( + UserEncryptionKeysSchema.pick({ + clientPublicKey: true, + serverPrivateKey: true, + encryptionVersion: true, + protectedKey: true, + protectedKeyIV: true, + protectedKeyTag: true, + publicKey: true, + encryptedPrivateKey: true, + iv: true, + tag: true, + salt: true, + verifier: true, + userId: true + }) + ) }) } }, diff --git a/backend/src/server/routes/v3/login-router.ts b/backend/src/server/routes/v3/login-router.ts index 900ad56d2..61a0c74e5 100644 --- a/backend/src/server/routes/v3/login-router.ts +++ b/backend/src/server/routes/v3/login-router.ts @@ -80,7 +80,9 @@ 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(), + password: z.string().optional() }), response: { 200: z.discriminatedUnion("mfaEnabled", [ @@ -106,11 +108,13 @@ 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, providerAuthToken: req.body.providerAuthToken, - clientProof: req.body.clientProof + clientProof: req.body.clientProof, + password: req.body.password }); if (data.isMfaEnabled) { diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 6fa574a69..e3f1528ad 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -8,8 +8,7 @@ import { SecretType, ServiceTokenScopes } from "@app/db/schemas"; -import { EventType } from "@app/ee/services/audit-log/audit-log-types"; -import { CommitType } from "@app/ee/services/secret-approval-request/secret-approval-request-types"; +import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -19,6 +18,7 @@ import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { ActorType, AuthMode } from "@app/services/auth/auth-type"; import { ProjectFilterType } from "@app/services/project/project-types"; +import { SecretOperations } from "@app/services/secret/secret-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { secretRawSchema } from "../sanitizedSchemas"; @@ -259,18 +259,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: secrets.length, - workspaceId, - environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secrets.length, + workspaceId, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secrets, imports }; } }); @@ -306,7 +308,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() + }) }) } }, @@ -358,18 +369,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: 1, - workspaceId: secret.workspace, - environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: 1, + workspaceId: secret.workspace, + environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secret }; } }); @@ -404,6 +417,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 +441,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 +507,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 +529,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({ @@ -710,24 +727,22 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { }); // TODO: Move to telemetry plugin - let shouldRecordK8Event = false; - if (req.headers["user-agent"] === "k8-operatoer") { - const randomNumber = Math.random(); - if (randomNumber > 0.95) { - shouldRecordK8Event = true; - } - } + // let shouldRecordK8Event = false; + // if (req.headers["user-agent"] === "k8-operatoer") { + // const randomNumber = Math.random(); + // if (randomNumber > 0.95) { + // shouldRecordK8Event = true; + // } + // } const shouldCapture = - req.query.workspaceId !== "650e71fbae3e6c8572f436d4" && - (req.headers["user-agent"] !== "k8-operator" || shouldRecordK8Event); - const approximateNumberTotalSecrets = secrets.length * 20; + req.query.workspaceId !== "650e71fbae3e6c8572f436d4" && req.headers["user-agent"] !== "k8-operator"; if (shouldCapture) { await server.services.telemetry.sendPostHogEvents({ event: PostHogEventTypes.SecretPulled, distinctId: getTelemetryDistinctId(req), properties: { - numberOfSecrets: shouldRecordK8Event ? approximateNumberTotalSecrets : secrets.length, + numberOfSecrets: secrets.length, workspaceId: req.query.workspaceId, environment: req.query.environment, secretPath: req.query.secretPath, @@ -804,18 +819,20 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { } }); - await server.services.telemetry.sendPostHogEvents({ - event: PostHogEventTypes.SecretPulled, - distinctId: getTelemetryDistinctId(req), - properties: { - numberOfSecrets: 1, - workspaceId: req.query.workspaceId, - environment: req.query.environment, - secretPath: req.query.secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: 1, + workspaceId: req.query.workspaceId, + environment: req.query.environment, + secretPath: req.query.secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } return { secret }; } }); @@ -902,7 +919,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Create]: [ + [SecretOperations.Create]: [ { secretName: req.params.secretName, secretValueCiphertext, @@ -1084,7 +1101,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Update]: [ + [SecretOperations.Update]: [ { secretName: req.params.secretName, newSecretName, @@ -1234,7 +1251,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Delete]: [ + [SecretOperations.Delete]: [ { secretName: req.params.secretName } @@ -1364,7 +1381,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Create]: inputSecrets + [SecretOperations.Create]: inputSecrets } }); @@ -1491,7 +1508,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Update]: inputSecrets.filter(({ type }) => type === "shared") + [SecretOperations.Update]: inputSecrets.filter(({ type }) => type === "shared") } }); @@ -1606,7 +1623,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { projectId, policy, data: { - [CommitType.Delete]: inputSecrets.filter(({ type }) => type === "shared") + [SecretOperations.Delete]: inputSecrets.filter(({ type }) => type === "shared") } }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v3/signup-router.ts b/backend/src/server/routes/v3/signup-router.ts index ac43df36d..59131464a 100644 --- a/backend/src/server/routes/v3/signup-router.ts +++ b/backend/src/server/routes/v3/signup-router.ts @@ -102,7 +102,8 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { verifier: z.string().trim(), organizationName: z.string().trim().min(1), providerAuthToken: z.string().trim().optional().nullish(), - attributionSource: z.string().trim().optional() + attributionSource: z.string().trim().optional(), + password: z.string() }), response: { 200: z.object({ @@ -167,6 +168,7 @@ export const registerSignupRouter = async (server: FastifyZodProvider) => { schema: { body: z.object({ email: z.string().email().trim(), + password: z.string(), firstName: z.string().trim(), lastName: z.string().trim().optional(), protectedKey: z.string().trim(), diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 5d68a4e94..b1f8aa2f6 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -13,8 +13,9 @@ import { TCreateTokenForUserDTO, TIssueAuthTokenDTO, TokenType, TValidateTokenFo type TAuthTokenServiceFactoryDep = { tokenDAL: TTokenDALFactory; - userDAL: Pick; + userDAL: Pick; }; + export type TAuthTokenServiceFactory = ReturnType; export const getTokenConfig = (tokenType: TokenType) => { @@ -53,6 +54,11 @@ export const getTokenConfig = (tokenType: TokenType) => { const expiresAt = new Date(new Date().getTime() + 86400000); return { token, expiresAt }; } + case TokenType.TOKEN_USER_UNLOCK: { + const token = crypto.randomBytes(16).toString("hex"); + const expiresAt = new Date(new Date().getTime() + 259200000); + return { token, expiresAt }; + } default: { const token = crypto.randomBytes(16).toString("hex"); const expiresAt = new Date(); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 630e36310..8917bd672 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -3,7 +3,8 @@ export enum TokenType { TOKEN_EMAIL_VERIFICATION = "emailVerification", // unverified -> verified TOKEN_EMAIL_MFA = "emailMfa", TOKEN_EMAIL_ORG_INVITATION = "organizationInvitation", - TOKEN_EMAIL_PASSWORD_RESET = "passwordReset" + TOKEN_EMAIL_PASSWORD_RESET = "passwordReset", + TOKEN_USER_UNLOCK = "userUnlock" } export type TCreateTokenForUserDTO = { diff --git a/backend/src/services/auth/auth-fns.ts b/backend/src/services/auth/auth-fns.ts index 80fb0b325..e8574a8b9 100644 --- a/backend/src/services/auth/auth-fns.ts +++ b/backend/src/services/auth/auth-fns.ts @@ -15,10 +15,10 @@ export const validateProviderAuthToken = (providerToken: string, username?: stri if (decodedToken.username !== username) throw new Error("Invalid auth credentials"); if (decodedToken.organizationId) { - return { orgId: decodedToken.organizationId, authMethod: decodedToken.authMethod }; + return { orgId: decodedToken.organizationId, authMethod: decodedToken.authMethod, userName: decodedToken.username }; } - return { authMethod: decodedToken.authMethod, orgId: null }; + return { authMethod: decodedToken.authMethod, orgId: null, userName: decodedToken.username }; }; export const validateSignUpAuthorization = (token: string, userId: string, validate = true) => { @@ -44,3 +44,27 @@ export const validateSignUpAuthorization = (token: string, userId: string, valid if (decodedToken.authTokenType !== AuthTokenType.SIGNUP_TOKEN) throw new UnauthorizedError(); if (decodedToken.userId !== userId) throw new UnauthorizedError(); }; + +export const enforceUserLockStatus = (isLocked: boolean, temporaryLockDateEnd?: Date | null) => { + if (isLocked) { + throw new UnauthorizedError({ + name: "User Locked", + message: + "User is locked due to multiple failed login attempts. An email has been sent to you in order to unlock your account. You can also reset your password to unlock your account." + }); + } + + if (temporaryLockDateEnd) { + const timeDiff = new Date().getTime() - temporaryLockDateEnd.getTime(); + if (timeDiff < 0) { + const secondsDiff = (-1 * timeDiff) / 1000; + const timeDisplay = + secondsDiff > 60 ? `${Math.ceil(secondsDiff / 60)} minutes` : `${Math.ceil(secondsDiff)} seconds`; + + throw new UnauthorizedError({ + name: "User Locked", + message: `User is temporary locked due to multiple failed login attempts. Try again after ${timeDisplay}. You can also reset your password now to proceed.` + }); + } + } +}; diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 4d2a302c6..29a2a176f 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -1,10 +1,14 @@ +import bcrypt from "bcrypt"; 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, UnauthorizedError } from "@app/lib/errors"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { getUserPrivateKey } from "@app/lib/crypto/srp"; +import { BadRequestError, DatabaseError, UnauthorizedError } from "@app/lib/errors"; import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { TTokenDALFactory } from "../auth-token/auth-token-dal"; @@ -13,11 +17,12 @@ import { TokenType } from "../auth-token/auth-token-types"; import { TOrgDALFactory } from "../org/org-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; -import { validateProviderAuthToken } from "./auth-fns"; +import { enforceUserLockStatus, validateProviderAuthToken } from "./auth-fns"; import { TLoginClientProofDTO, TLoginGenServerPublicKeyDTO, TOauthLoginDTO, + TOauthTokenExchangeDTO, TVerifyMfaTokenDTO } from "./auth-login-type"; import { AuthMethod, AuthModeJwtTokenPayload, AuthModeMfaJwtTokenPayload, AuthTokenType } from "./auth-type"; @@ -100,7 +105,7 @@ export const authLoginServiceFactory = ({ user: TUsers; ip: string; userAgent: string; - organizationId: string | undefined; + organizationId?: string; authMethod: AuthMethod; }) => { const cfg = getConfig(); @@ -176,12 +181,17 @@ export const authLoginServiceFactory = ({ clientProof, ip, userAgent, - providerAuthToken + providerAuthToken, + captchaToken, + password }: 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 +206,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,14 +239,48 @@ export const authLoginServiceFactory = ({ userEnc.clientPublicKey, clientProof ); - if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); - await userDAL.updateUserEncryptionByUserId(userEnc.userId, { - serverPrivateKey: null, - clientPublicKey: null + if (!isValidClientProof) { + await userDAL.update( + { id: userEnc.userId }, + { + $incr: { + consecutiveFailedPasswordAttempts: 1 + } + } + ); + + throw new Error("Failed to authenticate. Try again?"); + } + + await userDAL.updateById(userEnc.userId, { + consecutiveFailedPasswordAttempts: 0 }); + // from password decrypt the private key + if (password) { + const privateKey = await getUserPrivateKey(password, userEnc); + const hashedPassword = await bcrypt.hash(password, cfg.BCRYPT_SALT_ROUND); + const { iv, tag, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey); + await userDAL.updateUserEncryptionByUserId(userEnc.userId, { + serverPrivateKey: null, + clientPublicKey: null, + hashedPassword, + serverEncryptedPrivateKey: ciphertext, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyEncoding: encoding + }); + } else { + await userDAL.updateUserEncryptionByUserId(userEnc.userId, { + serverPrivateKey: null, + clientPublicKey: null + }); + } + // send multi factor auth token if they it enabled if (userEnc.isMfaEnabled && userEnc.email) { + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + const mfaToken = jwt.sign( { authMethod, @@ -300,28 +369,111 @@ export const authLoginServiceFactory = ({ const resendMfaToken = async (userId: string) => { const user = await userDAL.findById(userId); if (!user || !user.email) return; + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); await sendUserMfaCode({ userId: user.id, email: user.email }); }; + const processFailedMfaAttempt = async (userId: string) => { + try { + const updatedUser = await userDAL.transaction(async (tx) => { + const PROGRESSIVE_DELAY_INTERVAL = 3; + const user = await userDAL.updateById(userId, { $incr: { consecutiveFailedMfaAttempts: 1 } }, tx); + + if (!user) { + throw new Error("User not found"); + } + + const progressiveDelaysInMins = [5, 30, 60]; + + // lock user when failed attempt exceeds threshold + if ( + user.consecutiveFailedMfaAttempts && + user.consecutiveFailedMfaAttempts >= PROGRESSIVE_DELAY_INTERVAL * (progressiveDelaysInMins.length + 1) + ) { + return userDAL.updateById( + userId, + { + isLocked: true, + temporaryLockDateEnd: null + }, + tx + ); + } + + // delay user only when failed MFA attempts is a multiple of configured delay interval + if (user.consecutiveFailedMfaAttempts && user.consecutiveFailedMfaAttempts % PROGRESSIVE_DELAY_INTERVAL === 0) { + const delayIndex = user.consecutiveFailedMfaAttempts / PROGRESSIVE_DELAY_INTERVAL - 1; + return userDAL.updateById( + userId, + { + temporaryLockDateEnd: new Date(new Date().getTime() + progressiveDelaysInMins[delayIndex] * 60 * 1000) + }, + tx + ); + } + + return user; + }); + + return updatedUser; + } catch (error) { + throw new DatabaseError({ error, name: "Process failed MFA Attempt" }); + } + }; + /* * Multi factor authentication verification of code * Third step of login in which user completes with mfa * */ const verifyMfaToken = async ({ userId, mfaToken, mfaJwtToken, ip, userAgent, orgId }: TVerifyMfaTokenDTO) => { - await tokenService.validateTokenForUser({ - type: TokenType.TOKEN_EMAIL_MFA, - userId, - code: mfaToken - }); + const appCfg = getConfig(); + const user = await userDAL.findById(userId); + enforceUserLockStatus(Boolean(user.isLocked), user.temporaryLockDateEnd); + + try { + await tokenService.validateTokenForUser({ + type: TokenType.TOKEN_EMAIL_MFA, + userId, + code: mfaToken + }); + } catch (err) { + const updatedUser = await processFailedMfaAttempt(userId); + if (updatedUser.isLocked) { + if (updatedUser.email) { + const unlockToken = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_USER_UNLOCK, + userId: updatedUser.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.UnlockAccount, + subjectLine: "Unlock your Infisical account", + recipients: [updatedUser.email], + substitutions: { + token: unlockToken, + callback_url: `${appCfg.SITE_URL}/api/v1/user/${updatedUser.id}/unlock` + } + }); + } + } + + throw err; + } const decodedToken = jwt.verify(mfaJwtToken, getConfig().AUTH_SECRET) as AuthModeMfaJwtTokenPayload; const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to authenticate user"); + // reset lock states + await userDAL.updateById(userId, { + consecutiveFailedMfaAttempts: 0, + temporaryLockDateEnd: null + }); + const token = await generateUserTokens({ user: { ...userEnc, @@ -367,8 +519,14 @@ export const authLoginServiceFactory = ({ authMethods: [authMethod], isGhost: false }); + } else { + const isLinkingRequired = !user?.authMethods?.includes(authMethod); + if (isLinkingRequired) { + user = await userDAL.updateById(user.id, { authMethods: [...(user.authMethods || []), authMethod] }); + } } - const isLinkingRequired = !user?.authMethods?.includes(authMethod); + + const userEnc = await userDAL.findUserEncKeyByUserId(user.id); const isUserCompleted = user.isAccepted; const providerAuthToken = jwt.sign( { @@ -379,9 +537,9 @@ export const authLoginServiceFactory = ({ isEmailVerified: user.isEmailVerified, firstName: user.firstName, lastName: user.lastName, + hasExchangedPrivateKey: Boolean(userEnc?.serverEncryptedPrivateKey), authMethod, isUserCompleted, - isLinkingRequired, ...(callbackPort ? { callbackPort @@ -393,10 +551,71 @@ export const authLoginServiceFactory = ({ expiresIn: appCfg.JWT_PROVIDER_AUTH_LIFETIME } ); - return { isUserCompleted, providerAuthToken }; }; + /** + * Handles OAuth2 token exchange for user login with private key handoff. + * + * The process involves exchanging a provider's authorization token for an Infisical access token. + * The provider token is returned to the client, who then sends it back to obtain the Infisical access token. + * + * This approach is used instead of directly sending the access token for the following reasons: + * 1. To facilitate easier logic changes from SRP OAuth to simple OAuth. + * 2. To avoid attaching the access token to the URL, which could be logged. The provider token has a very short lifespan, reducing security risks. + */ + const oauth2TokenExchange = async ({ userAgent, ip, providerAuthToken, email }: TOauthTokenExchangeDTO) => { + const decodedProviderToken = validateProviderAuthToken(providerAuthToken, email); + + const appCfg = getConfig(); + const { authMethod, userName } = decodedProviderToken; + if (!userName) throw new BadRequestError({ message: "Missing user name" }); + const organizationId = + (isAuthMethodSaml(authMethod) || authMethod === AuthMethod.LDAP) && decodedProviderToken.orgId + ? decodedProviderToken.orgId + : undefined; + + const userEnc = await userDAL.findUserEncKeyByUsername({ + username: email + }); + if (!userEnc) throw new BadRequestError({ message: "Invalid token" }); + if (!userEnc.serverEncryptedPrivateKey) + throw new BadRequestError({ message: "Key handoff incomplete. Please try logging in again." }); + // send multi factor auth token if they it enabled + if (userEnc.isMfaEnabled && userEnc.email) { + enforceUserLockStatus(Boolean(userEnc.isLocked), userEnc.temporaryLockDateEnd); + + const mfaToken = jwt.sign( + { + authMethod, + authTokenType: AuthTokenType.MFA_TOKEN, + userId: userEnc.userId + }, + appCfg.AUTH_SECRET, + { + expiresIn: appCfg.JWT_MFA_LIFETIME + } + ); + + await sendUserMfaCode({ + userId: userEnc.userId, + email: userEnc.email + }); + + return { isMfaEnabled: true, token: mfaToken } as const; + } + + const token = await generateUserTokens({ + user: { ...userEnc, id: userEnc.userId }, + ip, + userAgent, + authMethod, + organizationId + }); + + return { token, isMfaEnabled: false, user: userEnc } as const; + }; + /* * logout user by incrementing the version by 1 meaning any old session will become invalid * as there number is behind @@ -410,6 +629,7 @@ export const authLoginServiceFactory = ({ loginExchangeClientProof, logout, oauth2Login, + oauth2TokenExchange, resendMfaToken, verifyMfaToken, selectOrganization, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 37b90f548..db57d730e 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -12,6 +12,8 @@ export type TLoginClientProofDTO = { providerAuthToken?: string; ip: string; userAgent: string; + captchaToken?: string; + password?: string; }; export type TVerifyMfaTokenDTO = { @@ -30,3 +32,10 @@ export type TOauthLoginDTO = { authMethod: AuthMethod; callbackPort?: string; }; + +export type TOauthTokenExchangeDTO = { + providerAuthToken: string; + ip: string; + userAgent: string; + email: string; +}; diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index 4025e4903..0e6558966 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; @@ -57,7 +58,8 @@ export const authPaswordServiceFactory = ({ encryptedPrivateKeyTag, salt, verifier, - tokenVersionId + tokenVersionId, + password }: TChangePasswordDTO) => { const userEnc = await userDAL.findUserEncKeyByUserId(userId); if (!userEnc) throw new Error("Failed to find user"); @@ -76,6 +78,8 @@ export const authPaswordServiceFactory = ({ ); if (!isValidClientProof) throw new Error("Failed to authenticate. Try again?"); + const appCfg = getConfig(); + const hashedPassword = await bcrypt.hash(password, appCfg.BCRYPT_SALT_ROUND); await userDAL.updateUserEncryptionByUserId(userId, { encryptionVersion: 2, protectedKey, @@ -87,7 +91,8 @@ export const authPaswordServiceFactory = ({ salt, verifier, serverPrivateKey: null, - clientPublicKey: null + clientPublicKey: null, + hashedPassword }); if (tokenVersionId) { @@ -174,6 +179,12 @@ export const authPaswordServiceFactory = ({ salt, verifier }); + + await userDAL.updateById(userId, { + isLocked: false, + temporaryLockDateEnd: null, + consecutiveFailedMfaAttempts: 0 + }); }; /* diff --git a/backend/src/services/auth/auth-password-type.ts b/backend/src/services/auth/auth-password-type.ts index cf2aac08d..a52374506 100644 --- a/backend/src/services/auth/auth-password-type.ts +++ b/backend/src/services/auth/auth-password-type.ts @@ -10,6 +10,7 @@ export type TChangePasswordDTO = { salt: string; verifier: string; tokenVersionId?: string; + password: string; }; export type TResetPasswordViaBackupKeyDTO = { diff --git a/backend/src/services/auth/auth-signup-service.ts b/backend/src/services/auth/auth-signup-service.ts index be7f5777d..8cf2c9d34 100644 --- a/backend/src/services/auth/auth-signup-service.ts +++ b/backend/src/services/auth/auth-signup-service.ts @@ -1,3 +1,4 @@ +import bcrypt from "bcrypt"; import jwt from "jsonwebtoken"; import { OrgMembershipStatus, TableName } from "@app/db/schemas"; @@ -6,6 +7,8 @@ import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-grou import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { isAuthMethodSaml } from "@app/ee/services/permission/permission-fns"; import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; import { isDisposableEmail } from "@app/lib/validator"; import { TGroupProjectDALFactory } from "@app/services/group-project/group-project-dal"; @@ -119,6 +122,7 @@ export const authSignupServiceFactory = ({ const completeEmailAccountSignup = async ({ email, + password, firstName, lastName, providerAuthToken, @@ -137,6 +141,7 @@ export const authSignupServiceFactory = ({ userAgent, authorization }: TCompleteAccountSignupDTO) => { + const appCfg = getConfig(); const user = await userDAL.findOne({ username: email }); if (!user || (user && user.isAccepted)) { throw new Error("Failed to complete account for complete user"); @@ -152,6 +157,17 @@ export const authSignupServiceFactory = ({ validateSignUpAuthorization(authorization, user.id); } + const hashedPassword = await bcrypt.hash(password, appCfg.BCRYPT_SALT_ROUND); + const privateKey = await getUserPrivateKey(password, { + salt, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + }); + const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); @@ -166,7 +182,12 @@ export const authSignupServiceFactory = ({ protectedKeyTag, encryptedPrivateKey, iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext }, tx ); @@ -227,11 +248,10 @@ export const authSignupServiceFactory = ({ userId: updateduser.info.id }); if (!tokenSession) throw new Error("Failed to create token"); - const appCfg = getConfig(); const accessToken = jwt.sign( { - authMethod: AuthMethod.EMAIL, + authMethod: authMethod || AuthMethod.EMAIL, authTokenType: AuthTokenType.ACCESS_TOKEN, userId: updateduser.info.id, tokenVersionId: tokenSession.id, @@ -244,7 +264,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, @@ -265,6 +285,7 @@ export const authSignupServiceFactory = ({ ip, salt, email, + password, verifier, firstName, publicKey, @@ -295,6 +316,18 @@ export const authSignupServiceFactory = ({ name: "complete account invite" }); + const appCfg = getConfig(); + const hashedPassword = await bcrypt.hash(password, appCfg.BCRYPT_SALT_ROUND); + const privateKey = await getUserPrivateKey(password, { + salt, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + }); + const { tag, encoding, ciphertext, iv } = infisicalSymmetricEncypt(privateKey); const updateduser = await authDAL.transaction(async (tx) => { const us = await userDAL.updateById(user.id, { firstName, lastName, isAccepted: true }, tx); if (!us) throw new Error("User not found"); @@ -310,7 +343,12 @@ export const authSignupServiceFactory = ({ protectedKeyTag, encryptedPrivateKey, iv: encryptedPrivateKeyIV, - tag: encryptedPrivateKeyTag + tag: encryptedPrivateKeyTag, + hashedPassword, + serverEncryptedPrivateKeyEncoding: encoding, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKey: ciphertext }, tx ); @@ -343,7 +381,6 @@ export const authSignupServiceFactory = ({ userId: updateduser.info.id }); if (!tokenSession) throw new Error("Failed to create token"); - const appCfg = getConfig(); const accessToken = jwt.sign( { diff --git a/backend/src/services/auth/auth-signup-type.ts b/backend/src/services/auth/auth-signup-type.ts index a37a1cd96..9cd70f8c7 100644 --- a/backend/src/services/auth/auth-signup-type.ts +++ b/backend/src/services/auth/auth-signup-type.ts @@ -1,5 +1,6 @@ export type TCompleteAccountSignupDTO = { email: string; + password: string; firstName: string; lastName?: string; protectedKey: string; @@ -21,6 +22,7 @@ export type TCompleteAccountSignupDTO = { export type TCompleteAccountInviteDTO = { email: string; + password: string; firstName: string; lastName?: string; protectedKey: string; 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/identity-access-token/identity-access-token-dal.ts b/backend/src/services/identity-access-token/identity-access-token-dal.ts index 92bae670c..a0f9fbc27 100644 --- a/backend/src/services/identity-access-token/identity-access-token-dal.ts +++ b/backend/src/services/identity-access-token/identity-access-token-dal.ts @@ -39,6 +39,12 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { `${TableName.IdentityAwsAuth}.identityId` ); }) + .leftJoin(TableName.IdentityAzureAuth, (qb) => { + qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.AZURE_AUTH])).andOn( + `${TableName.Identity}.id`, + `${TableName.IdentityAzureAuth}.identityId` + ); + }) .leftJoin(TableName.IdentityKubernetesAuth, (qb) => { qb.on(`${TableName.Identity}.authMethod`, db.raw("?", [IdentityAuthMethod.KUBERNETES_AUTH])).andOn( `${TableName.Identity}.id`, @@ -50,6 +56,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityUniversalAuth).as("accessTokenTrustedIpsUa"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityGcpAuth).as("accessTokenTrustedIpsGcp"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAwsAuth).as("accessTokenTrustedIpsAws"), + db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityAzureAuth).as("accessTokenTrustedIpsAzure"), db.ref("accessTokenTrustedIps").withSchema(TableName.IdentityKubernetesAuth).as("accessTokenTrustedIpsK8s"), db.ref("name").withSchema(TableName.Identity) ) @@ -63,6 +70,7 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { doc.accessTokenTrustedIpsUa || doc.accessTokenTrustedIpsGcp || doc.accessTokenTrustedIpsAws || + doc.accessTokenTrustedIpsAzure || doc.accessTokenTrustedIpsK8s }; } catch (error) { @@ -70,5 +78,48 @@ export const identityAccessTokenDALFactory = (db: TDbClient) => { } }; - return { ...identityAccessTokenOrm, findOne }; + const removeExpiredTokens = async (tx?: Knex) => { + try { + const docs = (tx || db)(TableName.IdentityAccessToken) + .where({ + isAccessTokenRevoked: true + }) + .orWhere((qb) => { + void qb + .where("accessTokenNumUsesLimit", ">", 0) + .andWhere( + "accessTokenNumUses", + ">=", + db.ref("accessTokenNumUsesLimit").withSchema(TableName.IdentityAccessToken) + ); + }) + .orWhere((qb) => { + void qb.where("accessTokenTTL", ">", 0).andWhere((qb2) => { + void qb2 + .where((qb3) => { + void qb3 + .whereNotNull("accessTokenLastRenewedAt") + // accessTokenLastRenewedAt + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."accessTokenLastRenewedAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }) + .orWhere((qb3) => { + void qb3 + .whereNull("accessTokenLastRenewedAt") + // created + convert_integer_to_seconds(accessTokenTTL) < present_date + .andWhereRaw( + `"${TableName.IdentityAccessToken}"."createdAt" + make_interval(secs => "${TableName.IdentityAccessToken}"."accessTokenTTL") < NOW()` + ); + }); + }); + }) + .delete(); + return await docs; + } catch (error) { + throw new DatabaseError({ error, name: "IdentityAccessTokenPrune" }); + } + }; + + return { ...identityAccessTokenOrm, findOne, removeExpiredTokens }; }; diff --git a/backend/src/services/identity-access-token/identity-access-token-service.ts b/backend/src/services/identity-access-token/identity-access-token-service.ts index 898d0bc62..3e7fe31a6 100644 --- a/backend/src/services/identity-access-token/identity-access-token-service.ts +++ b/backend/src/services/identity-access-token/identity-access-token-service.ts @@ -21,17 +21,18 @@ export const identityAccessTokenServiceFactory = ({ identityAccessTokenDAL, identityOrgMembershipDAL }: TIdentityAccessTokenServiceFactoryDep) => { - const validateAccessTokenExp = (identityAccessToken: TIdentityAccessTokens) => { + const validateAccessTokenExp = async (identityAccessToken: TIdentityAccessTokens) => { const { + id: tokenId, accessTokenTTL, accessTokenNumUses, accessTokenNumUsesLimit, accessTokenLastRenewedAt, - accessTokenMaxTTL, createdAt: accessTokenCreatedAt } = identityAccessToken; if (accessTokenNumUsesLimit > 0 && accessTokenNumUses > 0 && accessTokenNumUses >= accessTokenNumUsesLimit) { + await identityAccessTokenDAL.deleteById(tokenId); throw new BadRequestError({ message: "Unable to renew because access token number of uses limit reached" }); @@ -46,41 +47,26 @@ export const identityAccessTokenServiceFactory = ({ const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenRenewed.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } else { // access token has never been renewed const accessTokenCreated = new Date(accessTokenCreatedAt); const ttlInMilliseconds = Number(accessTokenTTL) * 1000; const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - if (currentDate > expirationDate) + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(tokenId); throw new UnauthorizedError({ message: "Failed to renew MI access token due to TTL expiration" }); + } } } - - // max ttl checks - if (Number(accessTokenMaxTTL) > 0) { - const accessTokenCreated = new Date(accessTokenCreatedAt); - const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; - const currentDate = new Date(); - const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); - - if (currentDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token due to Max TTL expiration" - }); - - const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL)); - if (extendToDate > expirationDate) - throw new UnauthorizedError({ - message: "Failed to renew MI access token past its Max TTL expiration" - }); - } }; const renewAccessToken = async ({ accessToken }: TRenewAccessTokenDTO) => { @@ -97,7 +83,32 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + const { accessTokenMaxTTL, createdAt: accessTokenCreatedAt, accessTokenTTL } = identityAccessToken; + + // max ttl checks - will it go above max ttl + if (Number(accessTokenMaxTTL) > 0) { + const accessTokenCreated = new Date(accessTokenCreatedAt); + const ttlInMilliseconds = Number(accessTokenMaxTTL) * 1000; + const currentDate = new Date(); + const expirationDate = new Date(accessTokenCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token due to Max TTL expiration" + }); + } + + const extendToDate = new Date(currentDate.getTime() + Number(accessTokenTTL * 1000)); + if (extendToDate > expirationDate) { + await identityAccessTokenDAL.deleteById(identityAccessToken.id); + throw new UnauthorizedError({ + message: "Failed to renew MI access token past its Max TTL expiration" + }); + } + } const updatedIdentityAccessToken = await identityAccessTokenDAL.updateById(identityAccessToken.id, { accessTokenLastRenewedAt: new Date() @@ -131,7 +142,7 @@ export const identityAccessTokenServiceFactory = ({ }); if (!identityAccessToken) throw new UnauthorizedError(); - if (ipAddress) { + if (ipAddress && identityAccessToken) { checkIPAgainstBlocklist({ ipAddress, trustedIps: identityAccessToken?.accessTokenTrustedIps as TIp[] @@ -146,7 +157,14 @@ export const identityAccessTokenServiceFactory = ({ throw new UnauthorizedError({ message: "Identity does not belong to any organization" }); } - validateAccessTokenExp(identityAccessToken); + await validateAccessTokenExp(identityAccessToken); + + await identityAccessTokenDAL.updateById(identityAccessToken.id, { + accessTokenLastUsedAt: new Date(), + $incr: { + accessTokenNumUses: 1 + } + }); return { ...identityAccessToken, orgId: identityOrgMembership.orgId }; }; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-dal.ts new file mode 100644 index 000000000..7038e2b9c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-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 TIdentityAzureAuthDALFactory = ReturnType; + +export const identityAzureAuthDALFactory = (db: TDbClient) => { + const azureAuthOrm = ormify(db, TableName.IdentityAzureAuth); + return azureAuthOrm; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts new file mode 100644 index 000000000..ad9e6f12d --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-fns.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import jwt from "jsonwebtoken"; + +import { UnauthorizedError } from "@app/lib/errors"; + +import { TAzureAuthJwtPayload, TAzureJwksUriResponse, TDecodedAzureAuthJwt } from "./identity-azure-auth-types"; + +export const validateAzureIdentity = async ({ + tenantId, + resource, + jwt: azureJwt +}: { + tenantId: string; + resource: string; + jwt: string; +}) => { + const jwksUri = `https://login.microsoftonline.com/${tenantId}/discovery/keys`; + + const decodedJwt = jwt.decode(azureJwt, { complete: true }) as TDecodedAzureAuthJwt; + const { kid } = decodedJwt.header; + + const { data }: { data: TAzureJwksUriResponse } = await axios.get(jwksUri); + const signingKeys = data.keys; + + const signingKey = signingKeys.find((key) => key.kid === kid); + if (!signingKey) throw new UnauthorizedError(); + + const publicKey = `-----BEGIN CERTIFICATE-----\n${signingKey.x5c[0]}\n-----END CERTIFICATE-----`; + + return jwt.verify(azureJwt, publicKey, { + audience: resource, + issuer: `https://sts.windows.net/${tenantId}/` + }) as TAzureAuthJwtPayload; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts new file mode 100644 index 000000000..fa439bdc0 --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-service.ts @@ -0,0 +1,286 @@ +import { ForbiddenError } from "@casl/ability"; +import jwt from "jsonwebtoken"; + +import { IdentityAuthMethod } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; +import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; + +import { AuthTokenType } from "../auth/auth-type"; +import { TIdentityDALFactory } from "../identity/identity-dal"; +import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; +import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; +import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; +import { TIdentityAzureAuthDALFactory } from "./identity-azure-auth-dal"; +import { validateAzureIdentity } from "./identity-azure-auth-fns"; +import { + TAttachAzureAuthDTO, + TGetAzureAuthDTO, + TLoginAzureAuthDTO, + TUpdateAzureAuthDTO +} from "./identity-azure-auth-types"; + +type TIdentityAzureAuthServiceFactoryDep = { + identityAzureAuthDAL: Pick; + identityOrgMembershipDAL: Pick; + identityAccessTokenDAL: Pick; + identityDAL: Pick; + permissionService: Pick; + licenseService: Pick; +}; + +export type TIdentityAzureAuthServiceFactory = ReturnType; + +export const identityAzureAuthServiceFactory = ({ + identityAzureAuthDAL, + identityOrgMembershipDAL, + identityAccessTokenDAL, + identityDAL, + permissionService, + licenseService +}: TIdentityAzureAuthServiceFactoryDep) => { + const login = async ({ identityId, jwt: azureJwt }: TLoginAzureAuthDTO) => { + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + if (!identityAzureAuth) throw new UnauthorizedError(); + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityAzureAuth.identityId }); + if (!identityMembershipOrg) throw new UnauthorizedError(); + + const azureIdentity = await validateAzureIdentity({ + tenantId: identityAzureAuth.tenantId, + resource: identityAzureAuth.resource, + jwt: azureJwt + }); + + if (azureIdentity.tid !== identityAzureAuth.tenantId) throw new UnauthorizedError(); + + if (identityAzureAuth.allowedServicePrincipalIds) { + // validate if the service principal id is in the list of allowed service principal ids + + const isServicePrincipalAllowed = identityAzureAuth.allowedServicePrincipalIds + .split(",") + .map((servicePrincipalId) => servicePrincipalId.trim()) + .some((servicePrincipalId) => servicePrincipalId === azureIdentity.oid); + + if (!isServicePrincipalAllowed) throw new UnauthorizedError(); + } + + const identityAccessToken = await identityAzureAuthDAL.transaction(async (tx) => { + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityAzureAuth.identityId, + isAccessTokenRevoked: false, + accessTokenTTL: identityAzureAuth.accessTokenTTL, + accessTokenMaxTTL: identityAzureAuth.accessTokenMaxTTL, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityAzureAuth.accessTokenNumUsesLimit + }, + tx + ); + return newToken; + }); + + const appCfg = getConfig(); + const accessToken = jwt.sign( + { + identityId: identityAzureAuth.identityId, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + { + expiresIn: + Number(identityAccessToken.accessTokenMaxTTL) === 0 + ? undefined + : Number(identityAccessToken.accessTokenMaxTTL) + } + ); + + return { accessToken, identityAzureAuth, identityAccessToken, identityMembershipOrg }; + }; + + const attachAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TAttachAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity.authMethod) + throw new BadRequestError({ + message: "Failed to add Azure Auth to already configured identity" + }); + + if (accessTokenMaxTTL > 0 && accessTokenTTL > accessTokenMaxTTL) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const identityAzureAuth = await identityAzureAuthDAL.transaction(async (tx) => { + const doc = await identityAzureAuthDAL.create( + { + identityId: identityMembershipOrg.identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps) + }, + tx + ); + await identityDAL.updateById( + identityMembershipOrg.identityId, + { + authMethod: IdentityAuthMethod.AZURE_AUTH + }, + tx + ); + return doc; + }); + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + const updateAzureAuth = async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "Failed to update Azure Auth" + }); + + const identityGcpAuth = await identityAzureAuthDAL.findOne({ identityId }); + + if ( + (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) > 0 && + (accessTokenTTL || identityGcpAuth.accessTokenMaxTTL) > (accessTokenMaxTTL || identityGcpAuth.accessTokenMaxTTL) + ) { + throw new BadRequestError({ message: "Access token TTL cannot be greater than max TTL" }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Identity); + + const plan = await licenseService.getPlan(identityMembershipOrg.orgId); + const reformattedAccessTokenTrustedIps = accessTokenTrustedIps?.map((accessTokenTrustedIp) => { + if ( + !plan.ipAllowlisting && + accessTokenTrustedIp.ipAddress !== "0.0.0.0/0" && + accessTokenTrustedIp.ipAddress !== "::/0" + ) + throw new BadRequestError({ + message: + "Failed to add IP access range to access token due to plan restriction. Upgrade plan to add IP access range." + }); + if (!isValidIpOrCidr(accessTokenTrustedIp.ipAddress)) + throw new BadRequestError({ + message: "The IP is not a valid IPv4, IPv6, or CIDR block" + }); + return extractIPDetails(accessTokenTrustedIp.ipAddress); + }); + + const updatedAzureAuth = await identityAzureAuthDAL.updateById(identityGcpAuth.id, { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenMaxTTL, + accessTokenTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps: reformattedAccessTokenTrustedIps + ? JSON.stringify(reformattedAccessTokenTrustedIps) + : undefined + }); + + return { + ...updatedAzureAuth, + orgId: identityMembershipOrg.orgId + }; + }; + + const getAzureAuth = async ({ identityId, actorId, actor, actorAuthMethod, actorOrgId }: TGetAzureAuthDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new BadRequestError({ message: "Failed to find identity" }); + if (identityMembershipOrg.identity?.authMethod !== IdentityAuthMethod.AZURE_AUTH) + throw new BadRequestError({ + message: "The identity does not have Azure Auth attached" + }); + + const identityAzureAuth = await identityAzureAuthDAL.findOne({ identityId }); + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.Identity); + + return { ...identityAzureAuth, orgId: identityMembershipOrg.orgId }; + }; + + return { + login, + attachAzureAuth, + updateAzureAuth, + getAzureAuth + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts new file mode 100644 index 000000000..65459003c --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-types.ts @@ -0,0 +1,120 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TLoginAzureAuthDTO = { + identityId: string; + jwt: string; +}; + +export type TAttachAzureAuthDTO = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { ipAddress: string }[]; +} & Omit; + +export type TUpdateAzureAuthDTO = { + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { ipAddress: string }[]; +} & Omit; + +export type TGetAzureAuthDTO = { + identityId: string; +} & Omit; + +export type TAzureJwksUriResponse = { + keys: { + kty: string; + use: string; + kid: string; + x5t: string; + n: string; + e: string; + x5c: string[]; + }[]; +}; + +type TUserPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + acr: string; + aio: string; + amr: string[]; + appid: string; + appidacr: string; + family_name: string; + given_name: string; + groups: string[]; + idtyp: string; + ipaddr: string; + name: string; + oid: string; + puid: string; + rh: string; + scp: string; + sub: string; + tid: string; + unique_name: string; + upn: string; + uti: string; + ver: string; + wids: string[]; + xms_cae: string; + xms_cc: string[]; + xms_filter_index: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +type TAppPayload = { + aud: string; + iss: string; + iat: number; + nbf: number; + exp: number; + aio: string; + appid: string; + appidacr: string; + idp: string; + idtyp: string; + oid: string; // service principal id + rh: string; + sub: string; + tid: string; + uti: string; + ver: string; + xms_cae: string; + xms_cc: string[]; + xms_rd: string; + xms_ssm: string; + xms_tcdt: number; +}; + +export type TAzureAuthJwtPayload = TUserPayload | TAppPayload; + +export type TDecodedAzureAuthJwt = { + header: { + type: string; + alg: string; + x5t: string; + kid: string; + }; + payload: TAzureAuthJwtPayload; + signature: string; + metadata: { + [key: string]: string; + }; +}; diff --git a/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts new file mode 100644 index 000000000..3f7f7d8af --- /dev/null +++ b/backend/src/services/identity-azure-auth/identity-azure-auth-validators.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const validateAzureAuthField = z + .string() + .trim() + .default("") + .transform((data) => { + if (data === "") return ""; + // Trim each ID and join with ', ' to ensure formatting + return data + .split(",") + .map((id) => id.trim()) + .join(", "); + }); diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 8ee8c36bd..f1e1c6be0 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -442,7 +442,34 @@ export const identityKubernetesAuthServiceFactory = ({ const updatedKubernetesAuth = await identityKubernetesAuthDAL.updateById(identityKubernetesAuth.id, updateQuery); - return { ...updatedKubernetesAuth, orgId: identityMembershipOrg.orgId }; + const updatedCACert = + updatedKubernetesAuth.encryptedCaCert && updatedKubernetesAuth.caCertIV && updatedKubernetesAuth.caCertTag + ? decryptSymmetric({ + ciphertext: updatedKubernetesAuth.encryptedCaCert, + iv: updatedKubernetesAuth.caCertIV, + tag: updatedKubernetesAuth.caCertTag, + key + }) + : ""; + + const updatedTokenReviewerJwt = + updatedKubernetesAuth.encryptedTokenReviewerJwt && + updatedKubernetesAuth.tokenReviewerJwtIV && + updatedKubernetesAuth.tokenReviewerJwtTag + ? decryptSymmetric({ + ciphertext: updatedKubernetesAuth.encryptedTokenReviewerJwt, + iv: updatedKubernetesAuth.tokenReviewerJwtIV, + tag: updatedKubernetesAuth.tokenReviewerJwtTag, + key + }) + : ""; + + return { + ...updatedKubernetesAuth, + orgId: identityMembershipOrg.orgId, + caCert: updatedCACert, + tokenReviewerJwt: updatedTokenReviewerJwt + }; }; const getKubernetesAuth = async ({ diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index fb5dc6fb0..10f2b3460 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -259,7 +259,7 @@ export const identityProjectServiceFactory = ({ if (!hasRequiredPriviledges) throw new ForbiddenRequestError({ message: "Failed to delete more privileged identity" }); - const [deletedIdentity] = await identityProjectDAL.delete({ identityId }); + const [deletedIdentity] = await identityProjectDAL.delete({ identityId, projectId }); return deletedIdentity; }; diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index 74d881d26..02091d88c 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -199,6 +199,7 @@ export const integrationAuthServiceFactory = ({ projectId, namespace, integration, + url, algorithm: SecretEncryptionAlgo.AES_256_GCM, keyEncoding: SecretKeyEncoding.UTF8, ...(integration === Integrations.GCP_SECRET_MANAGER diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 2aaf5d5f4..edc426327 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -30,7 +30,8 @@ export enum Integrations { DIGITAL_OCEAN_APP_PLATFORM = "digital-ocean-app-platform", CLOUD_66 = "cloud-66", NORTHFLANK = "northflank", - HASURA_CLOUD = "hasura-cloud" + HASURA_CLOUD = "hasura-cloud", + RUNDECK = "rundeck" } export enum IntegrationType { @@ -368,6 +369,15 @@ export const getIntegrationOptions = async () => { type: "pat", clientId: "", docsLink: "" + }, + { + name: "Rundeck", + slug: "rundeck", + image: "Rundeck.svg", + isAvailable: true, + type: "pat", + clientId: "", + docsLink: "" } ]; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 40d51c81a..70e3435a2 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -18,7 +18,7 @@ import { UpdateSecretCommand } from "@aws-sdk/client-secrets-manager"; import { Octokit } from "@octokit/rest"; -import AWS from "aws-sdk"; +import AWS, { AWSError } from "aws-sdk"; import { AxiosError } from "axios"; import sodium from "libsodium-wrappers"; import isEqual from "lodash.isequal"; @@ -27,9 +27,11 @@ import { z } from "zod"; import { SecretType, TIntegrationAuths, TIntegrations, TSecrets } from "@app/db/schemas"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; +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, @@ -450,7 +452,11 @@ const syncSecretsAWSParameterStore = async ({ accessId: string | null; accessToken: string; }) => { - if (!accessId) return; + let response: { isSynced: boolean; syncMessage: string } | null = null; + + if (!accessId) { + throw new Error("AWS access ID is required"); + } const config = new AWS.Config({ region: integration.region as string, @@ -521,18 +527,47 @@ const syncSecretsAWSParameterStore = async ({ .promise(); } // case: secret exists in AWS parameter store - } else if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { - // case: secret value doesn't match one in AWS parameter store + } else { // -> update secret - await ssm - .putParameter({ - Name: `${integration.path}${key}`, - Type: "SecureString", - Value: secrets[key].value, - Overwrite: true - // Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] - }) - .promise(); + if (awsParameterStoreSecretsObj[key].Value !== secrets[key].value) { + await ssm + .putParameter({ + Name: `${integration.path}${key}`, + Type: "SecureString", + Value: secrets[key].value, + Overwrite: true + }) + .promise(); + } + + if (awsParameterStoreSecretsObj[key].Name) { + try { + await ssm + .addTagsToResource({ + ResourceType: "Parameter", + ResourceId: awsParameterStoreSecretsObj[key].Name as string, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ + Key: tag.key, + Value: tag.value + })) + : [] + }) + .promise(); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((err as any).code === "AccessDeniedException") { + logger.error( + `AWS Parameter Store Error [integration=${integration.id}]: double check AWS account permissions (refer to the Infisical docs)` + ); + } + + response = { + isSynced: false, + syncMessage: (err as AWSError)?.message || "Error syncing with AWS Parameter Store" + }; + } + } } await new Promise((resolve) => { @@ -559,6 +594,8 @@ const syncSecretsAWSParameterStore = async ({ } } } + + return response; }; /** @@ -577,7 +614,9 @@ const syncSecretsAWSSecretManager = async ({ }) => { const metadata = z.record(z.any()).parse(integration.metadata || {}); - if (!accessId) return; + if (!accessId) { + throw new Error("AWS access ID is required"); + } const secretsManager = new SecretsManagerClient({ region: integration.region as string, @@ -696,7 +735,7 @@ const syncSecretsAWSSecretManager = async ({ } } } catch (err) { - // case when AWS manager can't find the specified secret + // case 1: when AWS manager can't find the specified secret if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( new CreateSecretCommand({ @@ -708,6 +747,9 @@ const syncSecretsAWSSecretManager = async ({ : [] }) ); + // case 2: something unexpected went wrong, so we'll throw the error to reflect the error in the integration sync status + } else { + throw err; } } }; @@ -727,14 +769,12 @@ const syncSecretsAWSSecretManager = async ({ const syncSecretsHeroku = async ({ createManySecretsRawFn, updateManySecretsRawFn, - integrationDAL, integration, secrets, accessToken }: { createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; - integrationDAL: Pick; integration: TIntegrations & { projectId: string; environment: { @@ -836,10 +876,6 @@ const syncSecretsHeroku = async ({ } } ); - - await integrationDAL.updateById(integration.id, { - lastUsed: new Date() - }); }; /** @@ -1338,38 +1374,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; + } } } } @@ -1892,13 +1931,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; } @@ -1918,8 +1957,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 }, @@ -1936,7 +1975,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: { @@ -2625,7 +2666,9 @@ const syncSecretsHashiCorpVault = async ({ accessId: string | null; accessToken: string; }) => { - if (!accessId) return; + if (!accessId) { + throw new Error("Access ID is required"); + } interface LoginAppRoleRes { auth: { @@ -2696,18 +2739,21 @@ const syncSecretsCloudflarePages = async ({ }) ).data.result.deployment_configs[integration.targetEnvironment as string].env_vars; - // copy the secrets object, so we can set deleted keys to null - const secretsObj = Object.fromEntries( - Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ - key, - key in Object.keys(getSecretsRes) ? { type: "secret_text", value: val } : null - ]) - ); + let secretEntries: [string, object | null][] = Object.entries(getSecretKeyValuePair(secrets)).map(([key, val]) => [ + key, + { type: "secret_text", value: val } + ]); + + if (getSecretsRes) { + const toDeleteKeys = Object.keys(getSecretsRes).filter((key) => !Object.keys(secrets).includes(key)); + const toDeleteEntries: [string, null][] = toDeleteKeys.map((key) => [key, null]); + secretEntries = [...secretEntries, ...toDeleteEntries]; + } const data = { deployment_configs: { [integration.targetEnvironment as string]: { - env_vars: secretsObj + env_vars: Object.fromEntries(secretEntries) } } }; @@ -2722,6 +2768,20 @@ const syncSecretsCloudflarePages = async ({ } } ); + + const metadata = z.record(z.any()).parse(integration.metadata); + if (metadata.shouldAutoRedeploy) { + await request.post( + `${IntegrationUrls.CLOUDFLARE_PAGES_API_URL}/client/v4/accounts/${accessId}/pages/projects/${integration.app}/deployments`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json" + } + } + ); + } }; /** @@ -3327,6 +3387,82 @@ const syncSecretsHasuraCloud = async ({ } }; +/** Sync/push [secrets] to Rundeck + * @param {Object} obj + * @param {TIntegrations} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Rundeck integration + */ +const syncSecretsRundeck = async ({ + integration, + secrets, + accessToken +}: { + integration: TIntegrations; + secrets: Record; + accessToken: string; +}) => { + interface RundeckSecretResource { + name: string; + } + interface RundeckSecretsGetRes { + resources: RundeckSecretResource[]; + } + + let existingRundeckSecrets: string[] = []; + + try { + const listResult = await request.get( + `${integration.url}/api/44/storage/${integration.path}`, + { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + } + ); + + existingRundeckSecrets = listResult.data.resources.map((res) => res.name); + } catch (err) { + logger.info("No existing rundeck secrets"); + } + + try { + for await (const [key, value] of Object.entries(secrets)) { + if (existingRundeckSecrets.includes(key)) { + await request.put(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } else { + await request.post(`${integration.url}/api/44/storage/${integration.path}/${key}`, value.value, { + headers: { + "X-Rundeck-Auth-Token": accessToken, + "Content-Type": "application/x-rundeck-data-password" + } + }); + } + } + + for await (const existingSecret of existingRundeckSecrets) { + if (!(existingSecret in secrets)) { + await request.delete(`${integration.url}/api/44/storage/${integration.path}/${existingSecret}`, { + headers: { + "X-Rundeck-Auth-Token": accessToken + } + }); + } + } + } catch (err: unknown) { + throw new Error( + `Ensure that the provided Rundeck URL is accessible by Infisical and that the linked API token has sufficient permissions.\n\n${ + (err as Error).message + }` + ); + } +}; + /** * Sync/push [secrets] to [app] in integration named [integration] * @@ -3362,6 +3498,8 @@ export const syncIntegrationSecrets = async ({ accessToken: string; appendices?: { prefix: string; suffix: string }; }) => { + let response: { isSynced: boolean; syncMessage: string } | null = null; + switch (integration.integration) { case Integrations.GCP_SECRET_MANAGER: await syncSecretsGCPSecretManager({ @@ -3378,7 +3516,7 @@ export const syncIntegrationSecrets = async ({ }); break; case Integrations.AWS_PARAMETER_STORE: - await syncSecretsAWSParameterStore({ + response = await syncSecretsAWSParameterStore({ integration, secrets, accessId, @@ -3397,7 +3535,6 @@ export const syncIntegrationSecrets = async ({ await syncSecretsHeroku({ createManySecretsRawFn, updateManySecretsRawFn, - integrationDAL, integration, secrets, accessToken @@ -3593,7 +3730,16 @@ export const syncIntegrationSecrets = async ({ accessToken }); break; + case Integrations.RUNDECK: + await syncSecretsRundeck({ + integration, + secrets, + accessToken + }); + break; default: throw new BadRequestError({ message: "Invalid integration" }); } + + return response; }; diff --git a/backend/src/services/integration-auth/integration-team.ts b/backend/src/services/integration-auth/integration-team.ts index 81ef9b70c..c39b2c44f 100644 --- a/backend/src/services/integration-auth/integration-team.ts +++ b/backend/src/services/integration-auth/integration-team.ts @@ -5,7 +5,7 @@ import { Integrations, IntegrationUrls } from "./integration-list"; type Team = { name: string; - teamId: string; + id: string; }; const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: string }) => { const gitLabApiUrl = url ? `${url}/api` : IntegrationUrls.GITLAB_API_URL; @@ -22,7 +22,7 @@ const getTeamsGitLab = async ({ url, accessToken }: { url: string; accessToken: teams = res.map((t) => ({ name: t.name, - teamId: t.id + id: t.id.toString() })); return teams; 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-service.ts b/backend/src/services/integration/integration-service.ts index eff73c1b6..da9cfc71f 100644 --- a/backend/src/services/integration/integration-service.ts +++ b/backend/src/services/integration/integration-service.ts @@ -1,4 +1,4 @@ -import { ForbiddenError } from "@casl/ability"; +import { ForbiddenError, subject } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -43,6 +43,7 @@ export const integrationServiceFactory = ({ scope, actorId, region, + url, isActive, metadata, secretPath, @@ -66,6 +67,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment: sourceEnvironment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integrationAuth.projectId, sourceEnvironment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); @@ -82,6 +88,7 @@ export const integrationServiceFactory = ({ region, scope, owner, + url, appId, path, app, @@ -123,6 +130,11 @@ export const integrationServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Integrations); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + const folder = await folderDAL.findBySecretPath(integration.projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder path not found" }); diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 1c8772478..abbccbe90 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -12,6 +12,7 @@ export type TCreateIntegrationDTO = { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -28,6 +29,9 @@ export type TCreateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; @@ -53,6 +57,7 @@ export type TUpdateIntegrationDTO = { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldEnableDelete?: boolean; }; } & Omit; diff --git a/backend/src/services/kms/kms-dal.ts b/backend/src/services/kms/kms-dal.ts new file mode 100644 index 000000000..bee667e10 --- /dev/null +++ b/backend/src/services/kms/kms-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 TKmsDALFactory = ReturnType; + +export const kmsDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsKey); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-root-config-dal.ts b/backend/src/services/kms/kms-root-config-dal.ts new file mode 100644 index 000000000..f448e2df8 --- /dev/null +++ b/backend/src/services/kms/kms-root-config-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 TKmsRootConfigDALFactory = ReturnType; + +export const kmsRootConfigDALFactory = (db: TDbClient) => { + const kmsOrm = ormify(db, TableName.KmsServerRootConfig); + return kmsOrm; +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts new file mode 100644 index 000000000..63aba8939 --- /dev/null +++ b/backend/src/services/kms/kms-service.ts @@ -0,0 +1,129 @@ +import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { randomSecureBytes } from "@app/lib/crypto"; +import { symmetricCipherService, SymmetricEncryption } from "@app/lib/crypto/cipher"; +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { TKmsDALFactory } from "./kms-dal"; +import { TKmsRootConfigDALFactory } from "./kms-root-config-dal"; +import { TDecryptWithKmsDTO, TEncryptWithKmsDTO, TGenerateKMSDTO } from "./kms-types"; + +type TKmsServiceFactoryDep = { + kmsDAL: TKmsDALFactory; + kmsRootConfigDAL: Pick; + keyStore: Pick; +}; + +export type TKmsServiceFactory = ReturnType; + +const KMS_ROOT_CONFIG_UUID = "00000000-0000-0000-0000-000000000000"; + +const KMS_ROOT_CREATION_WAIT_KEY = "wait_till_ready_kms_root_key"; +const KMS_ROOT_CREATION_WAIT_TIME = 10; + +// akhilmhdh: Don't edit this value. This is measured for blob concatination in kms +const KMS_VERSION = "v01"; +const KMS_VERSION_BLOB_LENGTH = 3; +export const kmsServiceFactory = ({ kmsDAL, kmsRootConfigDAL, keyStore }: TKmsServiceFactoryDep) => { + let ROOT_ENCRYPTION_KEY = Buffer.alloc(0); + + // this is used symmetric encryption + 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 + }, + tx + ); + return doc; + }; + + const encrypt = async ({ kmsId, plainText }: TEncryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + const encryptedPlainTextBlob = cipher.encrypt(plainText, kmsKey); + + // Buffer#1 encrypted text + Buffer#2 version number + const versionBlob = Buffer.from(KMS_VERSION, "utf8"); // length is 3 + const cipherTextBlob = Buffer.concat([encryptedPlainTextBlob, versionBlob]); + return { cipherTextBlob }; + }; + + const decrypt = async ({ cipherTextBlob: versionedCipherTextBlob, kmsId }: TDecryptWithKmsDTO) => { + const kmsDoc = await kmsDAL.findById(kmsId); + if (!kmsDoc) throw new BadRequestError({ message: "KMS ID not found" }); + // akhilmhdh: as more encryption are added do a check here on kmsDoc.encryptionAlgorithm + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + const kmsKey = cipher.decrypt(kmsDoc.encryptedKey, ROOT_ENCRYPTION_KEY); + + const cipherTextBlob = versionedCipherTextBlob.subarray(0, -KMS_VERSION_BLOB_LENGTH); + const decryptedBlob = cipher.decrypt(cipherTextBlob, kmsKey); + return decryptedBlob; + }; + + const startService = async () => { + const appCfg = getConfig(); + // This will switch to a seal process and HMS flow in future + const encryptionKey = appCfg.ENCRYPTION_KEY || appCfg.ROOT_ENCRYPTION_KEY; + // if root key its base64 encoded + const isBase64 = !appCfg.ENCRYPTION_KEY; + if (!encryptionKey) throw new Error("Root encryption key not found for KMS service."); + const encryptionKeyBuffer = Buffer.from(encryptionKey, isBase64 ? "base64" : "utf8"); + + const lock = await keyStore.acquireLock([`KMS_ROOT_CFG_LOCK`], 3000, { retryCount: 3 }).catch(() => null); + if (!lock) { + await keyStore.waitTillReady({ + key: KMS_ROOT_CREATION_WAIT_KEY, + keyCheckCb: (val) => val === "true", + waitingCb: () => logger.info("KMS. Waiting for leader to finish creation of KMS Root Key") + }); + } + + // check if KMS root key was already generated and saved in DB + const kmsRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID); + const cipher = symmetricCipherService(SymmetricEncryption.AES_GCM_256); + if (kmsRootConfig) { + if (lock) await lock.release(); + logger.info("KMS: Encrypted ROOT Key found from DB. Decrypting."); + const decryptedRootKey = cipher.decrypt(kmsRootConfig.encryptedRootKey, encryptionKeyBuffer); + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Loading ROOT Key into Memory."); + ROOT_ENCRYPTION_KEY = decryptedRootKey; + return; + } + + logger.info("KMS: Generating ROOT Key"); + const newRootKey = randomSecureBytes(32); + const encryptedRootKey = cipher.encrypt(newRootKey, encryptionKeyBuffer); + // @ts-expect-error id is kept as fixed for idempotence and to avoid race condition + await kmsRootConfigDAL.create({ encryptedRootKey, id: KMS_ROOT_CONFIG_UUID }); + + // set the flag so that other instancen nodes can start + await keyStore.setItemWithExpiry(KMS_ROOT_CREATION_WAIT_KEY, KMS_ROOT_CREATION_WAIT_TIME, "true"); + logger.info("KMS: Saved and loaded ROOT Key into memory"); + if (lock) await lock.release(); + ROOT_ENCRYPTION_KEY = newRootKey; + }; + + return { + startService, + generateKmsKey, + encrypt, + decrypt + }; +}; diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts new file mode 100644 index 000000000..63fdaf484 --- /dev/null +++ b/backend/src/services/kms/kms-types.ts @@ -0,0 +1,18 @@ +import { Knex } from "knex"; + +export type TGenerateKMSDTO = { + scopeType: "project" | "org"; + scopeId: string; + isReserved?: boolean; + tx?: Knex; +}; + +export type TEncryptWithKmsDTO = { + kmsId: string; + plainText: Buffer; +}; + +export type TDecryptWithKmsDTO = { + kmsId: string; + cipherTextBlob: Buffer; +}; 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-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index a6682465f..45e3f9ab5 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -36,6 +36,7 @@ import { TDeleteProjectMembershipsDTO, TGetProjectMembershipByUsernameDTO, TGetProjectMembershipDTO, + TLeaveProjectDTO, TUpdateProjectMembershipDTO } from "./project-membership-types"; import { TProjectUserMembershipRoleDALFactory } from "./project-user-membership-role-dal"; @@ -531,6 +532,53 @@ export const projectMembershipServiceFactory = ({ return memberships; }; + const leaveProject = async ({ projectId, actorId, actor }: TLeaveProjectDTO) => { + if (actor !== ActorType.USER) { + throw new BadRequestError({ message: "Only users can leave projects" }); + } + + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version !== ProjectVersion.V2) { + throw new BadRequestError({ + message: "Please ask your project administrator to upgrade the project before leaving." + }); + } + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + + if (!projectMembers?.length) { + throw new BadRequestError({ message: "Failed to find project members" }); + } + + if (projectMembers.length < 2) { + throw new BadRequestError({ message: "You cannot leave the project as you are the only member" }); + } + + const adminMembers = projectMembers.filter( + (member) => member.roles.map((r) => r.role).includes("admin") && member.userId !== actorId + ); + if (!adminMembers.length) { + throw new BadRequestError({ + message: "You cannot leave the project as you are the only admin. Promote another user to admin before leaving." + }); + } + + const deletedMembership = ( + await projectMembershipDAL.delete({ + projectId: project.id, + userId: actorId + }) + )?.[0]; + + if (!deletedMembership) { + throw new BadRequestError({ message: "Failed to leave project" }); + } + + return deletedMembership; + }; + return { getProjectMemberships, getProjectMembershipByUsername, @@ -538,6 +586,7 @@ export const projectMembershipServiceFactory = ({ addUsersToProjectNonE2EE, deleteProjectMemberships, deleteProjectMembership, // TODO: Remove this - addUsersToProject + addUsersToProject, + leaveProject }; }; diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 1eab75265..dc3a62016 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,7 @@ import { TProjectPermission } from "@app/lib/types"; export type TGetProjectMembershipDTO = TProjectPermission; +export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" } diff --git a/backend/src/services/project-role/project-role-service.ts b/backend/src/services/project-role/project-role-service.ts index 831af3200..ffd446fad 100644 --- a/backend/src/services/project-role/project-role-service.ts +++ b/backend/src/services/project-role/project-role-service.ts @@ -1,25 +1,30 @@ -import { ForbiddenError } from "@casl/ability"; -import { packRules } from "@casl/ability/extra"; +import { ForbiddenError, MongoAbility, RawRuleOf } from "@casl/ability"; +import { PackRule, packRules, unpackRules } from "@casl/ability/extra"; -import { ProjectMembershipRole, TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { ProjectMembershipRole } from "@app/db/schemas"; +import { UnpackedPermissionSchema } from "@app/ee/services/identity-project-additional-privilege/identity-project-additional-privilege-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { projectAdminPermissions, projectMemberPermissions, projectNoAccessPermissions, ProjectPermissionActions, + ProjectPermissionSet, ProjectPermissionSub, projectViewerPermission } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; -import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { ActorAuthMethod } from "../auth/auth-type"; import { TIdentityProjectMembershipRoleDALFactory } from "../identity-project/identity-project-membership-role-dal"; +import { TProjectDALFactory } from "../project/project-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; import { TProjectRoleDALFactory } from "./project-role-dal"; +import { TCreateRoleDTO, TDeleteRoleDTO, TGetRoleBySlugDTO, TListRolesDTO, TUpdateRoleDTO } from "./project-role-types"; type TProjectRoleServiceFactoryDep = { projectRoleDAL: TProjectRoleDALFactory; + projectDAL: Pick; permissionService: Pick; identityProjectMembershipRoleDAL: TIdentityProjectMembershipRoleDALFactory; projectUserMembershipRoleDAL: TProjectUserMembershipRoleDALFactory; @@ -27,20 +32,68 @@ type TProjectRoleServiceFactoryDep = { export type TProjectRoleServiceFactory = ReturnType; +const unpackPermissions = (permissions: unknown) => + UnpackedPermissionSchema.array().parse( + unpackRules((permissions || []) as PackRule>>[]) + ); + +const getPredefinedRoles = (projectId: string, roleFilter?: ProjectMembershipRole) => { + return [ + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid + projectId, + name: "Admin", + slug: ProjectMembershipRole.Admin, + permissions: projectAdminPermissions, + description: "Full administrative access over a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response + projectId, + name: "Developer", + slug: ProjectMembershipRole.Member, + permissions: projectMemberPermissions, + description: "Limited read/write role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response + projectId, + name: "Viewer", + slug: ProjectMembershipRole.Viewer, + permissions: projectViewerPermission, + description: "Only read role in a project", + createdAt: new Date(), + updatedAt: new Date() + }, + { + id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response + projectId, + name: "No Access", + slug: ProjectMembershipRole.NoAccess, + permissions: projectNoAccessPermissions, + description: "No access to any resources in the project", + createdAt: new Date(), + updatedAt: new Date() + } + ].filter(({ slug }) => !roleFilter || roleFilter.includes(slug)); +}; + export const projectRoleServiceFactory = ({ projectRoleDAL, permissionService, identityProjectMembershipRoleDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + projectDAL }: TProjectRoleServiceFactoryDep) => { - const createRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const createRole = async ({ projectSlug, data, actor, actorId, actorAuthMethod, actorOrgId }: TCreateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -53,21 +106,54 @@ export const projectRoleServiceFactory = ({ if (existingRole) throw new BadRequestError({ name: "Create Role", message: "Duplicate role" }); const role = await projectRoleDAL.create({ ...data, - projectId, - permissions: JSON.stringify(data.permissions) + projectId }); - return role; + return { ...role, permissions: unpackPermissions(role.permissions) }; }; - const updateRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - data: Omit, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const getRoleBySlug = async ({ + actor, + actorId, + projectSlug, + actorAuthMethod, + actorOrgId, + roleSlug + }: TGetRoleBySlugDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); + if (roleSlug !== "custom" && Object.values(ProjectMembershipRole).includes(roleSlug as ProjectMembershipRole)) { + const predefinedRole = getPredefinedRoles(projectId, roleSlug as ProjectMembershipRole)[0]; + return { ...predefinedRole, permissions: UnpackedPermissionSchema.array().parse(predefinedRole.permissions) }; + } + + const customRole = await projectRoleDAL.findOne({ slug: roleSlug, projectId }); + if (!customRole) throw new BadRequestError({ message: "Role not found" }); + return { ...customRole, permissions: unpackPermissions(customRole.permissions) }; + }; + + const updateRole = async ({ + roleId, + projectSlug, + actorOrgId, + actorAuthMethod, + actorId, + actor, + data + }: TUpdateRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -81,22 +167,16 @@ export const projectRoleServiceFactory = ({ if (existingRole && existingRole.id !== roleId) throw new BadRequestError({ name: "Update Role", message: "Duplicate role" }); } - const [updatedRole] = await projectRoleDAL.update( - { id: roleId, projectId }, - { ...data, permissions: data.permissions ? JSON.stringify(data.permissions) : undefined } - ); + const [updatedRole] = await projectRoleDAL.update({ id: roleId, projectId }, data); if (!updatedRole) throw new BadRequestError({ message: "Role not found", name: "Update role" }); - return updatedRole; + return { ...updatedRole, permissions: unpackPermissions(updatedRole.permissions) }; }; - const deleteRole = async ( - actor: ActorType, - actorId: string, - projectId: string, - roleId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const deleteRole = async ({ actor, actorId, actorAuthMethod, actorOrgId, projectSlug, roleId }: TDeleteRoleDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -125,16 +205,14 @@ export const projectRoleServiceFactory = ({ const [deletedRole] = await projectRoleDAL.delete({ id: roleId, projectId }); if (!deletedRole) throw new BadRequestError({ message: "Role not found", name: "Delete role" }); - return deletedRole; + return { ...deletedRole, permissions: unpackPermissions(deletedRole.permissions) }; }; - const listRoles = async ( - actor: ActorType, - actorId: string, - projectId: string, - actorAuthMethod: ActorAuthMethod, - actorOrgId: string | undefined - ) => { + const listRoles = async ({ projectSlug, actorOrgId, actorAuthMethod, actorId, actor }: TListRolesDTO) => { + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + const projectId = project.id; + const { permission } = await permissionService.getProjectPermission( actor, actorId, @@ -144,52 +222,7 @@ export const projectRoleServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Role); const customRoles = await projectRoleDAL.find({ projectId }); - const roles = [ - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c69", // dummy userid - projectId, - name: "Admin", - slug: ProjectMembershipRole.Admin, - description: "Complete administration access over the project", - permissions: packRules(projectAdminPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c70", // dummy user for zod validation in response - projectId, - name: "Developer", - slug: ProjectMembershipRole.Member, - description: "Non-administrative role in an project", - permissions: packRules(projectMemberPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c71", // dummy user for zod validation in response - projectId, - name: "Viewer", - slug: ProjectMembershipRole.Viewer, - description: "Non-administrative role in an project", - permissions: packRules(projectViewerPermission), - createdAt: new Date(), - updatedAt: new Date() - }, - { - id: "b11b49a9-09a9-4443-916a-4246f9ff2c72", // dummy user for zod validation in response - projectId, - name: "No Access", - slug: "no-access", - description: "No access to any resources in the project", - permissions: packRules(projectNoAccessPermissions), - createdAt: new Date(), - updatedAt: new Date() - }, - ...(customRoles || []).map(({ permissions, ...data }) => ({ - ...data, - permissions - })) - ]; + const roles = [...getPredefinedRoles(projectId), ...(customRoles || [])]; return roles; }; @@ -209,5 +242,5 @@ export const projectRoleServiceFactory = ({ return { permissions: packRules(permission.rules), membership }; }; - return { createRole, updateRole, deleteRole, listRoles, getUserPermission }; + return { createRole, updateRole, deleteRole, listRoles, getUserPermission, getRoleBySlug }; }; diff --git a/backend/src/services/project-role/project-role-types.ts b/backend/src/services/project-role/project-role-types.ts index e69de29bb..62b627a79 100644 --- a/backend/src/services/project-role/project-role-types.ts +++ b/backend/src/services/project-role/project-role-types.ts @@ -0,0 +1,27 @@ +import { TOrgRolesUpdate, TProjectRolesInsert } from "@app/db/schemas"; +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateRoleDTO = { + data: Omit; + projectSlug: string; +} & Omit; + +export type TGetRoleBySlugDTO = { + roleSlug: string; + projectSlug: string; +} & Omit; + +export type TUpdateRoleDTO = { + roleId: string; + data: Omit; + projectSlug: string; +} & Omit; + +export type TDeleteRoleDTO = { + roleId: string; + projectSlug: string; +} & Omit; + +export type TListRolesDTO = { + projectSlug: string; +} & Omit; 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 new file mode 100644 index 000000000..2e01e3549 --- /dev/null +++ b/backend/src/services/resource-cleanup/resource-cleanup-queue.ts @@ -0,0 +1,74 @@ +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; +}; + +export type TDailyResourceCleanUpQueueServiceFactory = ReturnType; + +export const dailyResourceCleanUpQueueServiceFactory = ({ + auditLogDAL, + queueService, + snapshotDAL, + secretVersionDAL, + secretFolderVersionDAL, + identityAccessTokenDAL, + secretSharingDAL +}: TDailyResourceCleanUpQueueServiceFactoryDep) => { + queueService.start(QueueName.DailyResourceCleanUp, async () => { + logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`); + 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`); + }); + + // we do a repeat cron job in utc timezone at 12 Midnight each day + const startCleanUp = async () => { + // TODO(akhilmhdh): remove later + await queueService.stopRepeatableJob( + QueueName.AuditLogPrune, + QueueJobs.AuditLogPrune, + { pattern: "0 0 * * *", utc: true }, + QueueName.AuditLogPrune // just a job id + ); + // clear previous job + await queueService.stopRepeatableJob( + QueueName.DailyResourceCleanUp, + QueueJobs.DailyResourceCleanUp, + { pattern: "0 0 * * *", utc: true }, + QueueName.DailyResourceCleanUp // just a job id + ); + + await queueService.queue(QueueName.DailyResourceCleanUp, QueueJobs.DailyResourceCleanUp, undefined, { + delay: 5000, + jobId: QueueName.DailyResourceCleanUp, + repeat: { pattern: "0 0 * * *", utc: true } + }); + }; + + queueService.listen(QueueName.DailyResourceCleanUp, "failed", (_, err) => { + logger.error(err, `${QueueName.DailyResourceCleanUp}: resource cleanup failed`); + }); + + return { + startCleanUp + }; +}; diff --git a/backend/src/services/secret-folder/secret-folder-dal.ts b/backend/src/services/secret-folder/secret-folder-dal.ts index b3147d1fa..0e896d0c6 100644 --- a/backend/src/services/secret-folder/secret-folder-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-dal.ts @@ -169,6 +169,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str // this is for root condition // if the given folder id is root folder id then intial path is set as / instead of /root // if not root folder the path here will be / + depth: 1, path: db.raw(`CONCAT('/', (CASE WHEN "parentId" is NULL THEN '' ELSE ${TableName.SecretFolder}.name END))`), child: db.raw("NULL::uuid"), environmentSlug: `${TableName.Environment}.slug` @@ -185,6 +186,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str .select({ // then we join join this folder name behind previous as we are going from child to parent // the root folder check is used to avoid last / and also root name in folders + depth: db.raw("parent.depth + 1"), path: db.raw( `CONCAT( CASE WHEN ${TableName.SecretFolder}."parentId" is NULL THEN '' @@ -199,7 +201,7 @@ const sqlFindSecretPathByFolderId = (db: Knex, projectId: string, folderIds: str ); }) .select("*") - .from("parent"); + .from("parent"); export type TSecretFolderDALFactory = ReturnType; // never change this. If u do write a migration for it @@ -260,12 +262,23 @@ export const secretFolderDALFactory = (db: TDbClient) => { try { const folders = await sqlFindSecretPathByFolderId(tx || db, projectId, folderIds); + // travelling all the way from leaf node to root contains real path const rootFolders = groupBy( folders.filter(({ parentId }) => parentId === null), (i) => i.child || i.id // root condition then child and parent will null ); + const actualFolders = groupBy( + folders.filter(({ depth }) => depth === 1), + (i) => i.id // root condition then child and parent will null + ); - return folderIds.map((folderId) => rootFolders[folderId]?.[0]); + return folderIds.map((folderId) => { + if (!rootFolders[folderId]?.[0]) return; + + const actualId = rootFolders[folderId][0].child || rootFolders[folderId][0].id; + const folder = actualFolders[actualId][0]; + return { ...folder, path: rootFolders[folderId]?.[0].path }; + }); } catch (error) { throw new DatabaseError({ error, name: "Find by secret path" }); } diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index da429d88a..97258c006 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -253,7 +253,7 @@ export const secretFolderServiceFactory = ({ const env = await projectEnvDAL.findOne({ projectId, slug: environment }); if (!env) throw new BadRequestError({ message: "Environment not found", name: "Update folder" }); const folder = await folderDAL - .findOne({ envId: env.id, id, parentId: parentFolder.id }) + .findOne({ envId: env.id, id, parentId: parentFolder.id, isReserved: false }) // now folder api accepts id based change // this is for cli backward compatiability and when cli removes this, we will remove this logic .catch(() => folderDAL.findOne({ envId: env.id, name: id, parentId: parentFolder.id })); @@ -276,7 +276,11 @@ export const secretFolderServiceFactory = ({ } const newFolder = await folderDAL.transaction(async (tx) => { - const [doc] = await folderDAL.update({ envId: env.id, id: folder.id, parentId: parentFolder.id }, { name }, tx); + const [doc] = await folderDAL.update( + { envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false }, + { name }, + tx + ); await folderVersionDAL.create( { name: doc.name, @@ -324,7 +328,12 @@ export const secretFolderServiceFactory = ({ if (!parentFolder) throw new BadRequestError({ message: "Secret path not found" }); const [doc] = await folderDAL.delete( - { envId: env.id, [uuidValidate(idOrName) ? "id" : "name"]: idOrName, parentId: parentFolder.id }, + { + envId: env.id, + [uuidValidate(idOrName) ? "id" : "name"]: idOrName, + parentId: parentFolder.id, + isReserved: false + }, tx ); if (!doc) throw new BadRequestError({ message: "Folder not found", name: "Delete folder" }); @@ -354,7 +363,7 @@ export const secretFolderServiceFactory = ({ const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!parentFolder) return []; - const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id }); + const folders = await folderDAL.find({ envId: env.id, parentId: parentFolder.id, isReserved: false }); return folders; }; diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index 1405f8bd7..c01d5f7b8 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -1,5 +1,9 @@ import { TProjectPermission } from "@app/lib/types"; +export enum ReservedFolders { + SecretReplication = "__reserve_replication_" +} + export type TCreateFolderDTO = { environment: string; path: string; 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 f133308cf..fb68ce801 100644 --- a/backend/src/services/secret-folder/secret-folder-version-dal.ts +++ b/backend/src/services/secret-folder/secret-folder-version-dal.ts @@ -15,7 +15,7 @@ export const secretFolderVersionDALFactory = (db: TDbClient) => { try { const docs = await (tx || db)(TableName.SecretFolderVersion) .join(TableName.SecretFolder, `${TableName.SecretFolderVersion}.folderId`, `${TableName.SecretFolder}.id`) - .where({ parentId: folderId }) + .where({ parentId: folderId, isReserved: false }) .join( (tx || db)(TableName.SecretFolderVersion) .groupBy("envId", "folderId") @@ -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-import/secret-import-dal.ts b/backend/src/services/secret-import/secret-import-dal.ts index aa45d410d..0e73a8c23 100644 --- a/backend/src/services/secret-import/secret-import-dal.ts +++ b/backend/src/services/secret-import/secret-import-dal.ts @@ -20,14 +20,14 @@ export const secretImportDALFactory = (db: TDbClient) => { return lastPos?.position || 0; }; - const updateAllPosition = async (folderId: string, pos: number, targetPos: number, tx?: Knex) => { + const updateAllPosition = async (folderId: string, pos: number, targetPos: number, positionInc = 1, tx?: Knex) => { try { if (targetPos === -1) { // this means delete await (tx || db)(TableName.SecretImport) .where({ folderId }) .andWhere("position", ">", pos) - .decrement("position", 1); + .decrement("position", positionInc); return; } @@ -36,13 +36,13 @@ export const secretImportDALFactory = (db: TDbClient) => { .where({ folderId }) .where("position", "<=", targetPos) .andWhere("position", ">", pos) - .decrement("position", 1); + .decrement("position", positionInc); } else { await (tx || db)(TableName.SecretImport) .where({ folderId }) .where("position", ">=", targetPos) .andWhere("position", "<", pos) - .increment("position", 1); + .increment("position", positionInc); } } catch (error) { throw new DatabaseError({ error, name: "Update position" }); @@ -74,6 +74,7 @@ export const secretImportDALFactory = (db: TDbClient) => { try { const docs = await (tx || db)(TableName.SecretImport) .whereIn("folderId", folderIds) + .where("isReplication", false) .join(TableName.Environment, `${TableName.SecretImport}.importEnv`, `${TableName.Environment}.id`) .select( db.ref("*").withSchema(TableName.SecretImport) as unknown as keyof TSecretImports, diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index fffc22a99..06ffbc903 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -79,7 +79,7 @@ export const fnSecretsFromImports = async ({ let secretsFromDeeperImports: TSecretImportSecrets[] = []; if (deeperImports.length) { secretsFromDeeperImports = await fnSecretsFromImports({ - allowedImports: deeperImports, + allowedImports: deeperImports.filter(({ isReplication }) => !isReplication), secretImportDAL, folderDAL, secretDAL, diff --git a/backend/src/services/secret-import/secret-import-service.ts b/backend/src/services/secret-import/secret-import-service.ts index 43676ba04..237c7cfe4 100644 --- a/backend/src/services/secret-import/secret-import-service.ts +++ b/backend/src/services/secret-import/secret-import-service.ts @@ -1,7 +1,12 @@ +import path from "node:path"; + import { ForbiddenError, subject } from "@casl/ability"; +import { TableName } from "@app/db/schemas"; +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 { getReplicationFolderName } from "@app/ee/services/secret-replication/secret-replication-service"; import { BadRequestError } from "@app/lib/errors"; import { TProjectDALFactory } from "../project/project-dal"; @@ -16,6 +21,7 @@ import { TDeleteSecretImportDTO, TGetSecretImportsDTO, TGetSecretsFromImportDTO, + TResyncSecretImportReplicationDTO, TUpdateSecretImportDTO } from "./secret-import-types"; @@ -26,7 +32,8 @@ type TSecretImportServiceFactoryDep = { projectDAL: Pick; projectEnvDAL: TProjectEnvDALFactory; permissionService: Pick; - secretQueueService: Pick; + secretQueueService: Pick; + licenseService: Pick; }; const ERR_SEC_IMP_NOT_FOUND = new BadRequestError({ message: "Secret import not found" }); @@ -40,7 +47,8 @@ export const secretImportServiceFactory = ({ folderDAL, projectDAL, secretDAL, - secretQueueService + secretQueueService, + licenseService }: TSecretImportServiceFactoryDep) => { const createImport = async ({ environment, @@ -50,7 +58,8 @@ export const secretImportServiceFactory = ({ actorOrgId, actorAuthMethod, projectId, - path + isReplication, + path: secretPath }: TCreateSecretImportDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -63,7 +72,7 @@ export const secretImportServiceFactory = ({ // check if user has permission to import into destination path ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Create, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); // check if user has permission to import from target path @@ -74,10 +83,18 @@ export const secretImportServiceFactory = ({ secretPath: data.path }) ); + if (isReplication) { + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication." + }); + } + } await projectDAL.checkProjectUpgradeStatus(projectId); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Create import" }); const [importEnv] = await projectEnvDAL.findBySlugs(projectId, [data.environment]); @@ -88,35 +105,62 @@ export const secretImportServiceFactory = ({ const existingImport = await secretImportDAL.findOne({ folderId: sourceFolder.id, importEnv: folder.environment.id, - importPath: path + importPath: secretPath }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); } const secImport = await secretImportDAL.transaction(async (tx) => { const lastPos = await secretImportDAL.findLastImportPosition(folder.id, tx); - return secretImportDAL.create( + const doc = await secretImportDAL.create( { folderId: folder.id, position: lastPos + 1, importEnv: importEnv.id, - importPath: data.path + importPath: data.path, + isReplication }, tx ); + if (doc.isReplication) { + await secretImportDAL.create( + { + folderId: folder.id, + position: lastPos + 2, + isReserved: true, + importEnv: folder.environment.id, + importPath: path.join(secretPath, getReplicationFolderName(doc.id)) + }, + tx + ); + } + return doc; }); - await secretQueueService.syncSecrets({ - secretPath: secImport.importPath, - projectId, - environment: importEnv.slug - }); + if (secImport.isReplication && sourceFolder) { + await secretQueueService.replicateSecrets({ + secretPath: secImport.importPath, + projectId, + environmentSlug: importEnv.slug, + pickOnlyImportIds: [secImport.id], + actorId, + actor + }); + } else { + await secretQueueService.syncSecrets({ + secretPath, + projectId, + environmentSlug: environment, + actorId, + actor + }); + } return { ...secImport, importEnv }; }; const updateImport = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -135,10 +179,10 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Edit, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); const secImpDoc = await secretImportDAL.findOne({ folderId: folder.id, id }); @@ -158,7 +202,7 @@ export const secretImportServiceFactory = ({ const existingImport = await secretImportDAL.findOne({ folderId: sourceFolder.id, importEnv: folder.environment.id, - importPath: path + importPath: secretPath }); if (existingImport) throw new BadRequestError({ message: "Cyclic import not allowed" }); } @@ -167,12 +211,31 @@ export const secretImportServiceFactory = ({ const secImp = await secretImportDAL.findOne({ folderId: folder.id, id }); if (!secImp) throw ERR_SEC_IMP_NOT_FOUND; if (data.position) { - await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, tx); + if (secImp.isReplication) { + await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 2, tx); + } else { + await secretImportDAL.updateAllPosition(folder.id, secImp.position, data.position, 1, tx); + } + } + if (secImp.isReplication) { + const replicationFolderPath = path.join(secretPath, getReplicationFolderName(secImp.id)); + await secretImportDAL.update( + { + folderId: folder.id, + importEnv: folder.environment.id, + importPath: replicationFolderPath, + isReserved: true + }, + { position: data?.position ? data.position + 1 : undefined }, + tx + ); } const [doc] = await secretImportDAL.update( { id, folderId: folder.id }, { - position: data?.position, + // when moving replicated import, the position is meant for reserved import + // replicated one should always be behind the reserved import + position: data.position, importEnv: data?.environment ? importedEnv.id : undefined, importPath: data?.path }, @@ -184,7 +247,7 @@ export const secretImportServiceFactory = ({ }; const deleteImport = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -202,16 +265,34 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Delete, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Delete import" }); const secImport = await secretImportDAL.transaction(async (tx) => { const [doc] = await secretImportDAL.delete({ folderId: folder.id, id }, tx); if (!doc) throw new BadRequestError({ name: "Sec imp del", message: "Secret import doc not found" }); - await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, tx); + if (doc.isReplication) { + const replicationFolderPath = path.join(secretPath, getReplicationFolderName(doc.id)); + const replicatedFolder = await folderDAL.findBySecretPath(projectId, environment, replicationFolderPath, tx); + if (replicatedFolder) { + await secretImportDAL.delete( + { + folderId: folder.id, + importEnv: folder.environment.id, + importPath: replicationFolderPath, + isReserved: true + }, + tx + ); + await folderDAL.deleteById(replicatedFolder.id, tx); + } + await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 2, tx); + } else { + await secretImportDAL.updateAllPosition(folder.id, doc.position, -1, 1, tx); + } const importEnv = await projectEnvDAL.findById(doc.importEnv); if (!importEnv) throw new BadRequestError({ error: "Imported env not found", name: "Create import" }); @@ -219,16 +300,91 @@ export const secretImportServiceFactory = ({ }); await secretQueueService.syncSecrets({ - secretPath: path, + secretPath, projectId, - environment + environmentSlug: environment, + actor, + actorId }); return secImport; }; + const resyncSecretImportReplication = async ({ + environment, + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId, + path: secretPath, + id: secretImportDocId + }: TResyncSecretImportReplicationDTO) => { + const { permission, membership } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + // check if user has permission to import into destination path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) + ); + + const plan = await licenseService.getPlan(actorOrgId); + if (!plan.secretApproval) { + throw new BadRequestError({ + message: "Failed to create secret replication due to plan restriction. Upgrade plan to create replication." + }); + } + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Update import" }); + + const [secretImportDoc] = await secretImportDAL.find({ + folderId: folder.id, + [`${TableName.SecretImport}.id` as "id"]: secretImportDocId + }); + if (!secretImportDoc) throw new BadRequestError({ message: "Failed to find secret import" }); + + if (!secretImportDoc.isReplication) throw new BadRequestError({ message: "Import is not in replication mode" }); + + // check if user has permission to import from target path + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { + environment: secretImportDoc.importEnv.slug, + secretPath: secretImportDoc.importPath + }) + ); + + await projectDAL.checkProjectUpgradeStatus(projectId); + + const sourceFolder = await folderDAL.findBySecretPath( + projectId, + secretImportDoc.importEnv.slug, + secretImportDoc.importPath + ); + + if (membership && sourceFolder) { + await secretQueueService.replicateSecrets({ + secretPath: secretImportDoc.importPath, + projectId, + environmentSlug: secretImportDoc.importEnv.slug, + pickOnlyImportIds: [secretImportDoc.id], + actorId, + actor + }); + } + + return { message: "replication started" }; + }; + const getImports = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -245,10 +401,10 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) throw new BadRequestError({ message: "Folder not found", name: "Get imports" }); const secImports = await secretImportDAL.find({ folderId: folder.id }); @@ -256,7 +412,7 @@ export const secretImportServiceFactory = ({ }; const getSecretsFromImports = async ({ - path, + path: secretPath, environment, projectId, actor, @@ -273,13 +429,13 @@ export const secretImportServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( ProjectPermissionActions.Read, - subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ); - const folder = await folderDAL.findBySecretPath(projectId, environment, path); + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) return []; // this will already order by position // so anything based on this order will also be in right position - const secretImports = await secretImportDAL.find({ folderId: folder.id }); + const secretImports = await secretImportDAL.find({ folderId: folder.id, isReplication: false }); const allowedImports = secretImports.filter(({ importEnv, importPath }) => permission.can( @@ -299,6 +455,7 @@ export const secretImportServiceFactory = ({ deleteImport, getImports, getSecretsFromImports, + resyncSecretImportReplication, fnSecretsFromImports }; }; diff --git a/backend/src/services/secret-import/secret-import-types.ts b/backend/src/services/secret-import/secret-import-types.ts index d123f28da..01847738b 100644 --- a/backend/src/services/secret-import/secret-import-types.ts +++ b/backend/src/services/secret-import/secret-import-types.ts @@ -7,6 +7,7 @@ export type TCreateSecretImportDTO = { environment: string; path: string; }; + isReplication?: boolean; } & TProjectPermission; export type TUpdateSecretImportDTO = { @@ -16,6 +17,12 @@ export type TUpdateSecretImportDTO = { data: Partial<{ environment: string; path: string; position: number }>; } & TProjectPermission; +export type TResyncSecretImportReplicationDTO = { + environment: string; + path: string; + id: string; +} & TProjectPermission; + export type TDeleteSecretImportDTO = { environment: string; path: string; diff --git a/backend/src/services/secret-sharing/secret-sharing-dal.ts b/backend/src/services/secret-sharing/secret-sharing-dal.ts new file mode 100644 index 000000000..6b5090d66 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-dal.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TSecretSharingDALFactory = ReturnType; + +export const secretSharingDALFactory = (db: TDbClient) => { + const sharedSecretOrm = ormify(db, TableName.SecretSharing); + + const pruneExpiredSharedSecrets = async (tx?: Knex) => { + try { + const today = new Date(); + const docs = await (tx || db)(TableName.SecretSharing).where("expiresAt", "<", today).del(); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "pruneExpiredSharedSecrets" }); + } + }; + + return { + ...sharedSecretOrm, + pruneExpiredSharedSecrets + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-service.ts b/backend/src/services/secret-sharing/secret-sharing-service.ts new file mode 100644 index 000000000..012b0f130 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-service.ts @@ -0,0 +1,139 @@ +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { BadRequestError, UnauthorizedError } from "@app/lib/errors"; + +import { TSecretSharingDALFactory } from "./secret-sharing-dal"; +import { + TCreatePublicSharedSecretDTO, + TCreateSharedSecretDTO, + TDeleteSharedSecretDTO, + TSharedSecretPermission +} from "./secret-sharing-types"; + +type TSecretSharingServiceFactoryDep = { + permissionService: Pick; + secretSharingDAL: TSecretSharingDALFactory; +}; + +export type TSecretSharingServiceFactory = ReturnType; + +export const secretSharingServiceFactory = ({ + permissionService, + secretSharingDAL +}: TSecretSharingServiceFactoryDep) => { + const createSharedSecret = async (createSharedSecretInput: TCreateSharedSecretDTO) => { + const { + actor, + actorId, + orgId, + actorAuthMethod, + actorOrgId, + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + } = createSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + + if (new Date(expiresAt) < new Date()) { + throw new BadRequestError({ message: "Expiration date cannot be in the past" }); + } + + // Limit Expiry Time to 1 month + const expiryTime = new Date(expiresAt).getTime(); + const currentTime = new Date().getTime(); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + if (expiryTime - currentTime > thirtyDays) { + throw new BadRequestError({ message: "Expiration date cannot be more than 30 days" }); + } + + // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) + if (encryptedValue.length > 13000) { + throw new BadRequestError({ message: "Shared secret value too long" }); + } + + const newSharedSecret = await secretSharingDAL.create({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews, + userId: actorId, + orgId + }); + return { id: newSharedSecret.id }; + }; + + const createPublicSharedSecret = async (createSharedSecretInput: TCreatePublicSharedSecretDTO) => { + const { encryptedValue, iv, tag, hashedHex, expiresAt, expiresAfterViews } = createSharedSecretInput; + if (new Date(expiresAt) < new Date()) { + throw new BadRequestError({ message: "Expiration date cannot be in the past" }); + } + + // Limit Expiry Time to 1 month + const expiryTime = new Date(expiresAt).getTime(); + const currentTime = new Date().getTime(); + const thirtyDays = 30 * 24 * 60 * 60 * 1000; + if (expiryTime - currentTime > thirtyDays) { + throw new BadRequestError({ message: "Expiration date cannot exceed more than 30 days" }); + } + + // Limit Input ciphertext length to 13000 (equivalent to 10,000 characters of Plaintext) + if (encryptedValue.length > 13000) { + throw new BadRequestError({ message: "Shared secret value too long" }); + } + + const newSharedSecret = await secretSharingDAL.create({ + encryptedValue, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + }); + return { id: newSharedSecret.id }; + }; + + const getSharedSecrets = async (getSharedSecretsInput: TSharedSecretPermission) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId } = getSharedSecretsInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const userSharedSecrets = await secretSharingDAL.find({ userId: actorId, orgId }, { sort: [["expiresAt", "asc"]] }); + return userSharedSecrets; + }; + + const getActiveSharedSecretByIdAndHashedHex = async (sharedSecretId: string, hashedHex: string) => { + const sharedSecret = await secretSharingDAL.findOne({ id: sharedSecretId, hashedHex }); + if (!sharedSecret) return; + if (sharedSecret.expiresAt && sharedSecret.expiresAt < new Date()) { + return; + } + if (sharedSecret.expiresAfterViews != null && sharedSecret.expiresAfterViews >= 0) { + if (sharedSecret.expiresAfterViews === 0) { + await secretSharingDAL.deleteById(sharedSecretId); + return; + } + await secretSharingDAL.updateById(sharedSecretId, { $decr: { expiresAfterViews: 1 } }); + } + return sharedSecret; + }; + + const deleteSharedSecretById = async (deleteSharedSecretInput: TDeleteSharedSecretDTO) => { + const { actor, actorId, orgId, actorAuthMethod, actorOrgId, sharedSecretId } = deleteSharedSecretInput; + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + if (!permission) throw new UnauthorizedError({ name: "User not in org" }); + const deletedSharedSecret = await secretSharingDAL.deleteById(sharedSecretId); + return deletedSharedSecret; + }; + + return { + createSharedSecret, + createPublicSharedSecret, + getSharedSecrets, + deleteSharedSecretById, + getActiveSharedSecretByIdAndHashedHex + }; +}; diff --git a/backend/src/services/secret-sharing/secret-sharing-types.ts b/backend/src/services/secret-sharing/secret-sharing-types.ts new file mode 100644 index 000000000..769bb4479 --- /dev/null +++ b/backend/src/services/secret-sharing/secret-sharing-types.ts @@ -0,0 +1,24 @@ +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; + +export type TSharedSecretPermission = { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + orgId: string; +}; + +export type TCreatePublicSharedSecretDTO = { + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; + expiresAfterViews: number; +}; + +export type TCreateSharedSecretDTO = TSharedSecretPermission & TCreatePublicSharedSecretDTO; + +export type TDeleteSharedSecretDTO = { + sharedSecretId: string; +} & TSharedSecretPermission; diff --git a/backend/src/services/secret-tag/secret-tag-service.ts b/backend/src/services/secret-tag/secret-tag-service.ts index ed8f5fec7..76b57dc90 100644 --- a/backend/src/services/secret-tag/secret-tag-service.ts +++ b/backend/src/services/secret-tag/secret-tag-service.ts @@ -2,10 +2,17 @@ import { ForbiddenError } from "@casl/ability"; 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 { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TSecretTagDALFactory } from "./secret-tag-dal"; -import { TCreateTagDTO, TDeleteTagDTO, TListProjectTagsDTO } from "./secret-tag-types"; +import { + TCreateTagDTO, + TDeleteTagDTO, + TGetTagByIdDTO, + TGetTagBySlugDTO, + TListProjectTagsDTO, + TUpdateTagDTO +} from "./secret-tag-types"; type TSecretTagServiceFactoryDep = { secretTagDAL: TSecretTagDALFactory; @@ -42,11 +49,34 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe name, slug, color, - createdBy: actorId + createdBy: actorId, + createdByActorType: actor }); return newTag; }; + const updateTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id, name, color, slug }: TUpdateTagDTO) => { + const tag = await secretTagDAL.findById(id); + if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); + + if (slug) { + const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId }); + if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Tags); + + const updatedTag = await secretTagDAL.updateById(tag.id, { name, color, slug }); + return updatedTag; + }; + const deleteTag = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TDeleteTagDTO) => { const tag = await secretTagDAL.findById(id); if (!tag) throw new BadRequestError({ message: "Tag doesn't exist" }); @@ -64,6 +94,38 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return deletedTag; }; + const getTagById = async ({ actorId, actor, actorOrgId, actorAuthMethod, id }: TGetTagByIdDTO) => { + const tag = await secretTagDAL.findById(id); + if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + + return tag; + }; + + const getTagBySlug = async ({ actorId, actor, actorOrgId, actorAuthMethod, slug, projectId }: TGetTagBySlugDTO) => { + const tag = await secretTagDAL.findOne({ projectId, slug }); + if (!tag) throw new NotFoundError({ message: "Tag doesn't exist" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + tag.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); + + return tag; + }; + const getProjectTags = async ({ actor, actorId, actorOrgId, actorAuthMethod, projectId }: TListProjectTagsDTO) => { const { permission } = await permissionService.getProjectPermission( actor, @@ -78,5 +140,5 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe return tags; }; - return { createTag, deleteTag, getProjectTags }; + return { createTag, deleteTag, getProjectTags, getTagById, getTagBySlug, updateTag }; }; diff --git a/backend/src/services/secret-tag/secret-tag-types.ts b/backend/src/services/secret-tag/secret-tag-types.ts index d2f027153..f2ace0901 100644 --- a/backend/src/services/secret-tag/secret-tag-types.ts +++ b/backend/src/services/secret-tag/secret-tag-types.ts @@ -6,6 +6,21 @@ export type TCreateTagDTO = { slug: string; } & TProjectPermission; +export type TUpdateTagDTO = { + id: string; + name?: string; + slug?: string; + color?: string; +} & Omit; + +export type TGetTagByIdDTO = { + id: string; +} & Omit; + +export type TGetTagBySlugDTO = { + slug: string; +} & TProjectPermission; + export type TDeleteTagDTO = { id: string; } & Omit; 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 51ad7a6aa..aa112e6b0 100644 --- a/backend/src/services/secret/secret-fns.ts +++ b/backend/src/services/secret/secret-fns.ts @@ -32,6 +32,8 @@ import { TCreateManySecretsRawFn, TCreateManySecretsRawFnFactory, TFnSecretBlindIndexCheck, + TFnSecretBlindIndexCheckV2, + TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate, TUpdateManySecretsRawFn, @@ -149,7 +151,8 @@ export const recursivelyGetSecretPaths = ({ // Fetch all folders in env once with a single query const folders = await folderDAL.find({ - envId: env.id + envId: env.id, + isReserved: false }); // Build the folder hierarchy map @@ -306,7 +309,7 @@ export const interpolateSecrets = ({ projectId, secretEncKey, secretDAL, folderD }; const expandSecrets = async ( - secrets: Record + secrets: Record ) => { const expandedSec: Record = {}; const interpolatedSec: Record = {}; @@ -326,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; } @@ -344,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; @@ -353,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({ @@ -392,10 +405,36 @@ export const decryptSecretRaw = ( type: secret.type, _id: secret.id, id: secret.id, - user: secret.userId + user: secret.userId, + tags: secret.tags, + skipMultilineEncoding: secret.skipMultilineEncoding }; }; +// this is used when secret blind index already exist +// mainly for secret approval +export const fnSecretBlindIndexCheckV2 = async ({ + inputSecrets, + folderId, + userId, + secretDAL +}: TFnSecretBlindIndexCheckV2) => { + if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { + throw new BadRequestError({ message: "Missing user id for personal secret" }); + } + const secrets = await secretDAL.findByBlindIndexes( + folderId, + inputSecrets.map(({ secretBlindIndex, type }) => ({ + blindIndex: secretBlindIndex, + type: type || SecretType.Shared + })), + userId + ); + const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string); + + return { secsGroupedByBlindIndex, secrets }; +}; + /** * Grabs and processes nested secret references from a string * @@ -598,6 +637,35 @@ export const fnSecretBulkUpdate = async ({ return newSecrets.map((secret) => ({ ...secret, _id: secret.id })); }; +export const fnSecretBulkDelete = async ({ + folderId, + inputSecrets, + tx, + actorId, + secretDAL, + secretQueueService +}: TFnSecretBulkDelete) => { + const deletedSecrets = await secretDAL.deleteMany( + inputSecrets.map(({ type, secretBlindIndex }) => ({ + blindIndex: secretBlindIndex, + type + })), + folderId, + actorId, + tx + ); + + await Promise.allSettled( + deletedSecrets + .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) + .map(({ id, secretReminderRepeatDays }) => + secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) + ) + ); + + return deletedSecrets; +}; + export const createManySecretsRawFnFactory = ({ projectDAL, projectBotDAL, diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index f3e3f1731..ac27d912f 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"; @@ -28,7 +30,12 @@ import { TWebhookDALFactory } from "../webhook/webhook-dal"; import { fnTriggerWebhook } from "../webhook/webhook-fns"; import { TSecretDALFactory } from "./secret-dal"; import { interpolateSecrets } from "./secret-fns"; -import { TCreateSecretReminderDTO, THandleReminderDTO, TRemoveSecretReminderDTO } from "./secret-types"; +import { + TCreateSecretReminderDTO, + THandleReminderDTO, + TRemoveSecretReminderDTO, + TSyncSecretsDTO +} from "./secret-types"; export type TSecretQueueFactory = ReturnType; type TSecretQueueFactoryDep = { @@ -59,8 +66,13 @@ export type TGetSecrets = { }; const MAX_SYNC_SECRET_DEPTH = 5; -const uniqueIntegrationKey = (environment: string, secretPath: string) => `integration-${environment}-${secretPath}`; +export const uniqueSecretQueueKey = (environment: string, secretPath: string) => + `secret-queue-dedupe-${environment}-${secretPath}`; +type TIntegrationSecret = Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined } +>; export const secretQueueFactory = ({ queueService, integrationDAL, @@ -81,68 +93,6 @@ export const secretQueueFactory = ({ secretTagDAL, secretVersionTagDAL }: TSecretQueueFactoryDep) => { - const createManySecretsRawFn = createManySecretsRawFnFactory({ - projectDAL, - projectBotDAL, - secretDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - folderDAL - }); - - const updateManySecretsRawFn = updateManySecretsRawFnFactory({ - projectDAL, - projectBotDAL, - secretDAL, - secretVersionDAL, - secretBlindIndexDAL, - secretTagDAL, - secretVersionTagDAL, - folderDAL - }); - - const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { - await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { - attempts: 3, - delay: 1000, - backoff: { - type: "exponential", - delay: 3000 - }, - removeOnComplete: true, - removeOnFail: true - }); - }; - - const syncSecrets = async ({ - deDupeQueue = {}, - ...dto - }: TGetSecrets & { depth?: number; deDupeQueue?: Record }) => { - const deDuplicationKey = uniqueIntegrationKey(dto.environment, dto.secretPath); - if (deDupeQueue?.[deDuplicationKey]) { - return; - } - // eslint-disable-next-line - deDupeQueue[deDuplicationKey] = true; - logger.info( - `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environment}] [path=${dto.secretPath}]` - ); - await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, dto, { - jobId: `secret-webhook-${dto.environment}-${dto.projectId}-${dto.secretPath}`, - removeOnFail: true, - removeOnComplete: true, - delay: 1000, - attempts: 5, - backoff: { - type: "exponential", - delay: 3000 - } - }); - await syncIntegrations({ ...dto, deDupeQueue }); - }; - const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const appCfg = getConfig(); await queueService.stopRepeatableJob( @@ -237,8 +187,27 @@ export const secretQueueFactory = ({ } } }; + const createManySecretsRawFn = createManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); - type Content = Record; + const updateManySecretsRawFn = updateManySecretsRawFnFactory({ + projectDAL, + projectBotDAL, + secretDAL, + secretVersionDAL, + secretBlindIndexDAL, + secretTagDAL, + secretVersionTagDAL, + folderDAL + }); /** * Return the secrets in a given [folderId] including secrets from @@ -251,7 +220,7 @@ export const secretQueueFactory = ({ key: string; depth: number; }) => { - let content: Content = {}; + let content: TIntegrationSecret = {}; if (dto.depth > MAX_SYNC_SECRET_DEPTH) { logger.info( `getIntegrationSecrets: secret depth exceeded for [projectId=${dto.projectId}] [folderId=${dto.folderId}] [depth=${dto.depth}]` @@ -301,7 +270,7 @@ export const secretQueueFactory = ({ await expandSecrets(content); // check if current folder has any imports from other folders - const secretImport = await secretImportDAL.find({ folderId: dto.folderId }); + const secretImport = await secretImportDAL.find({ folderId: dto.folderId, isReplication: false }); // if no imports then return secrets in the current folder if (!secretImport) return content; @@ -333,96 +302,207 @@ export const secretQueueFactory = ({ return content; }; + const syncIntegrations = async (dto: TGetSecrets & { deDupeQueue?: Record }) => { + await queueService.queue(QueueName.IntegrationSync, QueueJobs.IntegrationSync, dto, { + attempts: 3, + delay: 1000, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + }; + + const replicateSecrets = async (dto: Omit) => { + await queueService.queue(QueueName.SecretReplication, QueueJobs.SecretReplication, dto, { + attempts: 3, + backoff: { + type: "exponential", + delay: 2000 + }, + removeOnComplete: true, + removeOnFail: true + }); + }; + + const syncSecrets = async ({ + // seperate de-dupe queue for integration sync and replication sync + _deDupeQueue: deDupeQueue = {}, + _depth: depth = 0, + _deDupeReplicationQueue: deDupeReplicationQueue = {}, + ...dto + }: TSyncSecretsDTO) => { + logger.info( + `syncSecrets: syncing project secrets where [projectId=${dto.projectId}] [environment=${dto.environmentSlug}] [path=${dto.secretPath}]` + ); + const deDuplicationKey = uniqueSecretQueueKey(dto.environmentSlug, dto.secretPath); + if ( + !dto.excludeReplication + ? deDupeReplicationQueue?.[deDuplicationKey] + : deDupeQueue?.[deDuplicationKey] || depth > MAX_SYNC_SECRET_DEPTH + ) { + return; + } + // eslint-disable-next-line + deDupeQueue[deDuplicationKey] = true; + // eslint-disable-next-line + deDupeReplicationQueue[deDuplicationKey] = true; + await queueService.queue( + QueueName.SecretSync, + QueueJobs.SecretSync, + { + ...dto, + _deDupeQueue: deDupeQueue, + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth + } as TSyncSecretsDTO, + { + removeOnFail: true, + removeOnComplete: true, + delay: 1000, + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + } + } + ); + }; + + queueService.start(QueueName.SecretSync, async (job) => { + const { + _deDupeQueue: deDupeQueue, + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth, + secretPath, + projectId, + environmentSlug: environment, + excludeReplication, + actorId, + actor + } = job.data; + + await queueService.queue( + QueueName.SecretWebhook, + QueueJobs.SecWebhook, + { environment, projectId, secretPath }, + { + jobId: `secret-webhook-${environment}-${projectId}-${secretPath}`, + removeOnFail: { count: 5 }, + removeOnComplete: true, + delay: 1000, + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + } + } + ); + await syncIntegrations({ secretPath, projectId, environment, deDupeQueue }); + if (!excludeReplication) { + await replicateSecrets({ + _deDupeReplicationQueue: deDupeReplicationQueue, + _depth: depth, + projectId, + secretPath, + actorId, + actor, + excludeReplication, + environmentSlug: environment + }); + } + }); + queueService.start(QueueName.IntegrationSync, async (job) => { const { environment, projectId, secretPath, depth = 1, deDupeQueue = {} } = job.data; + if (depth > MAX_SYNC_SECRET_DEPTH) return; const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); if (!folder) { - logger.error(new Error("Secret path not found")); - return; + throw new Error("Secret path not found"); } - // start syncing all linked imports also - if (depth < MAX_SYNC_SECRET_DEPTH) { - // find all imports made with the given environment and secret path - const linkSourceDto = { - projectId, - importEnv: folder.environment.id, - importPath: secretPath - }; - const imports = await secretImportDAL.find(linkSourceDto); + // find all imports made with the given environment and secret path + const linkSourceDto = { + projectId, + importEnv: folder.environment.id, + importPath: secretPath, + isReplication: false + }; + const imports = await secretImportDAL.find(linkSourceDto); - if (imports.length) { - // keep calling sync secret for all the imports made - const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); - const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); - const foldersGroupedById = groupBy(importedFolders, (i) => i.child || i.id); - logger.info( - `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - imports - .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0].path)) - // filter out already synced ones - .filter( - ({ folderId }) => - !deDupeQueue[ - uniqueIntegrationKey( - foldersGroupedById[folderId][0].environmentSlug, - foldersGroupedById[folderId][0].path - ) - ] - ) - .map(({ folderId }) => - syncSecrets({ - depth: depth + 1, - projectId, - secretPath: foldersGroupedById[folderId][0].path, - environment: foldersGroupedById[folderId][0].environmentSlug, - deDupeQueue - }) - ) - ); - } - - const secretReferences = await secretDAL.findReferencedSecretReferences( - projectId, - folder.environment.slug, - secretPath + if (imports.length) { + // keep calling sync secret for all the imports made + const importedFolderIds = unique(imports, (i) => i.folderId).map(({ folderId }) => folderId); + const importedFolders = await folderDAL.findSecretPathByFolderIds(projectId, importedFolderIds); + const foldersGroupedById = groupBy(importedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to link change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + imports + .filter(({ folderId }) => Boolean(foldersGroupedById[folderId][0]?.path as string)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + foldersGroupedById[folderId][0]?.environmentSlug as string, + foldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: foldersGroupedById[folderId][0]?.path as string, + environmentSlug: foldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) + ); + } + + const secretReferences = await secretDAL.findReferencedSecretReferences( + projectId, + folder.environment.slug, + secretPath + ); + if (secretReferences.length) { + const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); + const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); + const referencedFoldersGroupedById = groupBy(referencedFolders.filter(Boolean), (i) => i?.id as string); + logger.info( + `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` + ); + await Promise.all( + secretReferences + .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0]?.path)) + // filter out already synced ones + .filter( + ({ folderId }) => + !deDupeQueue[ + uniqueSecretQueueKey( + referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + referencedFoldersGroupedById[folderId][0]?.path as string + ) + ] + ) + .map(({ folderId }) => + syncSecrets({ + projectId, + secretPath: referencedFoldersGroupedById[folderId][0]?.path as string, + environmentSlug: referencedFoldersGroupedById[folderId][0]?.environmentSlug as string, + _deDupeQueue: deDupeQueue, + _depth: depth + 1, + excludeReplication: true + }) + ) ); - if (secretReferences.length) { - const referencedFolderIds = unique(secretReferences, (i) => i.folderId).map(({ folderId }) => folderId); - const referencedFolders = await folderDAL.findSecretPathByFolderIds(projectId, referencedFolderIds); - const referencedFoldersGroupedById = groupBy(referencedFolders, (i) => i.child || i.id); - logger.info( - `getIntegrationSecrets: Syncing secret due to reference change [jobId=${job.id}] [projectId=${job.data.projectId}] [environment=${job.data.environment}] [secretPath=${job.data.secretPath}] [depth=${depth}]` - ); - await Promise.all( - secretReferences - .filter(({ folderId }) => Boolean(referencedFoldersGroupedById[folderId][0].path)) - // filter out already synced ones - .filter( - ({ folderId }) => - !deDupeQueue[ - uniqueIntegrationKey( - referencedFoldersGroupedById[folderId][0].environmentSlug, - referencedFoldersGroupedById[folderId][0].path - ) - ] - ) - .map(({ folderId }) => - syncSecrets({ - depth: depth + 1, - projectId, - secretPath: referencedFoldersGroupedById[folderId][0].path, - environment: referencedFoldersGroupedById[folderId][0].environmentSlug, - deDupeQueue - }) - ) - ); - } - } else { - logger.info(`getIntegrationSecrets: Secret depth exceeded for [projectId=${projectId}] [folderId=${folder.id}]`); } const integrations = await integrationDAL.findByProjectIdV2(projectId, environment); // note: returns array of integrations + integration auths in this environment @@ -464,7 +544,7 @@ export const secretQueueFactory = ({ } try { - await syncIntegrationSecrets({ + const response = await syncIntegrationSecrets({ createManySecretsRawFn, updateManySecretsRawFn, integrationDAL, @@ -482,15 +562,20 @@ export const secretQueueFactory = ({ await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), - syncMessage: "", - isSynced: true + syncMessage: response?.syncMessage ?? "", + isSynced: response?.isSynced ?? true }); - } catch (err: unknown) { - logger.info("Secret integration sync error:", err); + } catch (err) { + logger.info("Secret integration sync error: %o", err); + + const message = + (err instanceof AxiosError ? JSON.stringify(err?.response?.data) : (err as Error)?.message) || + "Unknown error occurred."; + await integrationDAL.updateById(integration.id, { lastSyncJobId: job.id, lastUsed: new Date(), - syncMessage: (err as Error)?.message, + syncMessage: message, isSynced: false }); } @@ -546,10 +631,11 @@ export const secretQueueFactory = ({ return { // depth is internal only field thus no need to make it available outside - syncSecrets: (dto: TGetSecrets) => syncSecrets(dto), + syncSecrets, syncIntegrations, addSecretReminder, removeSecretReminder, - handleSecretReminder + handleSecretReminder, + replicateSecrets }; }; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 39e47a28e..a5a469a8f 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -35,6 +35,7 @@ import { TSecretDALFactory } from "./secret-dal"; import { decryptSecretRaw, fnSecretBlindIndexCheck, + fnSecretBulkDelete, fnSecretBulkInsert, fnSecretBulkUpdate, getAllNestedSecretReferences, @@ -53,8 +54,6 @@ import { TDeleteManySecretRawDTO, TDeleteSecretDTO, TDeleteSecretRawDTO, - TFnSecretBlindIndexCheckV2, - TFnSecretBulkDelete, TGetASecretDTO, TGetASecretRawDTO, TGetSecretsDTO, @@ -139,53 +138,6 @@ export const secretServiceFactory = ({ return secretBlindIndex; }; - const fnSecretBulkDelete = async ({ folderId, inputSecrets, tx, actorId }: TFnSecretBulkDelete) => { - const deletedSecrets = await secretDAL.deleteMany( - inputSecrets.map(({ type, secretBlindIndex }) => ({ - blindIndex: secretBlindIndex, - type - })), - folderId, - actorId, - tx - ); - - for (const s of deletedSecrets) { - if (s.secretReminderRepeatDays) { - // eslint-disable-next-line no-await-in-loop - await secretQueueService - .removeSecretReminder({ - secretId: s.id, - repeatDays: s.secretReminderRepeatDays - }) - .catch((err) => { - logger.error(err, `Failed to delete secret reminder for secret with ID ${s?.id}`); - }); - } - } - - return deletedSecrets; - }; - - // this is used when secret blind index already exist - // mainly for secret approval - const fnSecretBlindIndexCheckV2 = async ({ inputSecrets, folderId, userId }: TFnSecretBlindIndexCheckV2) => { - if (inputSecrets.some(({ type }) => type === SecretType.Personal) && !userId) { - throw new BadRequestError({ message: "Missing user id for personal secret" }); - } - const secrets = await secretDAL.findByBlindIndexes( - folderId, - inputSecrets.map(({ secretBlindIndex, type }) => ({ - blindIndex: secretBlindIndex, - type: type || SecretType.Shared - })), - userId - ); - const secsGroupedByBlindIndex = groupBy(secrets, (i) => i.secretBlindIndex as string); - - return { secsGroupedByBlindIndex, secrets }; - }; - const createSecret = async ({ path, actor, @@ -283,8 +235,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + await secretQueueService.syncSecrets({ + secretPath: path, + actorId, + actor, + projectId, + environmentSlug: folder.environment.slug + }); return { ...secret[0], environment, workspace: projectId, tags, secretPath: path }; }; @@ -413,8 +370,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - // TODO(akhilmhdh-pg): licence check, posthog service and snapshot + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return { ...updatedSecret[0], workspace: projectId, environment, secretPath: path }; }; @@ -470,6 +432,8 @@ export const secretServiceFactory = ({ projectId, folderId, actorId, + secretDAL, + secretQueueService, inputSecrets: [ { type: inputSecret.type as SecretType, @@ -481,8 +445,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); - + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); // TODO(akhilmhdh-pg): licence check, posthog service and snapshot return { ...deletedSecret[0], _id: deletedSecret[0].id, workspace: projectId, environment, secretPath: path }; }; @@ -551,7 +520,8 @@ export const secretServiceFactory = ({ if (includeImports) { const secretImports = await secretImportDAL.findByFolderIds(paths.map((p) => p.folderId)); - const allowedImports = secretImports.filter(({ importEnv, importPath }) => + const allowedImports = secretImports.filter(({ importEnv, importPath, isReplication }) => + !isReplication && // if its service token allow full access over imported one actor === ActorType.SERVICE ? true @@ -638,7 +608,7 @@ export const secretServiceFactory = ({ } const secret = await (version === undefined - ? secretDAL.findOne({ + ? secretDAL.findOneWithTags({ folderId, type: secretType, userId: secretType === SecretType.Personal ? actorId : null, @@ -656,7 +626,7 @@ export const secretServiceFactory = ({ // then search for imported secrets // here we consider the import order also thus starting from bottom if (!secret && includeImports) { - const secretImports = await secretImportDAL.find({ folderId }); + const secretImports = await secretImportDAL.find({ folderId, isReplication: false }); const allowedImports = secretImports.filter(({ importEnv, importPath }) => // if its service token allow full access over imported one actor === ActorType.SERVICE @@ -767,7 +737,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return newSecrets; }; @@ -867,7 +843,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return secrets; }; @@ -917,6 +899,8 @@ export const secretServiceFactory = ({ const secretsDeleted = await secretDAL.transaction(async (tx) => fnSecretBulkDelete({ + secretDAL, + secretQueueService, inputSecrets: inputSecrets.map(({ type, secretName }) => ({ secretBlindIndex: keyName2BlindIndex[secretName], type @@ -929,7 +913,13 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ secretPath: path, projectId, environment }); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath: path, + projectId, + environmentSlug: folder.environment.slug + }); return secretsDeleted; }; @@ -962,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({ @@ -981,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]) { @@ -1000,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 }; }); @@ -1021,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 }; }; @@ -1078,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" }); @@ -1106,12 +1149,10 @@ export const secretServiceFactory = ({ secretCommentCiphertext: secretCommentEncrypted.ciphertext, secretCommentIV: secretCommentEncrypted.iv, secretCommentTag: secretCommentEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); - await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1126,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" }); @@ -1146,12 +1188,11 @@ export const secretServiceFactory = ({ secretValueCiphertext: secretValueEncrypted.ciphertext, secretValueIV: secretValueEncrypted.iv, secretValueTag: secretValueEncrypted.tag, - skipMultilineEncoding + skipMultilineEncoding, + tags: tagIds }); await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1181,9 +1222,6 @@ export const secretServiceFactory = ({ actorAuthMethod }); - await snapshotService.performSnapshot(secret.folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return decryptSecretRaw(secret, botKey); }; @@ -1232,9 +1270,6 @@ export const secretServiceFactory = ({ }) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) ); @@ -1286,9 +1321,6 @@ export const secretServiceFactory = ({ }) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) ); @@ -1322,9 +1354,6 @@ export const secretServiceFactory = ({ secrets: inputSecrets.map(({ secretKey }) => ({ secretName: secretKey, type: SecretType.Shared })) }); - await snapshotService.performSnapshot(secrets[0].folderId); - await secretQueueService.syncSecrets({ secretPath, projectId, environment }); - return secrets.map((secret) => decryptSecretRaw({ ...secret, workspace: projectId, environment, secretPath }, botKey) ); @@ -1448,7 +1477,12 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + await secretQueueService.syncSecrets({ + secretPath, + projectId: project.id, + environmentSlug: environment, + excludeReplication: true + }); return { ...updatedSecret[0], @@ -1550,7 +1584,12 @@ export const secretServiceFactory = ({ ); await snapshotService.performSnapshot(folder.id); - await secretQueueService.syncSecrets({ secretPath, projectId: project.id, environment }); + await secretQueueService.syncSecrets({ + secretPath, + projectId: project.id, + environmentSlug: environment, + excludeReplication: true + }); return { ...updatedSecret[0], @@ -1624,12 +1663,6 @@ export const secretServiceFactory = ({ updateManySecretsRaw, deleteManySecretsRaw, getSecretVersions, - backfillSecretReferences, - // external services function - fnSecretBulkDelete, - fnSecretBulkUpdate, - fnSecretBlindIndexCheck, - fnSecretBulkInsert, - fnSecretBlindIndexCheckV2 + backfillSecretReferences }; }; diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index 7e713a80f..1aac324c5 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -11,6 +11,8 @@ import { TSecretBlindIndexDALFactory } from "@app/services/secret-blind-index/se import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { ActorType } from "../auth/auth-type"; + type TPartialSecret = Pick; type TPartialInputSecret = Pick; @@ -162,6 +164,7 @@ export type TCreateSecretRawDTO = TProjectPermission & { secretName: string; secretValue: string; type: SecretType; + tagIds?: string[]; secretComment?: string; skipMultilineEncoding?: boolean; }; @@ -172,6 +175,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { secretName: string; secretValue?: string; type: SecretType; + tagIds?: string[]; skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; @@ -264,6 +268,10 @@ export type TFnSecretBulkDelete = { inputSecrets: Array<{ type: SecretType; secretBlindIndex: string }>; actorId: string; tx?: Knex; + secretDAL: Pick; + secretQueueService: { + removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise; + }; }; export type TFnSecretBlindIndexCheck = { @@ -277,6 +285,7 @@ export type TFnSecretBlindIndexCheck = { // when blind index is already present export type TFnSecretBlindIndexCheckV2 = { + secretDAL: Pick; folderId: string; userId?: string; inputSecrets: Array<{ secretBlindIndex: string; type?: SecretType }>; @@ -363,3 +372,27 @@ export type TUpdateManySecretsRawFn = { }[]; userId?: string; }; + +export enum SecretOperations { + Create = "create", + Update = "update", + Delete = "delete" +} + +export type TSyncSecretsDTO = { + _deDupeQueue?: Record; + _deDupeReplicationQueue?: Record; + _depth?: number; + secretPath: string; + projectId: string; + environmentSlug: string; + // cases for just doing sync integration and webhook + excludeReplication?: T; +} & (T extends true + ? object + : { + actor: ActorType; + actorId: string; + // used for import creation to trigger replication + pickOnlyImportIds?: string[]; + }); diff --git a/backend/src/services/secret/secret-version-dal.ts b/backend/src/services/secret/secret-version-dal.ts index 758352ed2..4d641bb8d 100644 --- a/backend/src/services/secret/secret-version-dal.ts +++ b/backend/src/services/secret/secret-version-dal.ts @@ -89,6 +89,7 @@ export const secretVersionDALFactory = (db: TDbClient) => { const findLatestVersionMany = async (folderId: string, secretIds: string[], tx?: Knex) => { try { + if (!secretIds.length) return {}; const docs: Array = await (tx || db)(TableName.SecretVersion) .where("folderId", folderId) .whereIn(`${TableName.SecretVersion}.secretId`, secretIds) @@ -110,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 81680537d..1fb89c553 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -21,6 +21,7 @@ export enum SmtpTemplates { EmailVerification = "emailVerification.handlebars", SecretReminder = "secretReminder.handlebars", EmailMfa = "emailMfa.handlebars", + UnlockAccount = "unlockAccount.handlebars", AccessApprovalRequest = "accessApprovalRequest.handlebars", HistoricalSecretList = "historicalSecretLeakIncident.handlebars", NewDeviceJoin = "newDevice.handlebars", @@ -40,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/smtp/templates/unlockAccount.handlebars b/backend/src/services/smtp/templates/unlockAccount.handlebars new file mode 100644 index 000000000..36664be87 --- /dev/null +++ b/backend/src/services/smtp/templates/unlockAccount.handlebars @@ -0,0 +1,16 @@ + + + + + + Your Infisical account has been locked + + + +

Unlock your Infisical account

+

Your account has been temporarily locked due to multiple failed login attempts. + To unlock your account, follow the link here +

If these attempts were not made by you, reset your password immediately.

+ + + \ No newline at end of file diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index bec8f3f37..f1d931b20 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -1,6 +1,10 @@ +import bcrypt from "bcrypt"; + import { TSuperAdmin, TSuperAdminUpdate } from "@app/db/schemas"; import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; +import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { getUserPrivateKey } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; import { TAuthLoginFactory } from "../auth/auth-login-service"; @@ -77,6 +81,7 @@ export const superAdminServiceFactory = ({ firstName, salt, email, + password, verifier, publicKey, protectedKey, @@ -92,6 +97,17 @@ export const superAdminServiceFactory = ({ const existingUser = await userDAL.findOne({ email }); if (existingUser) throw new BadRequestError({ name: "Admin sign up", message: "User already exist" }); + const privateKey = await getUserPrivateKey(password, { + salt, + protectedKey, + protectedKeyIV, + protectedKeyTag, + encryptedPrivateKey, + iv: encryptedPrivateKeyIV, + tag: encryptedPrivateKeyTag + }); + const hashedPassword = await bcrypt.hash(password, appCfg.BCRYPT_SALT_ROUND); + const { iv, tag, ciphertext, encoding } = infisicalSymmetricEncypt(privateKey); const userInfo = await userDAL.transaction(async (tx) => { const newUser = await userDAL.create( { @@ -119,7 +135,12 @@ export const superAdminServiceFactory = ({ iv: encryptedPrivateKeyIV, tag: encryptedPrivateKeyTag, verifier, - userId: newUser.id + userId: newUser.id, + hashedPassword, + serverEncryptedPrivateKey: ciphertext, + serverEncryptedPrivateKeyIV: iv, + serverEncryptedPrivateKeyTag: tag, + serverEncryptedPrivateKeyEncoding: encoding }, tx ); diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index e586946f2..e444c8843 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -1,5 +1,6 @@ export type TAdminSignUpDTO = { email: string; + password: string; publicKey: string; salt: string; lastName?: string; diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 089f3b8c6..4ee8bdc1f 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -1,3 +1,5 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; import { BadRequestError } from "@app/lib/errors"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; import { TokenType } from "@app/services/auth-token/auth-token-types"; @@ -21,6 +23,7 @@ type TUserServiceFactoryDep = { | "findOneUserAction" | "createUserAction" | "findUserEncKeyByUserId" + | "delete" >; userAliasDAL: Pick; orgMembershipDAL: Pick; @@ -85,7 +88,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 +137,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, @@ -207,6 +219,34 @@ export const userServiceFactory = ({ return userAction; }; + const unlockUser = async (userId: string, token: string) => { + await tokenService.validateTokenForUser({ + userId, + code: token, + type: TokenType.TOKEN_USER_UNLOCK + }); + + await userDAL.update( + { id: userId }, + { consecutiveFailedMfaAttempts: 0, isLocked: false, temporaryLockDateEnd: null } + ); + }; + + const getUserPrivateKey = async (userId: string) => { + const user = await userDAL.findUserEncKeyByUserId(userId); + if (!user?.serverEncryptedPrivateKey || !user.serverEncryptedPrivateKeyIV || !user.serverEncryptedPrivateKeyTag) { + throw new BadRequestError({ message: "Private key not found. Please login again" }); + } + const privateKey = infisicalSymmetricDecrypt({ + ciphertext: user.serverEncryptedPrivateKey, + tag: user.serverEncryptedPrivateKeyTag, + iv: user.serverEncryptedPrivateKeyIV, + keyEncoding: user.serverEncryptedPrivateKeyEncoding as SecretKeyEncoding + }); + + return privateKey; + }; + return { sendEmailVerificationCode, verifyEmailVerificationCode, @@ -216,6 +256,8 @@ export const userServiceFactory = ({ deleteMe, getMe, createUserAction, - getUserAction + getUserAction, + unlockUser, + getUserPrivateKey }; }; 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..a2144b674 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,11 +3,14 @@ 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 github.com/h2non/filetype v1.1.3 + github.com/infisical/go-sdk v0.2.0 github.com/mattn/go-isatty v0.0.14 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a github.com/muesli/mango-cobra v1.2.0 @@ -20,24 +23,48 @@ require ( github.com/rs/zerolog v1.26.1 github.com/spf13/cobra v1.6.1 github.com/spf13/viper v1.8.1 - github.com/stretchr/testify v1.8.1 - golang.org/x/crypto v0.14.0 - golang.org/x/term v0.13.0 + github.com/stretchr/testify v1.9.0 + golang.org/x/crypto v0.23.0 + golang.org/x/term v0.20.0 gopkg.in/yaml.v2 v2.4.0 ) 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/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/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/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dvsekhvalnov/jose2go v1.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/errors v0.20.2 // indirect github.com/go-openapi/strfmt v0.21.3 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // 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 github.com/hashicorp/hcl v1.0.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magiconair/properties v1.8.5 // indirect @@ -58,17 +85,30 @@ require ( github.com/subosito/gotenv v1.2.0 // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // 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/net v0.25.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + golang.org/x/time v0.5.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 + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.62.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/fatih/color v1.13.0 - github.com/go-resty/resty/v2 v2.10.0 + github.com/go-resty/resty/v2 v2.13.1 github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jedib0t/go-pretty v4.3.0+incompatible github.com/manifoldco/promptui v0.9.0 diff --git a/cli/go.sum b/cli/go.sum index 353579136..3784ce4d8 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -18,15 +18,23 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +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/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +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= @@ -49,6 +57,32 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= @@ -74,6 +108,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= @@ -95,6 +131,8 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg= github.com/fatih/semgroup v1.2.0/go.mod h1:1KAD4iIYfXjE4U13B48VM4z9QUwV5Tt8O4rS879kgm8= +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.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -103,12 +141,17 @@ github.com/gitleaks/go-gitdiff v0.8.0/go.mod h1:pKz0X4YzCKZs30BL+weqBIG7mx0jl4tF github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/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-openapi/errors v0.20.2 h1:dxy7PGTqEh94zj2E3h1cUmQQWiM1+aeCROfAr02EmK8= github.com/go-openapi/errors v0.20.2/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M= github.com/go-openapi/strfmt v0.21.3 h1:xwhj5X6CjXEZZHMWy1zKJxvW9AfHC9pkyUjLvHtKG7o= github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= -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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -117,6 +160,8 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -142,6 +187,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= 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/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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= @@ -155,8 +202,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/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 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -173,11 +221,18 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +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.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= 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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -208,6 +263,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/infisical/go-sdk v0.2.0 h1:n1/KNdYpeQavSqVwC9BfeV8VRzf3N2X9zO1tzQOSj5Q= +github.com/infisical/go-sdk v0.2.0/go.mod h1:vHTDVw3k+wfStXab513TGk1n53kaKF2xgLqpw/xvtl4= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -328,8 +385,9 @@ github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH 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 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 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= @@ -338,8 +396,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 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/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tidwall/pretty v1.0.0 h1:HsD+QiTn7sK6flMKIvNmpqz1qrpP3Ps6jOKIKMooyg4= @@ -370,6 +429,18 @@ 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.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +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/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= @@ -383,8 +454,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -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= @@ -463,8 +535,9 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx 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= @@ -477,6 +550,8 @@ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +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= @@ -489,8 +564,9 @@ 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-20210220032951-036812b2e83c/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 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 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-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -544,14 +620,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/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= @@ -563,13 +641,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= @@ -650,6 +729,8 @@ google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjR google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +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= @@ -698,6 +779,10 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +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= @@ -718,6 +803,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +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= @@ -730,6 +817,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.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index 01f29a03a..fd1dcc574 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -391,6 +391,7 @@ func CallCreateSecretsV3(httpClient *resty.Client, request CreateSecretV3Request } func CallDeleteSecretsV3(httpClient *resty.Client, request DeleteSecretV3Request) error { + var secretsResponse GetEncryptedSecretsV3Response response, err := httpClient. R(). @@ -490,7 +491,7 @@ func CallUniversalAuthLogin(httpClient *resty.Client, request UniversalAuthLogin return universalAuthLoginResponse, nil } -func CallUniversalAuthRefreshAccessToken(httpClient *resty.Client, request UniversalAuthRefreshRequest) (UniversalAuthRefreshResponse, error) { +func CallMachineIdentityRefreshAccessToken(httpClient *resty.Client, request UniversalAuthRefreshRequest) (UniversalAuthRefreshResponse, error) { var universalAuthRefreshResponse UniversalAuthRefreshResponse response, err := httpClient. R(). @@ -500,11 +501,11 @@ func CallUniversalAuthRefreshAccessToken(httpClient *resty.Client, request Unive Post(fmt.Sprintf("%v/v1/auth/token/renew", config.INFISICAL_URL)) if err != nil { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallUniversalAuthRefreshAccessToken: Unable to complete api request [err=%s]", err) + return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unable to complete api request [err=%s]", err) } if response.IsError() { - return UniversalAuthRefreshResponse{}, fmt.Errorf("CallUniversalAuthRefreshAccessToken: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + return UniversalAuthRefreshResponse{}, fmt.Errorf("CallMachineIdentityRefreshAccessToken: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) } return universalAuthRefreshResponse, nil @@ -566,3 +567,39 @@ func CallCreateDynamicSecretLeaseV1(httpClient *resty.Client, request CreateDyna return createDynamicSecretLeaseResponse, nil } + +func CallCreateRawSecretsV3(httpClient *resty.Client, request CreateRawSecretV3Request) error { + response, err := httpClient. + R(). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) + + if err != nil { + return fmt.Errorf("CallCreateRawSecretsV3: Unable to complete api request [err=%w]", err) + } + + if response.IsError() { + return fmt.Errorf("CallCreateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + } + + return nil +} + +func CallUpdateRawSecretsV3(httpClient *resty.Client, request UpdateRawSecretByNameV3Request) error { + response, err := httpClient. + R(). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Patch(fmt.Sprintf("%v/v3/secrets/raw/%s", config.INFISICAL_URL, request.SecretName)) + + if err != nil { + return fmt.Errorf("CallUpdateRawSecretsV3: Unable to complete api request [err=%w]", err) + } + + if response.IsError() { + return fmt.Errorf("CallUpdateRawSecretsV3: Unsuccessful response [%v %v] [status-code=%v] [response=%v]", response.Request.Method, response.Request.URL, response.StatusCode(), response.String()) + } + + return nil +} diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 56b9807f7..e09f2275f 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -161,6 +161,14 @@ type Secret struct { PlainTextKey string `json:"plainTextKey"` } +type RawSecret struct { + SecretKey string `json:"secretKey,omitempty"` + SecretValue string `json:"secretValue,omitempty"` + Type string `json:"type,omitempty"` + SecretComment string `json:"secretComment,omitempty"` + ID string `json:"id,omitempty"` +} + type GetEncryptedWorkspaceKeyRequest struct { WorkspaceId string `json:"workspaceId"` } @@ -233,6 +241,7 @@ type GetLoginOneV2Response struct { type GetLoginTwoV2Request struct { Email string `json:"email"` ClientProof string `json:"clientProof"` + Password string `json:"password"` } type GetLoginTwoV2Response struct { @@ -409,12 +418,23 @@ type CreateSecretV3Request struct { SecretPath string `json:"secretPath"` } +type CreateRawSecretV3Request struct { + SecretName string `json:"-"` + WorkspaceID string `json:"workspaceId"` + Type string `json:"type,omitempty"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath,omitempty"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment,omitempty"` + SkipMultilineEncoding bool `json:"skipMultilineEncoding,omitempty"` +} + type DeleteSecretV3Request struct { SecretName string `json:"secretName"` WorkspaceId string `json:"workspaceId"` Environment string `json:"environment"` - Type string `json:"type"` - SecretPath string `json:"secretPath"` + Type string `json:"type,omitempty"` + SecretPath string `json:"secretPath,omitempty"` } type UpdateSecretByNameV3Request struct { @@ -427,6 +447,15 @@ type UpdateSecretByNameV3Request struct { SecretValueTag string `json:"secretValueTag"` } +type UpdateRawSecretByNameV3Request struct { + SecretName string `json:"-"` + WorkspaceID string `json:"workspaceId"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath,omitempty"` + SecretValue string `json:"secretValue"` + Type string `json:"type,omitempty"` +} + type GetSingleSecretByNameV3Request struct { SecretName string `json:"secretName"` WorkspaceId string `json:"workspaceId"` diff --git a/cli/packages/cmd/agent.go b/cli/packages/cmd/agent.go index 03bf9af4d..f485f76a5 100644 --- a/cli/packages/cmd/agent.go +++ b/cli/packages/cmd/agent.go @@ -15,12 +15,12 @@ import ( "path" "runtime" "slices" - "strings" "sync" "syscall" "text/template" "time" + infisicalSdk "github.com/infisical/go-sdk" "github.com/rs/zerolog/log" "gopkg.in/yaml.v2" @@ -60,9 +60,26 @@ type UniversalAuth struct { RemoveClientSecretOnRead bool `yaml:"remove_client_secret_on_read"` } -type OAuthConfig struct { - ClientID string `yaml:"client-id"` - ClientSecret string `yaml:"client-secret"` +type KubernetesAuth struct { + IdentityID string `yaml:"identity-id"` + ServiceAccountToken string `yaml:"service-account-token"` +} + +type AzureAuth struct { + IdentityID string `yaml:"identity-id"` +} + +type GcpIdTokenAuth struct { + IdentityID string `yaml:"identity-id"` +} + +type GcpIamAuth struct { + IdentityID string `yaml:"identity-id"` + ServiceAccountKey string `yaml:"service-account-key"` +} + +type AwsIamAuth struct { + IdentityID string `yaml:"identity-id"` } type Sink struct { @@ -88,15 +105,6 @@ type Template struct { } `yaml:"config"` } -func newAgentTemplateChannels(templates []Template) map[string]chan bool { - // we keep each destination as an identifier for various channel - templateChannel := make(map[string]chan bool) - for _, template := range templates { - templateChannel[template.DestinationPath] = make(chan bool) - } - return templateChannel -} - type DynamicSecretLease struct { LeaseID string ExpireAt time.Time @@ -257,17 +265,12 @@ func WriteBytesToFile(data *bytes.Buffer, outputPath string) error { return err } -func appendAPIEndpoint(address string) string { - // Ensure the address does not already end with "/api" - if strings.HasSuffix(address, "/api") { - return address +func ParseAuthConfig(authConfigFile []byte, destination interface{}) error { + if err := yaml.Unmarshal(authConfigFile, destination); err != nil { + return err } - // Check if the address ends with a slash and append accordingly - if address[len(address)-1] == '/' { - return address + "api" - } - return address + "/api" + return nil } func ParseAgentConfig(configFile []byte) (*Config, error) { @@ -290,43 +293,20 @@ func ParseAgentConfig(configFile []byte) (*Config, error) { rawConfig.Infisical.Address = DEFAULT_INFISICAL_CLOUD_URL } - config.INFISICAL_URL = appendAPIEndpoint(rawConfig.Infisical.Address) + config.INFISICAL_URL = util.AppendAPIEndpoint(rawConfig.Infisical.Address) log.Info().Msgf("Infisical instance address set to %s", rawConfig.Infisical.Address) config := &Config{ Infisical: rawConfig.Infisical, Auth: AuthConfig{ - Type: rawConfig.Auth.Type, + Type: rawConfig.Auth.Type, + Config: rawConfig.Auth.Config, }, Sinks: rawConfig.Sinks, Templates: rawConfig.Templates, } - // Marshal and then unmarshal the config based on the type - configBytes, err := yaml.Marshal(rawConfig.Auth.Config) - if err != nil { - return nil, err - } - - switch rawConfig.Auth.Type { - case "universal-auth": - var tokenConfig UniversalAuth - if err := yaml.Unmarshal(configBytes, &tokenConfig); err != nil { - return nil, err - } - - config.Auth.Config = tokenConfig - case "oauth": // aws, gcp, k8s service account, etc - var oauthConfig OAuthConfig - if err := yaml.Unmarshal(configBytes, &oauthConfig); err != nil { - return nil, err - } - config.Auth.Config = oauthConfig - default: - return nil, fmt.Errorf("unknown auth type: %s", rawConfig.Auth.Type) - } - return config, nil } @@ -351,7 +331,7 @@ func dynamicSecretTemplateFunction(accessToken string, dynamicSecretManager *Dyn return func(args ...string) (map[string]interface{}, error) { argLength := len(args) if argLength != 4 && argLength != 5 { - return nil, fmt.Errorf("Invalid arguments found for dynamic-secret function. Check template %i", templateId) + return nil, fmt.Errorf("invalid arguments found for dynamic-secret function. Check template %d", templateId) } projectSlug, envSlug, secretPath, slug, ttl := args[0], args[1], args[2], args[3], "" @@ -435,32 +415,54 @@ func ProcessBase64Template(templateId int, encodedTemplate string, data interfac } type AgentManager struct { - accessToken string - accessTokenTTL time.Duration - accessTokenMaxTTL time.Duration - accessTokenFetchedTime time.Time - accessTokenRefreshedTime time.Time - mutex sync.Mutex - filePaths []Sink // Store file paths if needed - templates []Template - dynamicSecretLeases *DynamicSecretLeaseManager - clientIdPath string - clientSecretPath string - newAccessTokenNotificationChan chan bool - removeClientSecretOnRead bool - cachedClientSecret string - exitAfterAuth bool + accessToken string + accessTokenTTL time.Duration + accessTokenMaxTTL time.Duration + accessTokenFetchedTime time.Time + accessTokenRefreshedTime time.Time + mutex sync.Mutex + filePaths []Sink // Store file paths if needed + templates []Template + dynamicSecretLeases *DynamicSecretLeaseManager + + authConfigBytes []byte + authStrategy util.AuthStrategyType + + newAccessTokenNotificationChan chan bool + removeUniversalAuthClientSecretOnRead bool + cachedUniversalAuthClientSecret string + exitAfterAuth bool + + infisicalClient infisicalSdk.InfisicalClientInterface } -func NewAgentManager(fileDeposits []Sink, templates []Template, clientIdPath string, clientSecretPath string, newAccessTokenNotificationChan chan bool, removeClientSecretOnRead bool, exitAfterAuth bool) *AgentManager { +type NewAgentMangerOptions struct { + FileDeposits []Sink + Templates []Template + + AuthConfigBytes []byte + AuthStrategy util.AuthStrategyType + + NewAccessTokenNotificationChan chan bool + ExitAfterAuth bool +} + +func NewAgentManager(options NewAgentMangerOptions) *AgentManager { + return &AgentManager{ - filePaths: fileDeposits, - templates: templates, - clientIdPath: clientIdPath, - clientSecretPath: clientSecretPath, - newAccessTokenNotificationChan: newAccessTokenNotificationChan, - removeClientSecretOnRead: removeClientSecretOnRead, - exitAfterAuth: exitAfterAuth, + filePaths: options.FileDeposits, + templates: options.Templates, + + authConfigBytes: options.AuthConfigBytes, + authStrategy: options.AuthStrategy, + + newAccessTokenNotificationChan: options.NewAccessTokenNotificationChan, + exitAfterAuth: options.ExitAfterAuth, + + infisicalClient: infisicalSdk.NewInfisicalClient(infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, // ? Should we perhaps use a different user agent for the Agent for better analytics? + }), } } @@ -483,52 +485,164 @@ func (tm *AgentManager) GetToken() string { return tm.accessToken } +func (tm *AgentManager) FetchUniversalAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, e error) { + + var universalAuthConfig UniversalAuth + if err := ParseAuthConfig(tm.authConfigBytes, &universalAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + clientID, err := util.GetEnvVarOrFileContent(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME, universalAuthConfig.ClientIDPath) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get client id: %v", err) + } + + clientSecret, err := util.GetEnvVarOrFileContent("INFISICAL_UNIVERSAL_CLIENT_SECRET", universalAuthConfig.ClientSecretPath) + if err != nil { + if len(tm.cachedUniversalAuthClientSecret) == 0 { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get client secret: %v", err) + } + clientSecret = tm.cachedUniversalAuthClientSecret + } + + tm.cachedUniversalAuthClientSecret = clientSecret + if tm.removeUniversalAuthClientSecretOnRead { + defer os.Remove(universalAuthConfig.ClientSecretPath) + } + + return tm.infisicalClient.Auth().UniversalAuthLogin(clientID, clientSecret) + +} + +func (tm *AgentManager) FetchKubernetesAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, err error) { + + var kubernetesAuthConfig KubernetesAuth + if err := ParseAuthConfig(tm.authConfigBytes, &kubernetesAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + identityId, err := util.GetEnvVarOrFileContent(util.INFISICAL_MACHINE_IDENTITY_ID_NAME, kubernetesAuthConfig.IdentityID) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get identity id: %v", err) + } + + serviceAccountTokenPath := os.Getenv(util.INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME) + if serviceAccountTokenPath == "" { + serviceAccountTokenPath = kubernetesAuthConfig.ServiceAccountToken + if serviceAccountTokenPath == "" { + serviceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + } + } + + return tm.infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) + +} + +func (tm *AgentManager) FetchAzureAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, err error) { + + var azureAuthConfig AzureAuth + if err := ParseAuthConfig(tm.authConfigBytes, &azureAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + identityId, err := util.GetEnvVarOrFileContent(util.INFISICAL_MACHINE_IDENTITY_ID_NAME, azureAuthConfig.IdentityID) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get identity id: %v", err) + } + + return tm.infisicalClient.Auth().AzureAuthLogin(identityId) + +} + +func (tm *AgentManager) FetchGcpIdTokenAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, err error) { + + var gcpIdTokenAuthConfig GcpIdTokenAuth + if err := ParseAuthConfig(tm.authConfigBytes, &gcpIdTokenAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + identityId, err := util.GetEnvVarOrFileContent(util.INFISICAL_MACHINE_IDENTITY_ID_NAME, gcpIdTokenAuthConfig.IdentityID) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get identity id: %v", err) + } + + return tm.infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) + +} + +func (tm *AgentManager) FetchGcpIamAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, err error) { + + var gcpIamAuthConfig GcpIamAuth + if err := ParseAuthConfig(tm.authConfigBytes, &gcpIamAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + identityId, err := util.GetEnvVarOrFileContent(util.INFISICAL_MACHINE_IDENTITY_ID_NAME, gcpIamAuthConfig.IdentityID) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get identity id: %v", err) + } + + serviceAccountKeyPath := os.Getenv(util.INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME) + if serviceAccountKeyPath == "" { + // we don't need to read this file, because the service account key path is directly read inside the sdk + serviceAccountKeyPath = gcpIamAuthConfig.ServiceAccountKey + if serviceAccountKeyPath == "" { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("gcp service account key path not found") + } + } + + return tm.infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyPath) + +} + +func (tm *AgentManager) FetchAwsIamAuthAccessToken() (credential infisicalSdk.MachineIdentityCredential, err error) { + + var awsIamAuthConfig AwsIamAuth + if err := ParseAuthConfig(tm.authConfigBytes, &awsIamAuthConfig); err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to parse auth config due to error: %v", err) + } + + identityId, err := util.GetEnvVarOrFileContent(util.INFISICAL_MACHINE_IDENTITY_ID_NAME, awsIamAuthConfig.IdentityID) + + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, fmt.Errorf("unable to get identity id: %v", err) + } + + return tm.infisicalClient.Auth().AwsIamAuthLogin(identityId) + +} + // Fetches a new access token using client credentials func (tm *AgentManager) FetchNewAccessToken() error { - clientID := os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) - if clientID == "" { - clientIDAsByte, err := ReadFile(tm.clientIdPath) - if err != nil { - return fmt.Errorf("unable to read client id from file path '%s' due to error: %v", tm.clientIdPath, err) - } - clientID = string(clientIDAsByte) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: tm.FetchUniversalAuthAccessToken, + util.AuthStrategy.KUBERNETES_AUTH: tm.FetchKubernetesAuthAccessToken, + util.AuthStrategy.AZURE_AUTH: tm.FetchAzureAuthAccessToken, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: tm.FetchGcpIdTokenAuthAccessToken, + util.AuthStrategy.GCP_IAM_AUTH: tm.FetchGcpIamAuthAccessToken, + util.AuthStrategy.AWS_IAM_AUTH: tm.FetchAwsIamAuthAccessToken, } - clientSecret := os.Getenv("INFISICAL_UNIVERSAL_CLIENT_SECRET") - if clientSecret == "" { - clientSecretAsByte, err := ReadFile(tm.clientSecretPath) - if err != nil { - if len(tm.cachedClientSecret) == 0 { - return fmt.Errorf("unable to read client secret from file and no cached client secret found: %v", err) - } else { - clientSecretAsByte = []byte(tm.cachedClientSecret) - } - } - clientSecret = string(clientSecretAsByte) + if _, ok := authStrategies[tm.authStrategy]; !ok { + return fmt.Errorf("auth strategy %s not found", tm.authStrategy) } - // remove client secret after first read - if tm.removeClientSecretOnRead { - os.Remove(tm.clientSecretPath) - } + credential, err := authStrategies[tm.authStrategy]() - // save as cache in memory - tm.cachedClientSecret = clientSecret - - loginResponse, err := util.UniversalAuthLogin(clientID, clientSecret) if err != nil { return err } - accessTokenTTL := time.Duration(loginResponse.AccessTokenTTL * int(time.Second)) - accessTokenMaxTTL := time.Duration(loginResponse.AccessTokenMaxTTL * int(time.Second)) + accessTokenTTL := time.Duration(credential.ExpiresIn * int64(time.Second)) + accessTokenMaxTTL := time.Duration(credential.AccessTokenMaxTTL * int64(time.Second)) if accessTokenTTL <= time.Duration(5)*time.Second { - util.PrintErrorMessageAndExit("At this this, agent does not support refresh of tokens with 5 seconds or less ttl. Please increase access token ttl and try again") + util.PrintErrorMessageAndExit("At this time, agent does not support refresh of tokens with 5 seconds or less ttl. Please increase access token ttl and try again") } tm.accessTokenFetchedTime = time.Now() - tm.SetToken(loginResponse.AccessToken, accessTokenTTL, accessTokenMaxTTL) + tm.SetToken(credential.AccessToken, accessTokenTTL, accessTokenMaxTTL) return nil } @@ -541,7 +655,7 @@ func (tm *AgentManager) RefreshAccessToken() error { SetRetryWaitTime(5 * time.Second) accessToken := tm.GetToken() - response, err := api.CallUniversalAuthRefreshAccessToken(httpClient, api.UniversalAuthRefreshRequest{AccessToken: accessToken}) + response, err := api.CallMachineIdentityRefreshAccessToken(httpClient, api.UniversalAuthRefreshRequest{AccessToken: accessToken}) if err != nil { return err } @@ -578,6 +692,7 @@ func (tm *AgentManager) ManageTokenLifecycle() { continue } } else if time.Now().After(accessTokenMaxTTLExpiresInTime) { + // case: token has reached max ttl and we should re-authenticate entirely (cannot refresh) log.Info().Msgf("token has reached max ttl, attempting to re authenticate...") err := tm.FetchNewAccessToken() if err != nil { @@ -588,6 +703,7 @@ func (tm *AgentManager) ManageTokenLifecycle() { continue } } else { + // case: token ttl has expired, but the token is still within max ttl, so we can refresh log.Info().Msgf("attempting to refresh existing token...") err := tm.RefreshAccessToken() if err != nil { @@ -784,18 +900,33 @@ var agentCmd = &cobra.Command{ return } - if agentConfig.Auth.Type != "universal-auth" { - util.PrintErrorMessageAndExit("Only auth type of 'universal-auth' is supported at this time") - } + authMethodValid, authStrategy := util.IsAuthMethodValid(agentConfig.Auth.Type, false) - configUniversalAuthType := agentConfig.Auth.Config.(UniversalAuth) + if !authMethodValid { + util.PrintErrorMessageAndExit(fmt.Sprintf("The auth method '%s' is not supported.", agentConfig.Auth.Type)) + } tokenRefreshNotifier := make(chan bool) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) filePaths := agentConfig.Sinks - tm := NewAgentManager(filePaths, agentConfig.Templates, configUniversalAuthType.ClientIDPath, configUniversalAuthType.ClientSecretPath, tokenRefreshNotifier, configUniversalAuthType.RemoveClientSecretOnRead, agentConfig.Infisical.ExitAfterAuth) + + configBytes, err := yaml.Marshal(agentConfig.Auth.Config) + if err != nil { + log.Error().Msgf("unable to marshal auth config because %v", err) + return + } + + tm := NewAgentManager(NewAgentMangerOptions{ + FileDeposits: filePaths, + Templates: agentConfig.Templates, + AuthConfigBytes: configBytes, + NewAccessTokenNotificationChan: tokenRefreshNotifier, + ExitAfterAuth: agentConfig.Infisical.ExitAfterAuth, + AuthStrategy: authStrategy, + }) + tm.dynamicSecretLeases = NewDynamicSecretLeaseManager(sigChan) go tm.ManageTokenLifecycle() diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 983c19255..c0fea738e 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -55,6 +55,11 @@ var exportCmd = &cobra.Command{ util.HandleError(err) } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + format, err := cmd.Flags().GetString("format") if err != nil { util.HandleError(err) @@ -70,11 +75,6 @@ var exportCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - token, err := util.GetInfisicalToken(cmd) - if err != nil { - util.HandleError(err, "Unable to parse flag") - } - tagSlugs, err := cmd.Flags().GetString("tags") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -169,9 +169,9 @@ func init() { exportCmd.Flags().StringP("format", "f", "dotenv", "Set the format of the output file (dotenv, json, csv)") exportCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") exportCmd.Flags().Bool("include-imports", true, "Imported linked secrets") - exportCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") + exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") exportCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs") - exportCmd.Flags().String("projectId", "", "manually set the projectId to fetch secrets from") + exportCmd.Flags().String("projectId", "", "manually set the projectId to export secrets from") exportCmd.Flags().String("path", "/", "get secrets within a folder path") exportCmd.Flags().String("template", "", "The path to the template file used to render secrets") } diff --git a/cli/packages/cmd/folder.go b/cli/packages/cmd/folder.go index 9cb76a312..538f1e2dc 100644 --- a/cli/packages/cmd/folder.go +++ b/cli/packages/cmd/folder.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "github.com/Infisical/infisical-merge/packages/models" @@ -71,10 +72,6 @@ var getCmd = &cobra.Command{ var createCmd = &cobra.Command{ Use: "create", Short: "Create a folder", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - }, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") if !cmd.Flags().Changed("env") { @@ -84,6 +81,16 @@ var createCmd = &cobra.Command{ } } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + folderPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -95,19 +102,31 @@ var createCmd = &cobra.Command{ } if folderName == "" { - util.HandleError(fmt.Errorf("Invalid folder name"), "Folder name cannot be empty") + util.HandleError(errors.New("invalid folder name, folder name cannot be empty")) } - workspaceFile, err := util.GetWorkSpaceFromFile() if err != nil { util.HandleError(err, "Unable to get workspace file") } + if projectId == "" { + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.HandleError(err, "Unable to get workspace file") + } + + projectId = workspaceFile.WorkspaceId + } + params := models.CreateFolderParameters{ FolderName: folderName, - WorkspaceId: workspaceFile.WorkspaceId, Environment: environmentName, FolderPath: folderPath, + WorkspaceId: projectId, + } + + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + params.InfisicalToken = token.Token } _, err = util.CreateFolder(params) @@ -124,10 +143,6 @@ var createCmd = &cobra.Command{ var deleteCmd = &cobra.Command{ Use: "delete", Short: "Delete a folder", - PersistentPreRun: func(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - }, Run: func(cmd *cobra.Command, args []string) { environmentName, _ := cmd.Flags().GetString("env") @@ -138,6 +153,16 @@ var deleteCmd = &cobra.Command{ } } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + folderPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -149,21 +174,29 @@ var deleteCmd = &cobra.Command{ } if folderName == "" { - util.HandleError(fmt.Errorf("Invalid folder name"), "Folder name cannot be empty") + util.HandleError(errors.New("invalid folder name, folder name cannot be empty")) } - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.HandleError(err, "Unable to get workspace file") + if projectId == "" { + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.HandleError(err, "Unable to get workspace file") + } + + projectId = workspaceFile.WorkspaceId } params := models.DeleteFolderParameters{ FolderName: folderName, - WorkspaceId: workspaceFile.WorkspaceId, + WorkspaceId: projectId, Environment: environmentName, FolderPath: folderPath, } + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + params.InfisicalToken = token.Token + } + _, err = util.DeleteFolder(params) if err != nil { util.HandleError(err, "Unable to delete folder") diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index bbb2c3a05..0efe7af9e 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -34,6 +34,8 @@ import ( "github.com/spf13/cobra" "golang.org/x/crypto/argon2" "golang.org/x/term" + + infisicalSdk "github.com/infisical/go-sdk" ) type params struct { @@ -44,6 +46,86 @@ type params struct { keyLength uint32 } +func handleUniversalAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + clientId, err := util.GetCmdFlagOrEnv(cmd, "client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) + + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + clientSecret, err := util.GetCmdFlagOrEnv(cmd, "client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) +} + +func handleKubernetesAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountTokenPath, err := util.GetCmdFlagOrEnv(cmd, "service-account-token-path", util.INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) +} + +func handleAzureAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().AzureAuthLogin(identityId) +} + +func handleGcpIdTokenAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) +} + +func handleGcpIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountKeyFilePath, err := util.GetCmdFlagOrEnv(cmd, "service-account-key-file-path", util.INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) +} + +func handleAwsIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return infisicalClient.Auth().AwsIamAuthLogin(identityId) +} + +func formatAuthMethod(authMethod string) string { + return strings.ReplaceAll(authMethod, "-", " ") +} + const ADD_USER = "Add a new account login" const REPLACE_USER = "Override current logged in user" const EXIT_USER_MENU = "Exit" @@ -56,6 +138,11 @@ var loginCmd = &cobra.Command{ DisableFlagsInUseLine: true, Run: func(cmd *cobra.Command, args []string) { + infisicalClient := infisicalSdk.NewInfisicalClient(infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + }) + loginMethod, err := cmd.Flags().GetString("method") if err != nil { util.HandleError(err) @@ -65,12 +152,13 @@ var loginCmd = &cobra.Command{ util.HandleError(err) } - if loginMethod != "user" && loginMethod != "universal-auth" { - util.PrintErrorMessageAndExit("Invalid login method. Please use either 'user' or 'universal-auth'") + authMethodValid, strategy := util.IsAuthMethodValid(loginMethod, true) + if !authMethodValid { + util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid login method: %s", loginMethod)) } + // standalone user auth if loginMethod == "user" { - currentLoggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() // if the key can't be found or there is an error getting current credentials from key ring, allow them to override if err != nil && (strings.Contains(err.Error(), "we couldn't find your logged in details")) { @@ -101,7 +189,7 @@ var loginCmd = &cobra.Command{ //set domainQuery to false if !overrideDomain { domainQuery = false - config.INFISICAL_URL = config.INFISICAL_URL_MANUAL_OVERRIDE + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL_MANUAL_OVERRIDE) } } @@ -133,7 +221,7 @@ var loginCmd = &cobra.Command{ err = util.StoreUserCredsInKeyRing(&userCredentialsToBeStored) if err != nil { - log.Error().Msgf("Unable to store your credentials in system vault [%s]") + log.Error().Msgf("Unable to store your credentials in system vault") log.Error().Msgf("\nTo trouble shoot further, read https://infisical.com/docs/cli/faq") log.Debug().Err(err) //return here @@ -160,47 +248,33 @@ var loginCmd = &cobra.Command{ fmt.Println("- Learn to inject secrets into your application at https://infisical.com/docs/cli/usage") fmt.Println("- Stuck? Join our slack for quick support https://infisical.com/slack") Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) - } else if loginMethod == "universal-auth" { + } else { - clientId, err := cmd.Flags().GetString("client-id") - if err != nil { - util.HandleError(err) + authStrategies := map[util.AuthStrategyType]func(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: handleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: handleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: handleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: handleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: handleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: handleAwsIamAuthLogin, } - clientSecret, err := cmd.Flags().GetString("client-secret") - if err != nil { - util.HandleError(err) - } - - if clientId == "" { - clientId = os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) - if clientId == "" { - util.PrintErrorMessageAndExit("Please provide client-id") - } - } - if clientSecret == "" { - clientSecret = os.Getenv(util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) - if clientSecret == "" { - util.PrintErrorMessageAndExit("Please provide client-secret") - } - } - - res, err := util.UniversalAuthLogin(clientId, clientSecret) + credential, err := authStrategies[strategy](cmd, infisicalClient) if err != nil { - util.HandleError(err) + util.HandleError(fmt.Errorf("unable to authenticate with %s [err=%v]", formatAuthMethod(loginMethod), err)) } if plainOutput { - fmt.Println(res.AccessToken) + fmt.Println(credential.AccessToken) return } boldGreen := color.New(color.FgGreen).Add(color.Bold) boldPlain := color.New(color.Bold) time.Sleep(time.Second * 1) - boldGreen.Printf(">>>> Successfully authenticated with Universal Auth!\n\n") - boldPlain.Printf("Universal Auth Access Token:\n%v", res.AccessToken) + boldGreen.Printf(">>>> Successfully authenticated with %s!\n\n", formatAuthMethod(loginMethod)) + boldPlain.Printf("Access Token:\n%v", credential.AccessToken) plainBold := color.New(color.Bold) plainBold.Println("\n\nYou can use this access token to authenticate through other commands in the CLI.") @@ -376,9 +450,12 @@ func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") - loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") + loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().String("client-secret", "", "client secret for universal auth") + loginCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") + loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") + loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") } func DomainOverridePrompt() (bool, error) { @@ -539,6 +616,7 @@ func getFreshUserCredentials(email string, password string) (*api.GetLoginOneV2R loginTwoResponseResult, err := api.CallLogin2V2(httpClient, api.GetLoginTwoV2Request{ Email: email, ClientProof: hex.EncodeToString(srpM1), + Password: password, }) if err != nil { diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 06846260f..482c6f78a 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -43,6 +43,7 @@ func init() { rootCmd.PersistentFlags().Bool("silent", false, "Disable output of tip/info messages. Useful when running in scripts or CI/CD pipelines.") rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { silent, err := cmd.Flags().GetBool("silent") + config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) if err != nil { util.HandleError(err) } diff --git a/cli/packages/cmd/run.go b/cli/packages/cmd/run.go index 04fe2588b..22a4ca65b 100644 --- a/cli/packages/cmd/run.go +++ b/cli/packages/cmd/run.go @@ -237,8 +237,8 @@ func filterReservedEnvVars(env map[string]models.SingleEnvironmentVariable) { func init() { rootCmd.AddCommand(runCmd) - runCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") - runCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") + runCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + runCmd.Flags().String("projectId", "", "manually set the project ID to fetch secrets from when using machine identity based auth") runCmd.Flags().StringP("env", "e", "dev", "Set the environment (dev, prod, etc.) from which your secrets should be pulled from") runCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets") runCmd.Flags().Bool("include-imports", true, "Import linked secrets ") diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index c3cec597a..a56e002dc 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -4,23 +4,17 @@ Copyright (c) 2023 Infisical Inc. package cmd import ( - "crypto/sha256" - "encoding/base64" "fmt" - "os" "regexp" "sort" "strings" - "unicode" "github.com/Infisical/infisical-merge/packages/api" - "github.com/Infisical/infisical-merge/packages/crypto" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/Infisical/infisical-merge/packages/visualize" "github.com/go-resty/resty/v2" "github.com/posthog/posthog-go" - "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -171,187 +165,38 @@ var secretsSetCmd = &cobra.Command{ } } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") } - workspaceFile, err := util.GetWorkSpaceFromFile() + secretType, err := cmd.Flags().GetString("type") + if err != nil || (secretType != util.SECRET_TYPE_SHARED && secretType != util.SECRET_TYPE_PERSONAL) { + util.HandleError(err, "Unable to parse secret type") + } + + var secretOperations []models.SecretSetOperation + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + secretOperations, err = util.SetRawSecrets(args, secretType, environmentName, secretsPath, projectId, token) + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() + + secretOperations, err = util.SetEncryptedSecrets(args, secretType, environmentName, secretsPath) + } + if err != nil { - util.HandleError(err, "Unable to get your local config details") - } - - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() - if err != nil { - util.HandleError(err, "Unable to authenticate") - } - - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") - } - - httpClient := resty.New(). - SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). - SetHeader("Accept", "application/json") - - request := api.GetEncryptedWorkspaceKeyRequest{ - WorkspaceId: workspaceFile.WorkspaceId, - } - - workspaceKeyResponse, err := api.CallGetEncryptedWorkspaceKey(httpClient, request) - if err != nil { - util.HandleError(err, "unable to get your encrypted workspace key") - } - - encryptedWorkspaceKey, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.EncryptedKey) - encryptedWorkspaceKeySenderPublicKey, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.Sender.PublicKey) - encryptedWorkspaceKeyNonce, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.Nonce) - currentUsersPrivateKey, _ := base64.StdEncoding.DecodeString(loggedInUserDetails.UserCredentials.PrivateKey) - - if len(currentUsersPrivateKey) == 0 || len(encryptedWorkspaceKeySenderPublicKey) == 0 { - log.Debug().Msgf("Missing credentials for generating plainTextEncryptionKey: [currentUsersPrivateKey=%s] [encryptedWorkspaceKeySenderPublicKey=%s]", currentUsersPrivateKey, encryptedWorkspaceKeySenderPublicKey) - util.PrintErrorMessageAndExit("Some required user credentials are missing to generate your [plainTextEncryptionKey]. Please run [infisical login] then try again") - } - - // decrypt workspace key - plainTextEncryptionKey := crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey) - - infisicalTokenEnv := os.Getenv(util.INFISICAL_TOKEN_NAME) - - // pull current secrets - secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, SecretsPath: secretsPath, InfisicalToken: infisicalTokenEnv}, "") - if err != nil { - util.HandleError(err, "unable to retrieve secrets") - } - - type SecretSetOperation struct { - SecretKey string - SecretValue string - SecretOperation string - } - - secretsToCreate := []api.Secret{} - secretsToModify := []api.Secret{} - secretOperations := []SecretSetOperation{} - - secretByKey := getSecretsByKeys(secrets) - - for _, arg := range args { - splitKeyValueFromArg := strings.SplitN(arg, "=", 2) - if splitKeyValueFromArg[0] == "" || splitKeyValueFromArg[1] == "" { - util.PrintErrorMessageAndExit("ensure that each secret has a none empty key and value. Modify the input and try again") - } - - if unicode.IsNumber(rune(splitKeyValueFromArg[0][0])) { - util.PrintErrorMessageAndExit("keys of secrets cannot start with a number. Modify the key name(s) and try again") - } - - // Key and value from argument - key := splitKeyValueFromArg[0] - value := splitKeyValueFromArg[1] - - hashedKey := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) - encryptedKey, err := crypto.EncryptSymmetric([]byte(key), []byte(plainTextEncryptionKey)) - if err != nil { - util.HandleError(err, "unable to encrypt your secrets") - } - - hashedValue := fmt.Sprintf("%x", sha256.Sum256([]byte(value))) - encryptedValue, err := crypto.EncryptSymmetric([]byte(value), []byte(plainTextEncryptionKey)) - if err != nil { - util.HandleError(err, "unable to encrypt your secrets") - } - - if existingSecret, ok := secretByKey[key]; ok { - // case: secret exists in project so it needs to be modified - encryptedSecretDetails := api.Secret{ - ID: existingSecret.ID, - SecretValueCiphertext: base64.StdEncoding.EncodeToString(encryptedValue.CipherText), - SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), - SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), - SecretValueHash: hashedValue, - PlainTextKey: key, - Type: existingSecret.Type, - } - - // Only add to modifications if the value is different - if existingSecret.Value != value { - secretsToModify = append(secretsToModify, encryptedSecretDetails) - secretOperations = append(secretOperations, SecretSetOperation{ - SecretKey: key, - SecretValue: value, - SecretOperation: "SECRET VALUE MODIFIED", - }) - } else { - // Current value is same as exisitng so no change - secretOperations = append(secretOperations, SecretSetOperation{ - SecretKey: key, - SecretValue: value, - SecretOperation: "SECRET VALUE UNCHANGED", - }) - } - - } else { - // case: secret doesn't exist in project so it needs to be created - encryptedSecretDetails := api.Secret{ - SecretKeyCiphertext: base64.StdEncoding.EncodeToString(encryptedKey.CipherText), - SecretKeyIV: base64.StdEncoding.EncodeToString(encryptedKey.Nonce), - SecretKeyTag: base64.StdEncoding.EncodeToString(encryptedKey.AuthTag), - SecretKeyHash: hashedKey, - SecretValueCiphertext: base64.StdEncoding.EncodeToString(encryptedValue.CipherText), - SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), - SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), - SecretValueHash: hashedValue, - Type: util.SECRET_TYPE_SHARED, - PlainTextKey: key, - } - secretsToCreate = append(secretsToCreate, encryptedSecretDetails) - secretOperations = append(secretOperations, SecretSetOperation{ - SecretKey: key, - SecretValue: value, - SecretOperation: "SECRET CREATED", - }) - } - } - - for _, secret := range secretsToCreate { - createSecretRequest := api.CreateSecretV3Request{ - WorkspaceID: workspaceFile.WorkspaceId, - Environment: environmentName, - SecretName: secret.PlainTextKey, - SecretKeyCiphertext: secret.SecretKeyCiphertext, - SecretKeyIV: secret.SecretKeyIV, - SecretKeyTag: secret.SecretKeyTag, - SecretValueCiphertext: secret.SecretValueCiphertext, - SecretValueIV: secret.SecretValueIV, - SecretValueTag: secret.SecretValueTag, - Type: secret.Type, - SecretPath: secretsPath, - } - - err = api.CallCreateSecretsV3(httpClient, createSecretRequest) - if err != nil { - util.HandleError(err, "Unable to process new secret creations") - return - } - } - - for _, secret := range secretsToModify { - updateSecretRequest := api.UpdateSecretByNameV3Request{ - WorkspaceID: workspaceFile.WorkspaceId, - Environment: environmentName, - SecretValueCiphertext: secret.SecretValueCiphertext, - SecretValueIV: secret.SecretValueIV, - SecretValueTag: secret.SecretValueTag, - Type: secret.Type, - SecretPath: secretsPath, - } - - err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest, secret.PlainTextKey) - if err != nil { - util.HandleError(err, "Unable to process secret update request") - return - } + util.HandleError(err, "Unable to set secrets") } // Print secret operations @@ -382,6 +227,16 @@ var secretsDeleteCmd = &cobra.Command{ } } + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + + projectId, err := cmd.Flags().GetString("projectId") + if err != nil { + util.HandleError(err, "Unable to parse flag") + } + secretsPath, err := cmd.Flags().GetString("path") if err != nil { util.HandleError(err, "Unable to parse flag") @@ -392,33 +247,44 @@ var secretsDeleteCmd = &cobra.Command{ util.HandleError(err, "Unable to parse flag") } - loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() - if err != nil { - util.HandleError(err, "Unable to authenticate") + httpClient := resty.New(). + SetHeader("Accept", "application/json") + + if projectId == "" { + workspaceFile, err := util.GetWorkSpaceFromFile() + if err != nil { + util.HandleError(err, "Unable to get local project details") + } + projectId = workspaceFile.WorkspaceId } - if loggedInUserDetails.LoginExpired { - util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") - } + if token != nil && (token.Type == util.SERVICE_TOKEN_IDENTIFIER || token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER) { + httpClient.SetAuthToken(token.Token) + } else { + util.RequireLogin() + util.RequireLocalWorkspaceFile() - workspaceFile, err := util.GetWorkSpaceFromFile() - if err != nil { - util.HandleError(err, "Unable to get local project details") + loggedInUserDetails, err := util.GetCurrentLoggedInUserDetails() + if err != nil { + util.HandleError(err, "Unable to authenticate") + } + + if loggedInUserDetails.LoginExpired { + util.PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + httpClient.SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken) } for _, secretName := range args { request := api.DeleteSecretV3Request{ - WorkspaceId: workspaceFile.WorkspaceId, + WorkspaceId: projectId, Environment: environmentName, SecretName: secretName, Type: secretType, SecretPath: secretsPath, } - httpClient := resty.New(). - SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). - SetHeader("Accept", "application/json") - err = api.CallDeleteSecretsV3(httpClient, request) if err != nil { util.HandleError(err, "Unable to complete your delete request") @@ -787,13 +653,13 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod } func init() { - secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") - secretsGenerateExampleEnvCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") + secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsGenerateExampleEnvCmd.Flags().String("projectId", "", "manually set the projectId when using machine identity based auth") secretsGenerateExampleEnvCmd.Flags().String("path", "/", "Fetch secrets from within a folder path") secretsCmd.AddCommand(secretsGenerateExampleEnvCmd) - secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") - secretsGetCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") + secretsGetCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsGetCmd.Flags().String("projectId", "", "manually set the project ID to fetch secrets from when using machine identity based auth") secretsGetCmd.Flags().String("path", "/", "get secrets within a folder path") secretsGetCmd.Flags().Bool("plain", false, "print values without formatting, one per line") secretsGetCmd.Flags().Bool("raw-value", false, "deprecated. Returns only the value of secret, only works with one secret. Use --plain instead") @@ -803,40 +669,37 @@ func init() { secretsCmd.AddCommand(secretsGetCmd) secretsCmd.Flags().Bool("secret-overriding", true, "Prioritizes personal secrets, if any, with the same name over shared secrets") secretsCmd.AddCommand(secretsSetCmd) + secretsSetCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsSetCmd.Flags().String("projectId", "", "manually set the project ID to for setting secrets when using machine identity based auth") secretsSetCmd.Flags().String("path", "/", "set secrets within a folder path") - - // Only supports logged in users (JWT auth) - secretsSetCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - } + secretsSetCmd.Flags().String("type", util.SECRET_TYPE_SHARED, "the type of secret to create: personal or shared") secretsDeleteCmd.Flags().String("type", "personal", "the type of secret to delete: personal or shared (default: personal)") + secretsDeleteCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsDeleteCmd.Flags().String("projectId", "", "manually set the projectId to delete secrets from when using machine identity based auth") secretsDeleteCmd.Flags().String("path", "/", "get secrets within a folder path") secretsCmd.AddCommand(secretsDeleteCmd) - // Only supports logged in users (JWT auth) - secretsDeleteCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { - util.RequireLogin() - util.RequireLocalWorkspaceFile() - } - // *** Folders sub command *** folderCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") // Add getCmd, createCmd and deleteCmd flags here getCmd.Flags().StringP("path", "p", "/", "The path from where folders should be fetched from") - getCmd.Flags().String("token", "", "Fetch folders using the infisical token") - getCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") + getCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + getCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from when using machine identity based auth") folderCmd.AddCommand(getCmd) // Add createCmd flags here createCmd.Flags().StringP("path", "p", "/", "Path to where the folder should be created") createCmd.Flags().StringP("name", "n", "", "Name of the folder to be created in selected `--path`") + createCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + createCmd.Flags().String("projectId", "", "manually set the project ID for creating folders in when using machine identity based auth") folderCmd.AddCommand(createCmd) // Add deleteCmd flags here deleteCmd.Flags().StringP("path", "p", "/", "Path to the folder to be deleted") + deleteCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + deleteCmd.Flags().String("projectId", "", "manually set the projectId to delete folders when using machine identity based auth") deleteCmd.Flags().StringP("name", "n", "", "Name of the folder to be deleted within selected `--path`") folderCmd.AddCommand(deleteCmd) @@ -844,8 +707,8 @@ func init() { // ** End of folders sub command - secretsCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token") - secretsCmd.Flags().String("projectId", "", "manually set the projectId to fetch folders from for machine identity") + secretsCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + secretsCmd.Flags().String("projectId", "", "manually set the projectId to fetch secrets when using machine identity based auth") secretsCmd.PersistentFlags().String("env", "dev", "Used to select the environment name on which actions should be taken on") secretsCmd.Flags().Bool("expand", true, "Parse shell parameter expansions in your secrets, and process your referenced secrets") secretsCmd.Flags().Bool("include-imports", true, "Imported linked secrets ") diff --git a/cli/packages/cmd/token.go b/cli/packages/cmd/token.go index 3e5d42765..4e568cb85 100644 --- a/cli/packages/cmd/token.go +++ b/cli/packages/cmd/token.go @@ -39,7 +39,7 @@ var tokenRenewCmd = &cobra.Command{ util.PrintErrorMessageAndExit("You are trying to renew a service token. You can only renew universal auth access tokens.") } - renewedAccessToken, err := util.RenewUniversalAuthAccessToken(token) + renewedAccessToken, err := util.RenewMachineIdentityAccessToken(token) if err != nil { util.HandleError(err, "Unable to renew token") diff --git a/cli/packages/cmd/user.go b/cli/packages/cmd/user.go index 96b03a0bb..844213e18 100644 --- a/cli/packages/cmd/user.go +++ b/cli/packages/cmd/user.go @@ -237,7 +237,7 @@ func NewDomainPrompt() (string, error) { return "", err } - return domain, nil + return util.AppendAPIEndpoint(domain), nil } func LoggedInUsersPrompt(profiles []string) (string, error) { diff --git a/cli/packages/models/cli.go b/cli/packages/models/cli.go index 68527c469..6f996d667 100644 --- a/cli/packages/models/cli.go +++ b/cli/packages/models/cli.go @@ -134,3 +134,9 @@ type MachineIdentityCredentials struct { ClientId string ClientSecret string } + +type SecretSetOperation struct { + SecretKey string + SecretValue string + SecretOperation string +} diff --git a/cli/packages/util/auth.go b/cli/packages/util/auth.go new file mode 100644 index 000000000..d27bbc2c8 --- /dev/null +++ b/cli/packages/util/auth.go @@ -0,0 +1,42 @@ +package util + +type AuthStrategyType string + +var AuthStrategy = struct { + UNIVERSAL_AUTH AuthStrategyType + KUBERNETES_AUTH AuthStrategyType + AZURE_AUTH AuthStrategyType + GCP_ID_TOKEN_AUTH AuthStrategyType + GCP_IAM_AUTH AuthStrategyType + AWS_IAM_AUTH AuthStrategyType +}{ + UNIVERSAL_AUTH: "universal-auth", + KUBERNETES_AUTH: "kubernetes", + AZURE_AUTH: "azure", + GCP_ID_TOKEN_AUTH: "gcp-id-token", + GCP_IAM_AUTH: "gcp-iam", + AWS_IAM_AUTH: "aws-iam", +} + +var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ + AuthStrategy.UNIVERSAL_AUTH, + AuthStrategy.KUBERNETES_AUTH, + AuthStrategy.AZURE_AUTH, + AuthStrategy.GCP_ID_TOKEN_AUTH, + AuthStrategy.GCP_IAM_AUTH, + AuthStrategy.AWS_IAM_AUTH, +} + +func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, strategy AuthStrategyType) { + + if authMethod == "user" && allowUserAuth { + return true, "" + } + + for _, strategy := range AVAILABLE_AUTH_STRATEGIES { + if string(strategy) == authMethod { + return true, strategy + } + } + return false, "" +} 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/constants.go b/cli/packages/util/constants.go index 311a4b0d9..bff3c3ab0 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -1,20 +1,32 @@ package util const ( - CONFIG_FILE_NAME = "infisical-config.json" - CONFIG_FOLDER_NAME = ".infisical" - INFISICAL_DEFAULT_API_URL = "https://app.infisical.com/api" - INFISICAL_DEFAULT_URL = "https://app.infisical.com" - INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json" - INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN" + CONFIG_FILE_NAME = "infisical-config.json" + CONFIG_FOLDER_NAME = ".infisical" + INFISICAL_DEFAULT_API_URL = "https://app.infisical.com/api" + INFISICAL_DEFAULT_URL = "https://app.infisical.com" + INFISICAL_WORKSPACE_CONFIG_FILE_NAME = ".infisical.json" + INFISICAL_TOKEN_NAME = "INFISICAL_TOKEN" + INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" + + // Universal Auth INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_ID" INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME = "INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET" - INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME = "INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN" - SECRET_TYPE_PERSONAL = "personal" - SECRET_TYPE_SHARED = "shared" - KEYRING_SERVICE_NAME = "infisical" - PERSONAL_SECRET_TYPE_NAME = "personal" - SHARED_SECRET_TYPE_NAME = "shared" + + // Kubernetes auth + INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME = "INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH" + + // GCP Auth + INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME = "INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH" + + // Generic env variable used for auth methods that require a machine identity ID + INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID" + + SECRET_TYPE_PERSONAL = "personal" + SECRET_TYPE_SHARED = "shared" + KEYRING_SERVICE_NAME = "infisical" + PERSONAL_SECRET_TYPE_NAME = "personal" + SHARED_SECRET_TYPE_NAME = "shared" SERVICE_TOKEN_IDENTIFIER = "service-token" UNIVERSAL_AUTH_TOKEN_IDENTIFIER = "universal-auth-token" diff --git a/cli/packages/util/credentials.go b/cli/packages/util/credentials.go index af63aa917..4856de35a 100644 --- a/cli/packages/util/credentials.go +++ b/cli/packages/util/credentials.go @@ -88,7 +88,7 @@ func GetCurrentLoggedInUserDetails() (LoggedInUserDetails, error) { //configFile.LoggedInUserDomain //if not empty set as infisical url if configFile.LoggedInUserDomain != "" { - config.INFISICAL_URL = configFile.LoggedInUserDomain + config.INFISICAL_URL = AppendAPIEndpoint(configFile.LoggedInUserDomain) } isAuthenticated := api.CallIsAuthenticated(httpClient) diff --git a/cli/packages/util/folders.go b/cli/packages/util/folders.go index 18fe5c888..c7f6de630 100644 --- a/cli/packages/util/folders.go +++ b/cli/packages/util/folders.go @@ -172,19 +172,28 @@ func GetFoldersViaMachineIdentity(accessToken string, workspaceId string, envSlu // CreateFolder creates a folder in Infisical func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, error) { - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() - if err != nil { - return models.SingleFolder{}, err - } - if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + // If no token is provided, we will try to get the token from the current logged in user + if params.InfisicalToken == "" { + RequireLogin() + RequireLocalWorkspaceFile() + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + + if err != nil { + return models.SingleFolder{}, err + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken } // set up resty client httpClient := resty.New() httpClient. - SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetAuthToken(params.InfisicalToken). SetHeader("Accept", "application/json"). SetHeader("Content-Type", "application/json") @@ -209,19 +218,29 @@ func CreateFolder(params models.CreateFolderParameters) (models.SingleFolder, er } func DeleteFolder(params models.DeleteFolderParameters) ([]models.SingleFolder, error) { - loggedInUserDetails, err := GetCurrentLoggedInUserDetails() - if err != nil { - return nil, err - } - if loggedInUserDetails.LoginExpired { - PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + // If no token is provided, we will try to get the token from the current logged in user + if params.InfisicalToken == "" { + RequireLogin() + RequireLocalWorkspaceFile() + + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + + if err != nil { + return nil, err + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + params.InfisicalToken = loggedInUserDetails.UserCredentials.JTWToken } // set up resty client httpClient := resty.New() httpClient. - SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetAuthToken(params.InfisicalToken). SetHeader("Accept", "application/json"). SetHeader("Content-Type", "application/json") diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 9a4d960db..9ce8c4a1d 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -123,7 +123,7 @@ func UniversalAuthLogin(clientId string, clientSecret string) (api.UniversalAuth return tokenResponse, nil } -func RenewUniversalAuthAccessToken(accessToken string) (string, error) { +func RenewMachineIdentityAccessToken(accessToken string) (string, error) { httpClient := resty.New() httpClient.SetRetryCount(10000). @@ -134,7 +134,7 @@ func RenewUniversalAuthAccessToken(accessToken string) (string, error) { AccessToken: accessToken, } - tokenResponse, err := api.CallUniversalAuthRefreshAccessToken(httpClient, request) + tokenResponse, err := api.CallMachineIdentityRefreshAccessToken(httpClient, request) if err != nil { return "", err } @@ -233,3 +233,57 @@ func getCurrentBranch() (string, error) { } return path.Base(strings.TrimSpace(out.String())), nil } + +func AppendAPIEndpoint(address string) string { + // Ensure the address does not already end with "/api" + if strings.HasSuffix(address, "/api") { + return address + } + + // Check if the address ends with a slash and append accordingly + if address[len(address)-1] == '/' { + return address + "api" + } + return address + "/api" +} + +func ReadFileAsString(filePath string) (string, error) { + fileBytes, err := os.ReadFile(filePath) + + if err != nil { + return "", err + } + + return string(fileBytes), nil + +} + +func GetEnvVarOrFileContent(envName string, filePath string) (string, error) { + // First check if the environment variable is set + if envVarValue := os.Getenv(envName); envVarValue != "" { + return envVarValue, nil + } + + // If it's not set, try to read the file + fileContent, err := ReadFileAsString(filePath) + + if err != nil { + return "", fmt.Errorf("unable to read file content from file path '%s' [err=%v]", filePath, err) + } + + return fileContent, nil +} + +func GetCmdFlagOrEnv(cmd *cobra.Command, flag, envName string) (string, error) { + value, flagsErr := cmd.Flags().GetString(flag) + if flagsErr != nil { + return "", flagsErr + } + if value == "" { + value = os.Getenv(envName) + } + if value == "" { + return "", fmt.Errorf("please provide %s flag", flag) + } + return value, nil +} diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 27f0636a9..08e6b563f 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -1,6 +1,7 @@ package util import ( + "crypto/sha256" "encoding/base64" "encoding/json" "errors" @@ -9,6 +10,7 @@ import ( "path" "regexp" "strings" + "unicode" "github.com/Infisical/infisical-merge/packages/api" "github.com/Infisical/infisical-merge/packages/crypto" @@ -307,32 +309,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 +367,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 +637,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 +676,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() @@ -803,3 +808,336 @@ func GetPlainTextWorkspaceKey(authenticationToken string, receiverPrivateKey str return crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey), nil } + +func SetEncryptedSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string) ([]models.SecretSetOperation, error) { + + workspaceFile, err := GetWorkSpaceFromFile() + if err != nil { + return nil, fmt.Errorf("unable to get your local config details [err=%v]", err) + } + + loggedInUserDetails, err := GetCurrentLoggedInUserDetails() + if err != nil { + return nil, fmt.Errorf("unable to authenticate [err=%v]", err) + } + + if loggedInUserDetails.LoginExpired { + PrintErrorMessageAndExit("Your login session has expired, please run [infisical login] and try again") + } + + httpClient := resty.New(). + SetAuthToken(loggedInUserDetails.UserCredentials.JTWToken). + SetHeader("Accept", "application/json") + + request := api.GetEncryptedWorkspaceKeyRequest{ + WorkspaceId: workspaceFile.WorkspaceId, + } + + workspaceKeyResponse, err := api.CallGetEncryptedWorkspaceKey(httpClient, request) + if err != nil { + return nil, fmt.Errorf("unable to get your encrypted workspace key [err=%v]", err) + } + + encryptedWorkspaceKey, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.EncryptedKey) + encryptedWorkspaceKeySenderPublicKey, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.Sender.PublicKey) + encryptedWorkspaceKeyNonce, _ := base64.StdEncoding.DecodeString(workspaceKeyResponse.Nonce) + currentUsersPrivateKey, _ := base64.StdEncoding.DecodeString(loggedInUserDetails.UserCredentials.PrivateKey) + + if len(currentUsersPrivateKey) == 0 || len(encryptedWorkspaceKeySenderPublicKey) == 0 { + log.Debug().Msgf("Missing credentials for generating plainTextEncryptionKey: [currentUsersPrivateKey=%s] [encryptedWorkspaceKeySenderPublicKey=%s]", currentUsersPrivateKey, encryptedWorkspaceKeySenderPublicKey) + PrintErrorMessageAndExit("Some required user credentials are missing to generate your [plainTextEncryptionKey]. Please run [infisical login] then try again") + } + + // decrypt workspace key + plainTextEncryptionKey := crypto.DecryptAsymmetric(encryptedWorkspaceKey, encryptedWorkspaceKeyNonce, encryptedWorkspaceKeySenderPublicKey, currentUsersPrivateKey) + + infisicalTokenEnv := os.Getenv(INFISICAL_TOKEN_NAME) + + // pull current secrets + secrets, err := GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, SecretsPath: secretsPath, InfisicalToken: infisicalTokenEnv}, "") + if err != nil { + return nil, fmt.Errorf("unable to retrieve secrets [err=%v]", err) + } + + secretsToCreate := []api.Secret{} + secretsToModify := []api.Secret{} + secretOperations := []models.SecretSetOperation{} + + sharedSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + personalSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + + for _, secret := range secrets { + if secret.Type == SECRET_TYPE_PERSONAL { + personalSecretMapByName[secret.Key] = secret + } else { + sharedSecretMapByName[secret.Key] = secret + } + } + + for _, arg := range secretArgs { + splitKeyValueFromArg := strings.SplitN(arg, "=", 2) + if splitKeyValueFromArg[0] == "" || splitKeyValueFromArg[1] == "" { + PrintErrorMessageAndExit("ensure that each secret has a none empty key and value. Modify the input and try again") + } + + if unicode.IsNumber(rune(splitKeyValueFromArg[0][0])) { + PrintErrorMessageAndExit("keys of secrets cannot start with a number. Modify the key name(s) and try again") + } + + // Key and value from argument + key := splitKeyValueFromArg[0] + value := splitKeyValueFromArg[1] + + hashedKey := fmt.Sprintf("%x", sha256.Sum256([]byte(key))) + encryptedKey, err := crypto.EncryptSymmetric([]byte(key), []byte(plainTextEncryptionKey)) + if err != nil { + return nil, fmt.Errorf("unable to encrypt your secrets [err=%v]", err) + } + + hashedValue := fmt.Sprintf("%x", sha256.Sum256([]byte(value))) + encryptedValue, err := crypto.EncryptSymmetric([]byte(value), []byte(plainTextEncryptionKey)) + if err != nil { + return nil, fmt.Errorf("unable to encrypt your secrets [err=%v]", err) + } + + var existingSecret models.SingleEnvironmentVariable + var doesSecretExist bool + + if secretType == SECRET_TYPE_SHARED { + existingSecret, doesSecretExist = sharedSecretMapByName[key] + } else { + existingSecret, doesSecretExist = personalSecretMapByName[key] + } + + if doesSecretExist { + // case: secret exists in project so it needs to be modified + encryptedSecretDetails := api.Secret{ + ID: existingSecret.ID, + SecretValueCiphertext: base64.StdEncoding.EncodeToString(encryptedValue.CipherText), + SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), + SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), + SecretValueHash: hashedValue, + PlainTextKey: key, + Type: existingSecret.Type, + } + + // Only add to modifications if the value is different + if existingSecret.Value != value { + secretsToModify = append(secretsToModify, encryptedSecretDetails) + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET VALUE MODIFIED", + }) + } else { + // Current value is same as exisitng so no change + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET VALUE UNCHANGED", + }) + } + + } else { + // case: secret doesn't exist in project so it needs to be created + encryptedSecretDetails := api.Secret{ + SecretKeyCiphertext: base64.StdEncoding.EncodeToString(encryptedKey.CipherText), + SecretKeyIV: base64.StdEncoding.EncodeToString(encryptedKey.Nonce), + SecretKeyTag: base64.StdEncoding.EncodeToString(encryptedKey.AuthTag), + SecretKeyHash: hashedKey, + SecretValueCiphertext: base64.StdEncoding.EncodeToString(encryptedValue.CipherText), + SecretValueIV: base64.StdEncoding.EncodeToString(encryptedValue.Nonce), + SecretValueTag: base64.StdEncoding.EncodeToString(encryptedValue.AuthTag), + SecretValueHash: hashedValue, + Type: secretType, + PlainTextKey: key, + } + secretsToCreate = append(secretsToCreate, encryptedSecretDetails) + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET CREATED", + }) + } + } + + for _, secret := range secretsToCreate { + createSecretRequest := api.CreateSecretV3Request{ + WorkspaceID: workspaceFile.WorkspaceId, + Environment: environmentName, + SecretName: secret.PlainTextKey, + SecretKeyCiphertext: secret.SecretKeyCiphertext, + SecretKeyIV: secret.SecretKeyIV, + SecretKeyTag: secret.SecretKeyTag, + SecretValueCiphertext: secret.SecretValueCiphertext, + SecretValueIV: secret.SecretValueIV, + SecretValueTag: secret.SecretValueTag, + Type: secret.Type, + SecretPath: secretsPath, + } + + err = api.CallCreateSecretsV3(httpClient, createSecretRequest) + if err != nil { + return nil, fmt.Errorf("unable to process new secret creations [err=%v]", err) + } + } + + for _, secret := range secretsToModify { + updateSecretRequest := api.UpdateSecretByNameV3Request{ + WorkspaceID: workspaceFile.WorkspaceId, + Environment: environmentName, + SecretValueCiphertext: secret.SecretValueCiphertext, + SecretValueIV: secret.SecretValueIV, + SecretValueTag: secret.SecretValueTag, + Type: secret.Type, + SecretPath: secretsPath, + } + + err = api.CallUpdateSecretsV3(httpClient, updateSecretRequest, secret.PlainTextKey) + if err != nil { + return nil, fmt.Errorf("unable to process secret update request [err=%v]", err) + } + } + + return secretOperations, nil + +} + +func SetRawSecrets(secretArgs []string, secretType string, environmentName string, secretsPath string, projectId string, tokenDetails *models.TokenDetails) ([]models.SecretSetOperation, error) { + + if tokenDetails == nil { + return nil, fmt.Errorf("unable to process set secret operations, token details are missing") + } + + getAllEnvironmentVariablesRequest := models.GetAllSecretsParameters{Environment: environmentName, SecretsPath: secretsPath, WorkspaceId: projectId} + if tokenDetails.Type == UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + getAllEnvironmentVariablesRequest.UniversalAuthAccessToken = tokenDetails.Token + } else { + getAllEnvironmentVariablesRequest.InfisicalToken = tokenDetails.Token + } + + httpClient := resty.New(). + SetAuthToken(tokenDetails.Token). + SetHeader("Accept", "application/json") + + // pull current secrets + secrets, err := GetAllEnvironmentVariables(getAllEnvironmentVariablesRequest, "") + if err != nil { + return nil, fmt.Errorf("unable to retrieve secrets [err=%v]", err) + } + + secretsToCreate := []api.RawSecret{} + secretsToModify := []api.RawSecret{} + secretOperations := []models.SecretSetOperation{} + + sharedSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + personalSecretMapByName := make(map[string]models.SingleEnvironmentVariable, len(secrets)) + + for _, secret := range secrets { + if secret.Type == SECRET_TYPE_PERSONAL { + personalSecretMapByName[secret.Key] = secret + } else { + sharedSecretMapByName[secret.Key] = secret + } + } + + for _, arg := range secretArgs { + splitKeyValueFromArg := strings.SplitN(arg, "=", 2) + if splitKeyValueFromArg[0] == "" || splitKeyValueFromArg[1] == "" { + PrintErrorMessageAndExit("ensure that each secret has a none empty key and value. Modify the input and try again") + } + + if unicode.IsNumber(rune(splitKeyValueFromArg[0][0])) { + PrintErrorMessageAndExit("keys of secrets cannot start with a number. Modify the key name(s) and try again") + } + + // Key and value from argument + key := splitKeyValueFromArg[0] + value := splitKeyValueFromArg[1] + + var existingSecret models.SingleEnvironmentVariable + var doesSecretExist bool + + if secretType == SECRET_TYPE_SHARED { + existingSecret, doesSecretExist = sharedSecretMapByName[key] + } else { + existingSecret, doesSecretExist = personalSecretMapByName[key] + } + + if doesSecretExist { + // case: secret exists in project so it needs to be modified + encryptedSecretDetails := api.RawSecret{ + ID: existingSecret.ID, + SecretValue: value, + SecretKey: key, + Type: existingSecret.Type, + } + + // Only add to modifications if the value is different + if existingSecret.Value != value { + secretsToModify = append(secretsToModify, encryptedSecretDetails) + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET VALUE MODIFIED", + }) + } else { + // Current value is same as existing so no change + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET VALUE UNCHANGED", + }) + } + + } else { + // case: secret doesn't exist in project so it needs to be created + encryptedSecretDetails := api.RawSecret{ + SecretKey: key, + SecretValue: value, + Type: secretType, + } + secretsToCreate = append(secretsToCreate, encryptedSecretDetails) + secretOperations = append(secretOperations, models.SecretSetOperation{ + SecretKey: key, + SecretValue: value, + SecretOperation: "SECRET CREATED", + }) + } + } + + for _, secret := range secretsToCreate { + createSecretRequest := api.CreateRawSecretV3Request{ + SecretName: secret.SecretKey, + SecretValue: secret.SecretValue, + Type: secret.Type, + SecretPath: secretsPath, + WorkspaceID: projectId, + Environment: environmentName, + } + + err = api.CallCreateRawSecretsV3(httpClient, createSecretRequest) + if err != nil { + return nil, fmt.Errorf("unable to process new secret creations [err=%v]", err) + } + } + + for _, secret := range secretsToModify { + updateSecretRequest := api.UpdateRawSecretByNameV3Request{ + SecretName: secret.SecretKey, + SecretValue: secret.SecretValue, + SecretPath: secretsPath, + WorkspaceID: projectId, + Environment: environmentName, + Type: secret.Type, + } + + err = api.CallUpdateRawSecretsV3(httpClient, updateSecretRequest) + if err != nil { + return nil, fmt.Errorf("unable to process secret update request [err=%v]", err) + } + } + + return secretOperations, nil + +} 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/company/documentation/getting-started/introduction.mdx b/company/documentation/getting-started/introduction.mdx index 0f414c62a..55b483194 100644 --- a/company/documentation/getting-started/introduction.mdx +++ b/company/documentation/getting-started/introduction.mdx @@ -4,59 +4,63 @@ sidebarTitle: "What is Infisical?" description: "An Introduction to the Infisical secret management platform." --- -Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. -It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database -credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure -sharing of secrets among engineers. +**[Infisical](https://infisical.com)** is the open source secret management platform that developers use to centralize their application configuration and secrets like API keys and database credentials as well as manage their internal PKI. In addition, developers use Infisical to prevent secrets leaks to git and securely share secrets amongst engineers. Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. - - Get started with Infisical Cloud in just a few minutes. - - - Self-host Infisical on your own infrastructure. - + + Get started with Infisical Cloud in just a few minutes. + + + Self-host Infisical on your own infrastructure. + -## Why Infisical? +## Why Infisical? + +Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: -Infisical helps developers achieve secure centralized secret management and provides all the tools to easily manage secrets in various environments and infrastructure components. In particular, here are some of the most common points that developers mention after adopting Infisical: - Streamlined **local development** processes (switching .env files to [Infisical CLI](/cli/commands/run) and removing secrets from developer machines). -- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). -- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. -- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. +- **Best-in-class developer experience** with an easy-to-use [Web Dashboard](/documentation/platform/project). +- Simple secret management inside **[CI/CD pipelines](/integrations/cicd/githubactions)** and staging environments. +- Secure and compliant secret management practices in **[production environments](/sdks/overview)**. - **Facilitated workflows** around [secret change management](/documentation/platform/pr-workflows), [access requests](/documentation/platform/access-controls/access-requests), [temporary access provisioning](/documentation/platform/access-controls/temporary-access), and more. - **Improved security posture** thanks to [secret scanning](/cli/scanning-overview), [granular access control policies](/documentation/platform/access-controls/overview), [automated secret rotation](https://infisical.com/docs/documentation/platform/secret-rotation/overview), and [dynamic secrets](/documentation/platform/dynamic-secrets/overview) capabilities. -## How does Infisical work? +## How does Infisical work? -To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. +To make secret management effortless and secure, Infisical follows a certain structure for enabling secret management workflows as defined below. -**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. +**Identities** in Infisical are users or machine which have a certain set of roles and permissions assigned to them. Such identities are able to manage secrets in various **Clients** throughout the entire infrastructure. To do that, identities have to verify themselves through one of the available **Authentication Methods**. -As a result, the 3 main concepts that are important to understand are: -- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. +As a result, the 3 main concepts that are important to understand are: + +- **[Identities](/documentation/platform/identities/overview)**: users or machines with a set permissions assigned to them. - **[Clients](/integrations/platforms/kubernetes)**: Infisical-developed tools for managing secrets in various infrastructure components (e.g., [Kubernetes Operator](/integrations/platforms/kubernetes), [Infisical Agent](/integrations/platforms/infisical-agent), [CLI](/cli/usage), [SDKs](/sdks/overview), [API](/api-reference/overview/introduction), [Web Dashboard](/documentation/platform/organization)). - **[Authentication Methods](/documentation/platform/identities/universal-auth)**: ways for Identities to authenticate inside different clients (e.g., SAML SSO for Web Dashboard, Universal Auth for Infisical Agent, etc.). -## How to get started with Infisical? +## How to get started with Infisical? Depending on your use case, it might be helpful to look into some of the resources and guides provided below. - + Inject secrets into any application process/environment. Fetch secrets with any programming language on demand. - + Inject secrets into Docker containers. a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + /* #sidebar ul > div.mt-12 { padding-top: 30px; position: relative; @@ -49,10 +56,10 @@ } */ #header { - border-left: 1px solid #26272b; + border-left: 4px solid #EFFF33; padding-left: 16px; padding-right: 16px; - background-color: #f5f5f5; + background-color: #FDFFE5; padding-bottom: 10px; padding-top: 10px; } @@ -63,6 +70,13 @@ border-color: #ebebeb; } +#content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} + #content-area .mt-8 .rounded-xl{ border-radius: 0; } 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/api-reference/endpoints/project-roles/create.mdx b/docs/api-reference/endpoints/project-roles/create.mdx new file mode 100644 index 000000000..2220b9309 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/delete.mdx b/docs/api-reference/endpoints/project-roles/delete.mdx new file mode 100644 index 000000000..6362c2154 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/project-roles/get-by-slug.mdx b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx new file mode 100644 index 000000000..18817bca9 --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectSlug}/roles/slug/{slug}" +--- diff --git a/docs/api-reference/endpoints/project-roles/list.mdx b/docs/api-reference/endpoints/project-roles/list.mdx new file mode 100644 index 000000000..ca83d6e7d --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/workspace/{projectSlug}/roles" +--- diff --git a/docs/api-reference/endpoints/project-roles/update.mdx b/docs/api-reference/endpoints/project-roles/update.mdx new file mode 100644 index 000000000..5a3d9668e --- /dev/null +++ b/docs/api-reference/endpoints/project-roles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectSlug}/roles/{roleId}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-id.mdx b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx new file mode 100644 index 000000000..de02fe133 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By ID" +openapi: "GET /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx new file mode 100644 index 000000000..91eab730f --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get By Slug" +openapi: "GET /api/v1/workspace/{projectId}/tags/slug/{tagSlug}" +--- diff --git a/docs/api-reference/endpoints/secret-tags/update.mdx b/docs/api-reference/endpoints/secret-tags/update.mdx new file mode 100644 index 000000000..b9c290db8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-tags/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/workspace/{projectId}/tags/{tagId}" +--- diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index d73c0bb14..d265b546c 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,25 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## May 2024 +- Released [AWS](https://infisical.com/docs/documentation/platform/identities/aws-auth), [GCP](https://infisical.com/docs/documentation/platform/identities/gcp-auth), [Azure](https://infisical.com/docs/documentation/platform/identities/azure-auth), and [Kubernetes](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth) Native Auth Methods. +- Added [Secret Sharing](https://infisical.com/docs/documentation/platform/secret-sharing) functionality for sharing sensitive data through encrypted links – within and outside of an organization. +- Updated [Secret Referencing](https://infisical.com/docs/documentation/platform/secret-reference) to be supported in all Infisical clients. Infisical UI is now able to provide automatic reference suggestions when typing. +- Released new [Infisical Jenkins Plugin](https://infisical.com/docs/integrations/cicd/jenkins). +- Added statuses and manual sync option to integrations in the Dashboard UI. +- Released universal [Audit Log Streaming](https://infisical.com/docs/documentation/platform/audit-log-streams). +- Added [Dynamic Secret template for AWS IAM](https://infisical.com/docs/documentation/platform/dynamic-secrets/aws-iam). +- Added support for syncing tags and custom KMS keys to [AWS Secrets Manager](https://infisical.com/docs/integrations/cloud/aws-secret-manager) and [Parameter Store](https://infisical.com/docs/integrations/cloud/aws-parameter-store) Integrations. +- Officially released Infisical on [AWS Marketplace](https://infisical.com/blog/infisical-launches-on-aws-marketplace). + +## April 2024 +- Added [Access Requests](https://infisical.com/docs/documentation/platform/access-controls/access-requests) as part of self-serve secrets management workflows. +- Added [Temporary Access Provisioning](https://infisical.com/docs/documentation/platform/access-controls/temporary-access) for roles and additional privileges. + +## March 2024 +- Released support for [Dynamic Secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview). +- Released the concept of [Additional Privileges](https://infisical.com/docs/documentation/platform/access-controls/additional-privileges) on top of user/machine roles. + ## Feb 2024 - Added org-scoped authentication enforcement for SAML - Added support for [SCIM](https://infisical.com/docs/documentation/platform/scim/overview) along with instructions for setting it up with [Okta](https://infisical.com/docs/documentation/platform/scim/okta), [Azure](https://infisical.com/docs/documentation/platform/scim/azure), and [JumpCloud](https://infisical.com/docs/documentation/platform/scim/jumpcloud). diff --git a/docs/cli/commands/login.mdx b/docs/cli/commands/login.mdx index 2758ced00..d97cb4c2b 100644 --- a/docs/cli/commands/login.mdx +++ b/docs/cli/commands/login.mdx @@ -7,32 +7,38 @@ description: "Login into Infisical from the CLI" infisical login ``` -## Description +### Description The CLI uses authentication to verify your identity. When you enter the correct email and password for your account, a token is generated and saved in your system Keyring to allow you to make future interactions with the CLI. To change where the login credentials are stored, visit the [vaults command](./vault). If you have added multiple users, you can switch between the users by using the [user command](./user). + + When you authenticate with **any other method than `user`**, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. + + Use flag `--plain` along with `--silent` to print only the token in plain text when using a machine identity auth method. + + + ### Flags +The login command supports a number of flags that you can use for different authentication methods. Below is a list of all the flags that can be used with the login command. + + ```bash infisical login --method= # Optional, will default to 'user'. ``` #### Valid values for the `method` flag are: - - `user`: Login using email and password. + - `user`: Login using email and password. (default) - `universal-auth`: Login using a universal auth client ID and client secret. - - - When `method` is set to `universal-auth`, the `client-id` and `client-secret` flags are required. Optionally you can set the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` and `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variables instead of using the flags. - - When you authenticate with universal auth, an access token will be printed to the console upon successful login. This token can be used to authenticate with the Infisical API and the CLI by passing it in the `--token` flag when applicable. - - Use flag `--plain` along with `--silent` to print only the token in plain text when using the `universal-auth` method. - - + - `kubernetes`: Login using a Kubernetes native auth. + - `azure`: Login using an Azure native auth. + - `gcp-id-token`: Login using a GCP ID token native auth. + - `gcp-iam`: Login using a GCP IAM. + - `aws-iam`: Login using an AWS IAM native auth. @@ -41,7 +47,7 @@ If you have added multiple users, you can switch between the users by using the ``` #### Description - The client ID of the universal auth client. This is required if the `--method` flag is set to `universal-auth`. + The client ID of the universal auth machine identity. This is required if the `--method` flag is set to `universal-auth`. The `client-id` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` environment variable. @@ -52,13 +58,245 @@ If you have added multiple users, you can switch between the users by using the infisical login --client-secret= # Optional, required if --method=universal-auth. ``` #### Description - The client secret of the universal auth client. This is required if the `--method` flag is set to `universal-auth`. + The client secret of the universal auth machine identity. This is required if the `--method` flag is set to `universal-auth`. The `client-secret` flag can be substituted with the `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` environment variable. - + + ```bash + infisical login --machine-identity-id= # Optional, required if --method=kubernetes, azure, gcp-id-token, gcp-iam, or aws-iam. + ``` + + #### Description + The ID of the machine identity. This is required if the `--method` flag is set to `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, or `aws-iam`. + + + The `machine-identity-id` flag can be substituted with the `INFISICAL_MACHINE_IDENTITY_ID` environment variable. + + + + ```bash + infisical login --service-account-token-path= # Optional Will default to '/var/run/secrets/kubernetes.io/serviceaccount/token'. + ``` + + #### Description + The path to the Kubernetes service account token to use for authentication. + This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + The `service-account-token-path` flag can be substituted with the `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH` environment variable. + + + + ```bash + infisical login --service-account-key-file-path= # Optional, but required if --method=gcp-iam. + ``` + + #### Description + The path to your GCP service account key file. This is required if the `--method` flag is set to `gcp-iam`. + + + The `service-account-key-path` flag can be substituted with the `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` environment variable. + + + - \ No newline at end of file +### Authentication Methods + +The Infisical CLI supports multiple authentication methods. Below are the available authentication methods, with their respective flags. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Your machine identity client ID. + + + Your machine identity client secret. + + + + + + + To create a universal auth machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/universal-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=universal-auth --client-id= --client-secret= + ``` + + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Your machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + + + + + To create a Kubernetes machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/kubernetes-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + # --service-account-token-path is optional, and will default to '/var/run/secrets/kubernetes.io/serviceaccount/token' if not provided. + infisical login --method=kubernetes --machine-identity-id= --service-account-token-path= + ``` + + + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Your machine identity ID. + + + + + + + To create an Azure machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/azure-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=azure --machine-identity-id= + ``` + + + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Your machine identity ID. + + + + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=gcp-id-token --machine-identity-id= + ``` + + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Your machine identity ID. + + + Path to your GCP service account key file _(Must be in JSON format!)_ + + + + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=gcp-iam --machine-identity-id= --service-account-key-file-path= + ``` + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Your machine identity ID. + + + + + + + To create an AWS machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/aws-auth). + + + Run the `login` command with the following flags to obtain an access token: + + ```bash + infisical login --method=aws-iam --machine-identity-id= + ``` + + + + + +### Machine Identity Authentication Quick Start +In this example we'll be using the `universal-auth` method to login to obtain an Infisical access token, which we will then use to fetch secrets with. + + + + ```bash + export INFISICAL_TOKEN=$(infisical login --method=universal-auth --client-id= --client-secret= --silent --plain) # silent and plain is important to ensure only the token itself is printed, so we can easily set it as an environment variable. + ``` + + Now that we've set the `INFISICAL_TOKEN` environment variable, we can use the CLI to interact with Infisical. The CLI will automatically check for the presence of the `INFISICAL_TOKEN` environment variable and use it for authentication. + + + Alternatively, if you would rather use the `--token` flag to pass the token directly, you can do so by running the following command: + + ```bash + infisical [command] --token= # The token output from the login command. + ``` + + + + ```bash + infisical secrets --projectId= + The `--recursive`, and `--env` flag is optional and will fetch all secrets in subfolders. The default environment is `dev` if no `--env` flag is provided. + + + + +And that's it! Now you're ready to start using the Infisical CLI to interact with your secrets, with the use of Machine Identities. diff --git a/docs/cli/commands/secrets.mdx b/docs/cli/commands/secrets.mdx index 941820227..7428a84a1 100644 --- a/docs/cli/commands/secrets.mdx +++ b/docs/cli/commands/secrets.mdx @@ -184,6 +184,16 @@ $ infisical secrets set STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jeb ``` + + + Used to select the type of secret to create. This could be either personal or shared (defaults to shared) + + ```bash + # Example + infisical secrets set DOMAIN=example.com --type=personal + ``` + + diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 06455092d..d73c28ab5 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -4,10 +4,7 @@ sidebarTitle: "What is Infisical?" description: "An Introduction to the Infisical secret management platform." --- -Infisical is an [open-source](https://github.com/infisical/infisical) secret management platform for developers. -It provides capabilities for storing, managing, and syncing application configuration and secrets like API keys, database -credentials, and certificates across infrastructure. In addition, Infisical prevents secrets leaks to git and enables secure -sharing of secrets among engineers. +**[Infisical](https://infisical.com)** is the open source secret management platform that developers use to centralize their application configuration and secrets like API keys and database credentials as well as manage their internal PKI. Additionally, developers use Infisical to prevent secrets leaks to git and securely share secrets amongst engineers. Start managing secrets securely with [Infisical Cloud](https://app.infisical.com) or learn how to [host Infisical](/self-hosting/overview) yourself. 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/guides/node.mdx b/docs/documentation/guides/node.mdx index 8b78cde5e..d1b8fe5e8 100644 --- a/docs/documentation/guides/node.mdx +++ b/docs/documentation/guides/node.mdx @@ -36,7 +36,7 @@ Initialize a new Node.js project with a default `package.json` file. npm init -y ``` -Install `express` and [infisical-node](https://github.com/Infisical/infisical-node), the client Node SDK for Infisical. +Install `express` and [@infisical/sdk](https://www.npmjs.com/package/@infisical/sdk), the client Node SDK for Infisical. ```console npm install express @infisical/sdk @@ -46,16 +46,19 @@ Finally, create an index.js file containing the application code. ```js const express = require('express'); -const { InfisicalClient, LogLevel } = require("@infisical/sdk"); +const { InfisicalClient } = require("@infisical/sdk"); const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + } + } }); app.get("/", async (req, res) => { diff --git a/docs/documentation/guides/python.mdx b/docs/documentation/guides/python.mdx index 696f0a7ee..00b3d6089 100644 --- a/docs/documentation/guides/python.mdx +++ b/docs/documentation/guides/python.mdx @@ -5,7 +5,7 @@ title: "Python" This guide demonstrates how to use Infisical to manage secrets for your Python stack from local development to production. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets. -- The [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py) Python client SDK to fetch secrets back to your Python application on demand. +- The [infisical-python](https://pypi.org/project/infisical-python/) Python client SDK to fetch secrets back to your Python application on demand. ## Project Setup @@ -36,23 +36,27 @@ python3 -m venv env source env/bin/activate ``` -Install Flask and [infisical-python](https://github.com/Infisical/sdk/tree/main/crates/infisical-py), the client Python SDK for Infisical. +Install Flask and [infisical-python](https://pypi.org/project/infisical-python/), the client Python SDK for Infisical. ```console -pip install Flask infisical-python +pip install flask infisical-python ``` Finally, create an `app.py` file containing the application code. ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") diff --git a/docs/documentation/platform/identities/aws-auth.mdx b/docs/documentation/platform/identities/aws-auth.mdx index 505d5a8dd..3eb094cc4 100644 --- a/docs/documentation/platform/identities/aws-auth.mdx +++ b/docs/documentation/platform/identities/aws-auth.mdx @@ -280,6 +280,10 @@ access the Infisical API using the AWS Auth authentication method. --data-urlencode 'iamRequestHeaders=...' ``` + + Note that you should replace `` with the ID of the identity you created in step 1. + + #### Sample response ```bash Response diff --git a/docs/documentation/platform/identities/azure-auth.mdx b/docs/documentation/platform/identities/azure-auth.mdx new file mode 100644 index 000000000..3ac957752 --- /dev/null +++ b/docs/documentation/platform/identities/azure-auth.mdx @@ -0,0 +1,176 @@ +--- +title: Azure Auth +description: "Learn how to authenticate with Infisical for services on Azure" +--- + +**Azure Auth** is an Azure-native authentication method for Azure resources like Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc. to access Infisical. + +## Diagram + +The following sequence digram illustrates the Azure Auth workflow for authenticating Azure [service principals](https://learn.microsoft.com/en-us/entra/identity-platform/app-objects-and-service-principals?tabs=browser) with Infisical. + +```mermaid +sequenceDiagram + participant Client as Client + participant Infis as Infisical + participant Azure as Azure AD OpenID + + Note over Client,Azure: Step 1: Instance Identity Token Retrieval + Client->>Azure: Request managed identity access token + Azure-->>Client: Return managed identity access token + + Note over Client,Infis: Step 2: Identity Token Login Operation + Client->>Infis: Send managed identity access token to /api/v1/auth/azure-auth/login + Infis->>Azure: Request public key + Azure-->>Infis: Return public key + + Note over Infis: Step 3: Identity Token Verification + Note over Infis: Step 4: Identity Property Validation + Infis->>Client: Return short-lived access token + + Note over Client,Infis: Step 4: Access Infisical API with Token + Client->>Infis: Make authenticated requests using the short-lived access token +``` + +## Concept + +At a high-level, Infisical authenticates an Azure service by verifying its identity and checking that it meets specific requirements (e.g. it is bound to an allowed service principal) at the `/api/v1/auth/azure-auth/login` endpoint. If successful, +then Infisical returns a short-lived access token that can be used to make authenticated requests to the Infisical API. + +To be more specific: + +1. The client running on an Azure service obtains an [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine; the managed identity is associated with a service principal in Azure AD. +2. The client sends the access token to Infisical. +3. Infisical verifies the token against the corresponding public key at the [public Azure AD OpenID configuration endpoint](https://learn.microsoft.com/en-us/answers/questions/793793/azure-ad-validate-access-token). +4. Infisical checks if the entity behind the access token is allowed to authenticate with Infisical based on set criteria such as **Allowed Service Principal IDs**. +5. If all is well, Infisical returns a short-lived access token that the client can use to make authenticated requests to the Infisical API. + + +We recommend using one of Infisical's clients like SDKs or the Infisical Agent +to authenticate with Infisical using Azure Auth as they handle the +authentication process including generating the client access token for you. + +Also, note that Infisical needs network-level access to send requests to the Google Cloud API +as part of the Azure Auth workflow. + + + +## Guide + +In the following steps, we explore how to create and use identities for your applications in Azure to +access the Infisical API using the Azure Auth authentication method. + + + + 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 **Azure Auth**. + + ![identities create azure auth method](/images/platform/identities/identities-org-create-azure-auth-method.png) + + Here's some more guidance on each field: + + - Tenant ID: The [tenant ID](https://learn.microsoft.com/en-us/entra/fundamentals/how-to-find-tenant) for the Azure AD organization. + - Resource / Audience: The resource URL for the application registered in Azure AD. The value is expected to match the `aud` claim of the access token JWT later used in the login operation against Infisical. See the [resource](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) parameter for how the audience is set when requesting a JWT access token from the Azure Instance Metadata Service (IMDS) endpoint. In most cases, this value should be `https://management.azure.com/` which is the default. + - Allowed Service Principal IDs: A comma-separated list of Azure AD service principal IDs that are allowed to authenticate with Infisical. + - Access Token TTL (default is `2592000` equivalent to 30 days): The lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max TTL (default is `2592000` equivalent to 30 days): The maximum lifetime for an acccess token in seconds. This value will be referenced at renewal time. + - Access Token Max Number of Uses (default is `0`): The maximum number of times that an access token can be used; a value of `0` implies infinite number of uses. + - Access Token Trusted IPs: The IPs or CIDR ranges that access tokens can be used from. By default, each token is given the `0.0.0.0/0`, allowing usage from any network address. + + + + To enable the identity to access project-level resources such as secrets within a specific project, you should add it to that project. + + 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) + + + To access the Infisical API as the identity, you need to generate a managed identity [access token](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http) that is a JWT token representing the managed identity for the Azure resource such as a Virtual Machine. The client token must be sent to the `/api/v1/auth/azure-auth/login` endpoint in exchange for a separate access token to access the Infisical API. + + We provide a few code examples below of how you can authenticate with Infisical to access the [Infisical API](/api-reference/overview/introduction). + + + + Start by making a request from your Azure client such as Virtual Machine to obtain a managed identity access token. + + For more examples of how to obtain the managed identity access token, refer to the [official documentation](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http). + + #### Sample request + ```bash curl + curl 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' -H Metadata:true -s + ``` + + #### Sample response + ```bash + { + "access_token": "eyJ0eXAi...", + "refresh_token": "", + "expires_in": "3599", + "expires_on": "1506484173", + "not_before": "1506480273", + "resource": "https://management.azure.com/", + "token_type": "Bearer" + } + ``` + + Next use send the obtained managed identity access token (i.e. the token from the `access_token` field above) to authenticate with Infisical and obtain a separate access token. + + #### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/auth/gcp-auth/login' \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'identityId=...' \ + --data-urlencode 'jwt=...' + ``` + + + Note that you should replace `` with the ID of the identity you created in step 1. + + + #### Sample response + + ```bash Response + { + "accessToken": "...", + "expiresIn": 7200, + "accessTokenMaxTTL": 43244 + "tokenType": "Bearer" + } + ``` + + Next, you can use this access token to access the [Infisical API](/api-reference/overview/introduction) + + + + + We recommend using one of Infisical's clients like SDKs or the Infisical Agent to authenticate with Infisical using Azure Auth as they handle the authentication process including retrieving the client access token. + + + Each identity access token has a time-to-live (TLL) which you can infer from the response of the login operation; + the default TTL is `7200` seconds which can be adjusted. + If an identity access token expires, it can no longer authenticate with the Infisical API. In this case, + a new access token should be obtained by performing another login operation. + + + + diff --git a/docs/documentation/platform/identities/machine-identities.mdx b/docs/documentation/platform/identities/machine-identities.mdx index f189a3d20..9cc6c4c3d 100644 --- a/docs/documentation/platform/identities/machine-identities.mdx +++ b/docs/documentation/platform/identities/machine-identities.mdx @@ -7,9 +7,9 @@ description: "Learn how to use Machine Identities to programmatically interact w An Infisical machine identity is an entity that represents a workload or application that require access to various resources in Infisical. This is conceptually similar to an IAM user in AWS or service account in Google Cloud Platform (GCP). -Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. +Each identity must authenticate with the Infisical API using a supported authentication method like [Universal Auth](/documentation/platform/identities/universal-auth), [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth), [AWS Auth](/documentation/platform/identities/aws-auth), [Azure Auth](/documentation/platform/identities/azure-auth), or [GCP Auth](/documentation/platform/identities/gcp-auth) to get back a short-lived access token to be used in subsequent requests. -![organization identities](/images/platform/organization/organization-machine-identities.png) +![Organization Identities](/images/platform/organization/organization-machine-identities.png) Key Features: @@ -39,11 +39,10 @@ To interact with various resources in Infisical, Machine Identities are able to - [Universal Auth](/documentation/platform/identities/universal-auth): A platform-agnostic authentication method that can be configured on an identity suitable to authenticate from any platform/environment. - [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth): A Kubernetes-native authentication method for applications (e.g. pods) to authenticate with Infisical. -- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for IAM principals like EC2 instances or Lambda functions to authenticate with Infisical. +- [AWS Auth](/documentation/platform/identities/aws-auth): An AWS-native authentication method for AWS services (e.g. EC2, Lambda functions, etc.) to authenticate with Infisical. +- [Azure Auth](/documentation/platform/identities/azure-auth): An Azure-native authentication method for Azure resources (e.g. Azure VMs, Azure App Services, Azure Functions, Azure Kubernetes Service, etc.) to authenticate with Infisical. - [GCP Auth](/documentation/platform/identities/gcp-auth): A GCP-native authentication method for GCP resources (e.g. Compute Engine, App Engine, Cloud Run, Google Kubernetes Engine, IAM service accounts, etc.) to authenticate with Infisical. -IAM service accounts and GCE instances to authenticate with Infisical. - ## FAQ 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/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index ee9ea5a7d..119a8cefa 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -9,14 +9,6 @@ description: "Learn the fundamentals of secret referencing and importing in Infi Infisical's secret referencing functionality makes it possible to reference the value of a "base" secret when defining the value of another secret. This means that updating the value of a base secret propagates directly to other secrets whose values depend on the base secret. - - Currently, the secret referencing feature is only supported by the - [Infisical CLI](/cli/overview), [native integrations](/integrations/overview) and [Infisical Agent](/infisical-agent/overview). - - We intend to add support for it to the [Node SDK](https://infisical.com/docs/sdks/languages/node), - [Python SDK](https://infisical.com/docs/sdks/languages/python), and [Java SDK](https://infisical.com/docs/sdks/languages/java) this quarter. - - ![secret referencing](../../images/platform/secret-references-imports/secret-reference.png) Since secret referencing works by reconstructing values back on the client side, the client, be it a user, service token, or a machine identity, fetching back secrets diff --git a/docs/documentation/platform/secret-sharing.mdx b/docs/documentation/platform/secret-sharing.mdx new file mode 100644 index 000000000..680751820 --- /dev/null +++ b/docs/documentation/platform/secret-sharing.mdx @@ -0,0 +1,45 @@ +--- +title: "Secret Sharing" +sidebarTitle: "Secret Sharing" +description: "Learn how to share time & view-count bound secrets securely with anyone on the internet." +--- + +Developers frequently need to share secrets with team members, contractors, or other third parties, which can be risky due to potential leaks or misuse. +Infisical offers a secure solution for sharing secrets over the internet in a time and view count bound manner. + +With its zero-knowledge architecture, secrets shared via Infisical remain unreadable even to Infisical itself. + +## Share a Secret + +1. Navigate to the **Organization** page. +2. Click on the **Secret Sharing** tab from the sidebar. + +![Secret Sharing](../../images/platform/secret-sharing/overview.png) + + + Infisical does not have access to the shared secrets. This is a part of our + zero knowledge architecture. + + +3. Click on the **Share Secret** button. Set the secret, its expiration time as well as the number of views allowed. It expires as soon as any of the conditions are met. + + ![Add View-Bound Sharing Secret](../../images/platform/secret-sharing/create-new-secret.png) + + + Secret once set cannot be changed. This is to ensure that the secret is not + tampered with. + + +5. Copy the link and share it with the intended recipient. Anyone with the link can access the secret before its expiration condition. Hence, it is recommended to share the link only with the intended recipient. + +![Copy URL](../../images/platform/secret-sharing/copy-url.png) + +## Access a Shared Secret + +Just click on the link you received to access the secret. The secret will be displayed on the screen & for how long it is valid. + +![Access Shared Secret](../../images/platform/secret-sharing/public-view.png) + +## Delete a Shared Secret + +In the **Secret Sharing** tab, click on the **Delete** button next to the secret you want to delete. This will delete the secret immediately & the link will no longer be accessible. diff --git a/docs/images/integrations/rundeck/integrations-rundeck-auth.png b/docs/images/integrations/rundeck/integrations-rundeck-auth.png new file mode 100644 index 000000000..8ffa69365 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-auth.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-create.png b/docs/images/integrations/rundeck/integrations-rundeck-create.png new file mode 100644 index 000000000..691c346f9 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-create.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck-token.png b/docs/images/integrations/rundeck/integrations-rundeck-token.png new file mode 100644 index 000000000..70ae704d1 Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck-token.png differ diff --git a/docs/images/integrations/rundeck/integrations-rundeck.png b/docs/images/integrations/rundeck/integrations-rundeck.png new file mode 100644 index 000000000..170e77a3f Binary files /dev/null and b/docs/images/integrations/rundeck/integrations-rundeck.png differ diff --git a/docs/images/platform/identities/identities-org-create-azure-auth-method.png b/docs/images/platform/identities/identities-org-create-azure-auth-method.png new file mode 100644 index 000000000..fc0fd1665 Binary files /dev/null and b/docs/images/platform/identities/identities-org-create-azure-auth-method.png differ 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/images/platform/secret-sharing/copy-url.png b/docs/images/platform/secret-sharing/copy-url.png new file mode 100644 index 000000000..89d86ede4 Binary files /dev/null and b/docs/images/platform/secret-sharing/copy-url.png differ diff --git a/docs/images/platform/secret-sharing/create-new-secret.png b/docs/images/platform/secret-sharing/create-new-secret.png new file mode 100644 index 000000000..335fca2b2 Binary files /dev/null and b/docs/images/platform/secret-sharing/create-new-secret.png differ diff --git a/docs/images/platform/secret-sharing/overview.png b/docs/images/platform/secret-sharing/overview.png new file mode 100644 index 000000000..428110517 Binary files /dev/null and b/docs/images/platform/secret-sharing/overview.png differ diff --git a/docs/images/platform/secret-sharing/public-view.png b/docs/images/platform/secret-sharing/public-view.png new file mode 100644 index 000000000..9673fcd37 Binary files /dev/null and b/docs/images/platform/secret-sharing/public-view.png differ diff --git a/docs/integrations/cicd/rundeck.mdx b/docs/integrations/cicd/rundeck.mdx new file mode 100644 index 000000000..a0743fd01 --- /dev/null +++ b/docs/integrations/cicd/rundeck.mdx @@ -0,0 +1,39 @@ +--- +title: "Rundeck" +description: "How to sync secrets from Infisical to Rundeck" +--- + +Prerequisites: + +- Set up and add envars to [Infisical Cloud](https://app.infisical.com) + + + + Obtain a User API Token in the Profile settings of Rundeck + + ![integrations rundeck token](../../images/integrations/rundeck/integrations-rundeck-token.png) + + Navigate to your project's integrations tab in Infisical. + + ![integrations](../../images/integrations.png) + + Press on the Rundeck tile and input your Rundeck instance Base URL and User API token to grant Infisical access to manage Rundeck keys + + ![integrations rundeck authorization](../../images/integrations/rundeck/integrations-rundeck-auth.png) + + + If this is your project's first cloud integration, then you'll have to grant + Infisical access to your project's environment variables. Although this step + breaks E2EE, it's necessary for Infisical to sync the environment variables to + the cloud platform. + + + + + Select which Infisical environment secrets you want to sync to a Rundeck Key Storage Path and press create integration to start syncing secrets to Rundeck. + + ![create integration rundeck](../../images/integrations/rundeck/integrations-rundeck-create.png) + ![integrations rundeck](../../images/integrations/rundeck/integrations-rundeck.png) + + + diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index fdad8b638..d53c557fb 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -28,6 +28,7 @@ Prerequisites: "Action": [ "ssm:PutParameter", "ssm:DeleteParameter", + "ssm:GetParameters", "ssm:GetParametersByPath", "ssm:DeleteParameters", "ssm:AddTagsToResource", // if you need to add tags to secrets diff --git a/docs/integrations/cloud/gcp-secret-manager.mdx b/docs/integrations/cloud/gcp-secret-manager.mdx index 0f21a6a9d..99edcd115 100644 --- a/docs/integrations/cloud/gcp-secret-manager.mdx +++ b/docs/integrations/cloud/gcp-secret-manager.mdx @@ -51,6 +51,8 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Using Infisical to sync secrets to GCP Secret Manager requires that you enable the Service Usage API and Cloud Resource Manager API in the Google Cloud project you want to sync secrets to. More on that [here](https://cloud.google.com/service-usage/docs/set-up-development-environment). + + Additionally, ensure that your GCP account has sufficient permission to manage secret and service resources (you can assign Secret Manager Admin and Service Usage Admin roles for testing purposes) @@ -115,6 +117,7 @@ description: "How to sync secrets from Infisical to GCP Secret Manager"
+ Using the GCP Secret Manager integration (via the OAuth2 method) on a self-hosted instance of Infisical requires configuring an OAuth2 application in GCP @@ -123,27 +126,27 @@ description: "How to sync secrets from Infisical to GCP Secret Manager" Navigate to your project API & Services > Credentials to create a new OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-api-services.png) + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app.png) + Create the application. As part of the form, add to **Authorized redirect URIs**: `https://your-domain.com/integrations/gcp-secret-manager/oauth2/callback`. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-new-app-form.png) Obtain the **Client ID** and **Client Secret** for your GCP OAuth2 application. - - ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) - + + ![integrations GCP secret manager config](../../images/integrations/gcp-secret-manager/integrations-gcp-secret-manager-config-credentials.png) + Back in your Infisical instance, add two new environment variables for the credentials of your GCP OAuth2 application: - `CLIENT_ID_GCP_SECRET_MANAGER`: The **Client ID** of your GCP OAuth2 application. - `CLIENT_SECRET_GCP_SECRET_MANAGER`: The **Client Secret** of your GCP OAuth2 application. - + Once added, restart your Infisical instance and use the GCP Secret Manager integration. + - diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 784f934ee..b29db8420 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -26,14 +26,14 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [Supabase](/integrations/cloud/supabase) | Cloud | Available | | [Northflank](/integrations/cloud/northflank) | Cloud | Available | | [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | | [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | | [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | | [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | | [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | +| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | | [Windmill](/integrations/cloud/windmill) | Cloud | Available | | [BitBucket](/integrations/cicd/bitbucket) | CI/CD | Available | | [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | @@ -41,6 +41,7 @@ Missing an integration? [Throw in a request](https://github.com/Infisical/infisi | [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | | [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | | [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | +| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | | [React](/integrations/frameworks/react) | Framework | Available | | [Vue](/integrations/frameworks/vue) | Framework | Available | | [Express](/integrations/frameworks/express) | Framework | Available | diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index ad95d0d5d..321dbec6e 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -3,7 +3,48 @@ title: "Ansible" description: "Learn how to use Infisical for secret management in Ansible." --- -The documentation for using Infisical to manage secrets in Ansible is currently available [here](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). +You can find the Infisical Ansible collection on [Ansible Galaxy](https://galaxy.ansible.com/ui/repo/published/infisical/vault/). + + +This Ansible Infisical collection includes a variety of Ansible content to help automate the management of Infisical services. This collection is maintained by the Infisical team. + + +## Ansible version compatibility +Tested with the Ansible Core >= 2.12.0 versions, and the current development version of Ansible. Ansible Core versions prior to 2.12.0 have not been tested. + +## Python version compatibility +This collection depends on the Infisical SDK for Python. + +Requires Python 3.7 or greater. + +## Installing this collection +You can install the Infisical collection with the Ansible Galaxy CLI: + +```bash +$ ansible-galaxy collection install infisical.vault +``` + +The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: + +```bash +$ pip install infisical-python +``` + +## Using this collection + +You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: + + +```bash +--- +vars: + read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" + # [{ "key": "HOST", "value": "google.com" }] +``` + ## Troubleshoot diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index 1516ae045..b397b7352 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -41,22 +41,212 @@ It then formats these secrets using the user provided templates and writes the f To set up the authentication method for token renewal and to define secret templates, the Infisical agent requires a YAML configuration file containing properties defined below. While specifying an authentication method is mandatory to start the agent, configuring sinks and secret templates are optional. -| Field | Description | -| ---------------------------- | ----------- | -| `infisical.address` | The URL of the Infisical service. Default: `"https://app.infisical.com"`. | -| `auth.type` | The type of authentication method used. Only `"universal-auth"` type is currently available | -| `auth.config.client-id` | The file path where the universal-auth client id is stored. | -| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | -| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | -| `sinks[].type` | The type of sink in a list of sinks. Each item specifies a sink type. Currently, only `"file"` type is available. | -| `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | -| `templates[].source-path` | The path to the template file that should be used to render secrets. | -| `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | -| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5 minutes` (optional) | -| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | -| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | +| Field | Description | +| ------------------------------------------------| ----------------------------- | +| `infisical.address` | The URL of the Infisical service. Default: `"https://app.infisical.com"`. | +| `auth.type` | The type of authentication method used. Available options: `universal-auth`, `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, `aws-iam`| +| `auth.config.identity-id` | The file path where the machine identity id is stored

This field is required when using any of the following auth types: `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, or `aws-iam`. | +| `auth.config.service-account-token` | Path to the Kubernetes service account token to use (optional)

Default: `/var/run/secrets/kubernetes.io/serviceaccount/token` | +| `auth.config.service-account-key` | Path to your GCP service account key file. This field is required when using `gcp-iam` auth type.

Please note that the file should be in JSON format. | +| `auth.config.client-id` | The file path where the universal-auth client id is stored. | +| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | +| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | +| `sinks[].type` | The type of sink in a list of sinks. Each item specifies a sink type. Currently, only `"file"` type is available. | +| `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | +| `templates[].source-path` | The path to the template file that should be used to render secrets. | +| `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | +| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5 minutes` (optional) | +| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | +| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | +## Authentication + +The Infisical agent supports multiple authentication methods. Below are the available authentication methods, with their respective configurations. + + + + The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. + + + + + Path to the file containing the universal auth client ID. + + + Path to the file containing the universal auth client secret. + + + Instructs the agent to remove the client secret from disk after reading it. + + + + + + + To create a universal auth machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/universal-auth). + + + Update the agent configuration file with the specified auth method, client ID, and client secret. In the snippet below you can see a sample configuration of the `auth` field when using the Universal Auth method. + + ```yaml example-auth-config.yaml + auth: + type: "universal-auth" + config: + client-id: "./client-id" # Path to the file containing the client ID + client-secret: "./client" # Path to the file containing the client secret + remove_client_secret_on_read: false # Optional field, instructs the agent to remove the client secret from disk after reading it + ``` + + + + + The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. + + + + + Path to the file containing the machine identity ID. + + + Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + + + + + To create a Kubernetes machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/kubernetes-auth). + + + Update the agent configuration file with the specified auth method, identity ID, and service account token. In the snippet below you can see a sample configuration of the `auth` field when using the Kubernetes method. + + ```yaml example-auth-config.yaml + auth: + type: "kubernetes" + config: + identity-id: "./identity-id" # Path to the file containing the machine identity ID + service-account-token: "/var/run/secrets/kubernetes.io/serviceaccount/token" # Optional field, custom path to the Kubernetes service account token to use + ``` + + + + + + The Native Azure method is used to authenticate with Infisical when running in an Azure environment. + + + + + Path to the file containing the machine identity ID. + + + + + + + To create an Azure machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/azure-auth). + + + Update the agent configuration file with the specified auth method and identity ID. In the snippet below you can see a sample configuration of the `auth` field when using the Azure method. + + ```yaml example-auth-config.yaml + auth: + type: "azure" + config: + identity-id: "./identity-id" # Path to the file containing the machine identity ID + ``` + + + + + + The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. + + + + + Path to the file containing the machine identity ID. + + + + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method and identity ID. In the snippet below you can see a sample configuration of the `auth` field when using the GCP ID Token method. + + ```yaml example-auth-config.yaml + auth: + type: "gcp-id-token" + config: + identity-id: "./identity-id" # Path to the file containing the machine identity ID + ``` + + + + + The GCP IAM method is used to authenticate with Infisical with a GCP service account key. + + + + + Path to the file containing the machine identity ID. + + + Path to your GCP service account key file. + + + + + + + To create a GCP machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/gcp-auth). + + + Update the agent configuration file with the specified auth method, identity ID, and service account key. In the snippet below you can see a sample configuration of the `auth` field when using the GCP IAM method. + + ```yaml example-auth-config.yaml + auth: + type: "gcp-iam" + config: + identity-id: "./identity-id" # Path to the file containing the machine identity ID + service-account-key: "./service-account-key.json" # Path to your GCP service account key file + ``` + + + + + The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. + + + + + Path to the file containing the machine identity ID. + + + + + + + To create an AWS machine identity, follow the step by step guide outlined [here](/documentation/platform/identities/aws-auth). + + + Update the agent configuration file with the specified auth method and identity ID. In the snippet below you can see a sample configuration of the `auth` field when using the AWS IAM method. + + ```yaml example-auth-config.yaml + auth: + type: "aws-iam" + config: + identity-id: "./identity-id" # Path to the file containing the machine identity ID + ``` + + + + + ## Quick start Infisical Agent To install the Infisical agent, you must first install the [Infisical CLI](../cli/overview) in the desired environment where you'd like the agent to run. This is because the Infisical agent is a sub-command of the Infisical CLI. diff --git a/docs/integrations/platforms/kubernetes.mdx b/docs/integrations/platforms/kubernetes.mdx index 3d1b72331..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). @@ -496,7 +890,6 @@ To enable auto redeployment you simply have to add the following annotation to t ```yaml secrets.infisical.com/auto-reload: "true" ``` - ```yaml apiVersion: apps/v1 @@ -527,7 +920,11 @@ spec: - containerPort: 80 ``` - + + #### How it works + When a secret change occurs, the operator will check to see which deployments are using the operator-managed Kubernetes secret that received the update. + Then, for each deployment that has this annotation present, a rolling update will be triggered. + ## Global configuration To configure global settings that will apply to all instances of `InfisicalSecret`, you can define these configurations in a Kubernetes ConfigMap. diff --git a/docs/mint.json b/docs/mint.json index 06b92711c..1caad0715 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,10 +32,7 @@ "thumbsRating": true }, "api": { - "baseUrl": [ - "https://app.infisical.com", - "http://localhost:8080" - ] + "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] }, "topbarLinks": [ { @@ -76,9 +73,7 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": [ - "documentation/guides/local-development" - ] + "pages": ["documentation/guides/local-development"] }, { "group": "Guides", @@ -107,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": [ @@ -124,7 +127,9 @@ "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", "documentation/platform/pr-workflows", - "documentation/platform/audit-logs" + "documentation/platform/audit-logs", + "documentation/platform/audit-log-streams", + "documentation/platform/groups" ] }, { @@ -148,8 +153,7 @@ "documentation/platform/dynamic-secrets/aws-iam" ] }, - "documentation/platform/groups", - "documentation/platform/audit-log-streams" + "documentation/platform/secret-sharing" ] }, { @@ -160,6 +164,7 @@ "documentation/platform/identities/universal-auth", "documentation/platform/identities/kubernetes-auth", "documentation/platform/identities/gcp-auth", + "documentation/platform/identities/azure-auth", "documentation/platform/identities/aws-auth", "documentation/platform/mfa", { @@ -219,9 +224,7 @@ }, { "group": "Reference architectures", - "pages": [ - "self-hosting/reference-architectures/aws-ecs" - ] + "pages": ["self-hosting/reference-architectures/aws-ecs"] }, "self-hosting/ee", "self-hosting/faq" @@ -341,6 +344,7 @@ "pages": [ "integrations/cicd/circleci", "integrations/cicd/travisci", + "integrations/cicd/rundeck", "integrations/cicd/codefresh", "integrations/cloud/checkly" ] @@ -377,21 +381,18 @@ }, { "group": "Build Tool Integrations", - "pages": [ - "integrations/build-tools/gradle" - ] + "pages": ["integrations/build-tools/gradle"] }, { "group": "", - "pages": [ - "sdks/overview" - ] + "pages": ["sdks/overview"] }, { "group": "SDK's", "pages": [ "sdks/languages/node", "sdks/languages/python", + "sdks/languages/go", "sdks/languages/java", "sdks/languages/csharp" ] @@ -403,9 +404,7 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": [ - "api-reference/overview/examples/integration" - ] + "pages": ["api-reference/overview/examples/integration"] } ] }, @@ -475,6 +474,16 @@ "api-reference/endpoints/project-identities/delete-identity-membership" ] }, + { + "group": "Project Roles", + "pages": [ + "api-reference/endpoints/project-roles/create", + "api-reference/endpoints/project-roles/update", + "api-reference/endpoints/project-roles/delete", + "api-reference/endpoints/project-roles/get-by-slug", + "api-reference/endpoints/project-roles/list" + ] + }, { "group": "Environments", "pages": [ @@ -496,7 +505,10 @@ "group": "Secret Tags", "pages": [ "api-reference/endpoints/secret-tags/list", + "api-reference/endpoints/secret-tags/get-by-id", + "api-reference/endpoints/secret-tags/get-by-slug", "api-reference/endpoints/secret-tags/create", + "api-reference/endpoints/secret-tags/update", "api-reference/endpoints/secret-tags/delete" ] }, @@ -551,14 +563,34 @@ }, { "group": "Service Tokens", - "pages": [ - "api-reference/endpoints/service-tokens/get" - ] + "pages": ["api-reference/endpoints/service-tokens/get"] }, { "group": "Audit Logs", + "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + }, + { + "group": "Certificate Authorities", "pages": [ - "api-reference/endpoints/audit-logs/export-audit-log" + "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" ] } ] @@ -575,9 +607,7 @@ }, { "group": "", - "pages": [ - "changelog/overview" - ] + "pages": ["changelog/overview"] }, { "group": "Contributing", @@ -601,9 +631,7 @@ }, { "group": "Contributing to SDK", - "pages": [ - "contributing/sdk/developing" - ] + "pages": ["contributing/sdk/developing"] } ] } diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx index b3a1d2086..90351a986 100644 --- a/docs/sdks/languages/csharp.mdx +++ b/docs/sdks/languages/csharp.mdx @@ -21,21 +21,28 @@ namespace Example static void Main(string[] args) { - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - // SiteUrl = "http://localhost:8080", <-- This line can be omitted if you're using Infisical Cloud. - }; - var infisical = new InfisicalClient(settings); + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var options = new GetSecretOptions + + var infisicalClient = new InfisicalClient(settings); + + var getSecretOptions = new GetSecretOptions { SecretName = "TEST", ProjectId = "PROJECT_ID", Environment = "dev", }; - var secret = infisical.GetSecret(options); + var secret = infisical.GetSecret(getSecretOptions); Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}"); @@ -52,8 +59,6 @@ This example demonstrates how to use the Infisical C# SDK in a C# application. T # Installation -Run `npm` to add `@infisical/sdk` to your project. - ```console $ dotnet add package Infisical.Sdk ``` @@ -70,14 +75,20 @@ namespace Example { static void Main(string[] args) { - - var settings = new ClientSettings + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions { - ClientId = "CLIENT_ID", - ClientSecret = "CLIENT_SECRET", - }; + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; - var infisical = new InfisicalClient(settings); // <-- Your SDK instance! + + var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use } } } @@ -87,14 +98,14 @@ namespace Example - + Your machine identity client ID. - + Your machine identity client secret. - + An access token obtained from the machine identity login endpoint. @@ -103,13 +114,175 @@ namespace Example If manually set to 0, caching will be disabled, this is not recommended. - + Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### 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** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + UniversalAuth = new UniversalAuthMethod + { + ClientId = "your-client-id", + ClientSecret = "your-client-secret" + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + +#### 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** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + GcpIdToken = new GcpIdTokenAuthMethod + { + IdentityId = "your-machine-identity-id", + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + +#### GCP IAM Auth + +**Using 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** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + GcpIam = new GcpIamAuthMethod + { + IdentityId = "your-machine-identity-id", + ServiceAccountKeyFilePath = "./path/to/your/service-account-key.json" + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + +#### 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** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + AwsIam = new AwsIamAuthMethod + { + IdentityId = "your-machine-identity-id", + } + } + }; + + + var infisicalClient = new InfisicalClient(settings); +``` + + +#### 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** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + Azure = new AzureAuthMethod + { + IdentityId = "YOUR_IDENTITY_ID", + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + +#### 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** +- `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** +```csharp + ClientSettings settings = new ClientSettings + { + Auth = new AuthenticationOptions + { + Kubernetes = new KubernetesAuthMethod + { + ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token", // Optional + IdentityId = "YOUR_IDENTITY_ID", + } + } + }; + + var infisicalClient = new InfisicalClient(settings); +``` + + + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. @@ -155,6 +328,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) + diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx new file mode 100644 index 000000000..2d5164b36 --- /dev/null +++ b/docs/sdks/languages/go.mdx @@ -0,0 +1,570 @@ +--- +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 := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://app.infisical.com + }) + + _, 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 := infisical.NewInfisicalClient(infisical.Config{ + SiteUrl: "https://app.infisical.com", // Optional, default is https://api.infisical.com + }) +``` + +### 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". + + + + +## Working with folders + + +### client.Folders().List(options) + +```go +folders, err := client.Folders().List(infisical.ListFoldersOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", +}) +``` + +Retrieve all within the Infisical project and environment that client is connected to. + +#### Parameters + + + + + The slug name (dev, prod, etc) of the environment from where folders should be fetched from. + + + + The project ID where the folder lives in. + + + + The path from where folders should be fetched from. + + + + + +### client.Folders().Create(options) + +```go +folder, err := client.Folders().Create(infisical.CreateFolderOptions{ + ProjectID: "PROJECT_ID", + Name: "new=folder-name", + Environment: "dev", + Path: "/", +}) +``` + +Create a new folder in Infisical. + +#### Parameters + + + + + The ID of the project where the folder will be created. + + + The slug name (dev, prod, etc) of the environment where the folder will be created. + + + The path to create the folder in. The root path is `/`. + + + The name of the folder to create. + + + + + + +### client.Folders().Update(options) + +```go +folder, err := client.Folders().Update(infisical.UpdateFolderOptions{ + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", + FolderID: "FOLDER_ID_TO_UPDATE", + NewName: "new-folder-name", +}) +``` + +Update an existing folder in Infisical. + +#### Parameters + + + + + The ID of the project where the folder will be updated. + + + The slug name (dev, prod, etc) of the environment from where the folder lives in. + + + The path from where the folder should be updated. + + + The ID of the folder to update. + + + The new name of the folder. + + + + +### client.Folders().Delete(options) + +```go +deletedFolder, err := client.Folders().Delete(infisical.DeleteFolderOptions{ + // Either folder ID or folder name is required. + FolderName: "name-of-folder-to-delete", + FolderID: "folder-id-to-delete", + ProjectID: "PROJECT_ID", + Environment: "dev", + Path: "/", +}) +``` + +Delete a folder in Infisical. + +#### Parameters + + + + + The name of the folder to delete. Note that either `FolderName` or `FolderID` is required. + + + The ID of the folder to delete. Note that either `FolderName` or `FolderID` is required. + + + + The ID of the project where the folder lives in. + + + The slug name (dev, prod, etc) of the environment from where the folder lives in. + + + The path from where the folder should be deleted. + + + \ No newline at end of file diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 5b8797b5d..879bfa624 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -19,12 +19,19 @@ import com.infisical.sdk.schema.*; public class Example { public static void main(String[] args) { - // Create a new Infisical Client + + // Create the authentication settings for the client ClientSettings settings = new ClientSettings(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); - settings.setCacheTTL(Long.valueOf(300)); // 300 seconds, 5 minutes + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + // Create a new Infisical Client InfisicalClient client = new InfisicalClient(settings); // Create the options for fetching the secret @@ -68,11 +75,18 @@ import com.infisical.sdk.schema.*; public class App { public static void main(String[] args) { - + // Create the authentication settings for the client ClientSettings settings = new ClientSettings(); - settings.setClientID("MACHINE_IDENTITY_CLIENT_ID"); - settings.setClientSecret("MACHINE_IDENTITY_CLIENT_SECRET"); + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + // Create a new Infisical Client InfisicalClient client = new InfisicalClient(settings); // Your client! } } @@ -82,15 +96,21 @@ public class App { - + Your machine identity client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - + Your machine identity client secret. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - + An access token obtained from the machine identity login endpoint. + + **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. @@ -101,10 +121,155 @@ public class App { Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### 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** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + UniversalAuthMethod authMethod = new UniversalAuthMethod(); + + authMethod.setClientID("YOUR_IDENTITY_ID"); + authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + + authOptions.setUniversalAuth(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### 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** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + GCPIDTokenAuthMethod authMethod = new GCPIDTokenAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + + authOptions.setGcpIDToken(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### GCP IAM Auth + +**Using 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** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + GCPIamAuthMethod authMethod = new GCPIamAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + authMethod.setServiceAccountKeyFilePath("./path/to/your/service-account-key.json"); + + authOptions.setGcpIam(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### 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** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + AWSIamAuthMethod authMethod = new AWSIamAuthMethod(); + + authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); + + authOptions.setAwsIam(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### 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** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + AzureAuthMethod authMethod = new AzureAuthMethod(); + + authMethod.setIdentityID("YOUR_IDENTITY_ID"); + + authOptions.setAzure(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + +#### 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** +- `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** +```java + ClientSettings settings = new ClientSettings(); + AuthenticationOptions authOptions = new AuthenticationOptions(); + KubernetesAuthMethod authMethod = new KubernetesAuthMethod(); + + authMethod.setIdentityID("YOUR_IDENTITY_ID"); + authMethod.setServiceAccountTokenPath("/var/run/secrets/kubernetes.io/serviceaccount/token"); // Optional + + authOptions.setKubernetes(authMethod); + settings.setAuth(authOptions); + + InfisicalClient client = new InfisicalClient(settings); +``` + + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. @@ -119,6 +284,8 @@ options.setEnvironment("dev"); options.setProjectID("PROJECT_ID"); options.setPath("/foo/bar"); options.setIncludeImports(false); +options.setRecursive(false); +options.setExpandSecretReferences(true); SecretElement[] secrets = client.listSecrets(options); ``` @@ -148,6 +315,14 @@ Retrieve all secrets within the Infisical project and environment that client is 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) + diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 4816392ed..1546451b8 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Node.js" icon: "node" --- -If you're working with Node.js, the official [infisical-node](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. +If you're working with Node.js, the official [Infisical Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. - [NPM Package](https://www.npmjs.com/package/@infisical/sdk) - [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/node) @@ -14,21 +14,25 @@ If you're working with Node.js, the official [infisical-node](https://github.com ```js import express from "express"; -import { InfisicalClient, LogLevel } from "@infisical/sdk"; +import { InfisicalClient } from "@infisical/sdk"; const app = express(); const PORT = 3000; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + siteUrl: "https://app.infisical.com", // Optional, defaults to https://app.infisical.com + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + } }); app.get("/", async (req, res) => { - // access value - + // Access the secret + const name = await client.getSecret({ environment: "dev", projectId: "PROJECT_ID", @@ -72,8 +76,12 @@ Import the SDK and create a client instance with your [Machine Identity](/docume import { InfisicalClient, LogLevel } from "@infisical/sdk"; const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, logLevel: LogLevel.Error }); ``` @@ -81,31 +89,40 @@ Import the SDK and create a client instance with your [Machine Identity](/docume ```js - const { InfisicalClient, LogLevel } = require("@infisical/sdk"); + const { InfisicalClient } = require("@infisical/sdk"); const client = new InfisicalClient({ - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET", - logLevel: LogLevel.Error + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + }, }); ``` -#### Parameters +### Parameters - + Your machine identity client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientId` field instead. - + Your machine identity client secret. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientSecret` field instead. - + An access token obtained from the machine identity login endpoint. + + **This field is deprecated and will be removed in future versions.** Please use the `auth.accessToken` field instead. @@ -119,10 +136,138 @@ Import the SDK and create a client instance with your [Machine Identity](/docume The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. + + + The authentication object to use for the client. This is required unless you're using environment variables. + + +### 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** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + universalAuth: { + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET" + } + } +}); +``` + +#### 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** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + gcpIdToken: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + +#### GCP IAM Auth + +**Using 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** +```js +const client = new InfisicalClient({ + auth: { + gcpIam: { + identityId: "YOUR_IDENTITY_ID", + serviceAccountKeyFilePath: "./path/to/your/service-account-key.json" + } + } +}); +``` + +#### 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** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + awsIam: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + +#### 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** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```js +const client = new InfisicalClient({ + auth: { + azure: { + identityId: "YOUR_IDENTITY_ID" + } + } +}); +``` + + +#### 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** +- `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** +```js +const client = new InfisicalClient({ + auth: { + kubernetes: { + identityId: "YOUR_IDENTITY_ID", + serviceAccountTokenPathEnvName: "/var/run/secrets/kubernetes.io/serviceaccount/token" // Optional + } + } +}); +``` + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTtl" option when creating the client. @@ -161,6 +306,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. + + 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) + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index 0ce221757..d9ab49688 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -6,20 +6,24 @@ icon: "python" If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. -- [PyPi Package](https://pypi.org/project/infisical-python/) -- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) +- [PyPi Package](https://pypi.org/project/infisical-python/) +- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) ## Basic Usage ```py from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions +from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod app = Flask(__name__) client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) @app.route("/") @@ -38,7 +42,7 @@ def hello_world(): This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. + We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. ## Installation @@ -56,11 +60,15 @@ Note: You need Python 3.7+. Import the SDK and create a client instance with your [Machine Identity](/api-reference/overview/authentication). ```py -from infisical_client import ClientSettings, InfisicalClient +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod client = InfisicalClient(ClientSettings( - client_id="MACHINE_IDENTITY_CLIENT_ID", - client_secret="MACHINE_IDENTITY_CLIENT_SECRET", + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) )) ``` @@ -68,14 +76,20 @@ client = InfisicalClient(ClientSettings( - + Your Infisical Client ID. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - + Your Infisical Client Secret. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - + If you want to directly pass an access token obtained from the authentication endpoints, you can do so. + + **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. @@ -85,18 +99,155 @@ client = InfisicalClient(ClientSettings( - Your self-hosted absolute site URL including the protocol (e.g. - `https://app.infisical.com`) + Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) + + + The authentication object to use for the client. This is required unless you're using environment variables. + +### 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** +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. +- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. + +**Using the SDK directly** +```python3 +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + universal_auth=UniversalAuthMethod( + client_id="CLIENT_ID", + client_secret="CLIENT_SECRET", + ) + ) +)) +``` + +#### 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** +- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIDTokenAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + gcp_id_token=GCPIDTokenAuthMethod( + identity_id="MACHINE_IDENTITY_ID", + ) + ) +)) +``` + +#### GCP IAM Auth + +**Using 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** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIamAuthMethod + + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + gcp_iam=GCPIamAuthMethod( + identity_id="MACHINE_IDENTITY_ID", + service_account_key_file_path="./path/to/service_account_key.json" + ) + ) +)) +``` + +#### 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** +- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```py +from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, AWSIamAuthMethod + +client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + aws_iam=AWSIamAuthMethod(identity_id="MACHINE_IDENTITY_ID") + ) +)) +``` + +#### 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** +- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. + +**Using the SDK directly** +```python +from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, AzureAuthMethod + +kubernetes_client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + azure=AzureAuthMethod( + identity_id="YOUR_IDENTITY_ID", + ) + ) +)) +``` + + +#### 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** +- `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** +```python +from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, KubernetesAuthMethod + +kubernetes_client = InfisicalClient(ClientSettings( + auth=AuthenticationOptions( + kubernetes=KubernetesAuthMethod( + identity_id="YOUR_IDENTITY_ID", + service_account_token_path="/var/run/secrets/kubernetes.io/serviceaccount/token" # Optional + ) + ) +)) +``` + ### Caching To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cache_ttl" option when creating the client. @@ -133,6 +284,14 @@ Retrieve all secrets within the Infisical project and environment that client is Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. + + 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) + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) @@ -156,26 +315,26 @@ By default, `getSecret()` fetches and returns a shared secret. If not found, it #### Parameters - - - The key of the secret to retrieve - - - 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 secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - + + + The key of the secret to retrieve + + + 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 secret should be fetched from. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". + + + Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) + + ### client.createSecret(options) @@ -194,26 +353,26 @@ Create a new secret in Infisical. #### Parameters - - - The key of the secret to create. - - - The value of 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". - - + + + The key of the secret to create. + + + The value of 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.updateSecret(options) @@ -232,26 +391,26 @@ Update an existing secret in Infisical. #### Parameters - - - The key of the secret to update. - - - The new value of 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 updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - + + + The key of the secret to update. + + + The new value of 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 updated. + + + The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". + + ### client.deleteSecret(options) @@ -269,23 +428,23 @@ 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". - - + + + 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". + + ## Cryptography @@ -299,9 +458,11 @@ key = client.createSymmetricKey() ``` #### Returns (string) + `key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. ### Encrypt symmetric + ```py encryptOptions = EncryptSymmetricOptions( key=key, @@ -314,22 +475,22 @@ encryptedData = client.encryptSymmetric(encryptOptions) #### Parameters - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - + + + The plaintext you want to encrypt. + + + The symmetric key to use for encryption. + + #### Returns (object) -`tag` (string): A base64-encoded, 128-bit authentication tag. -`iv` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext` (string): A base64-encoded, encrypted ciphertext. + +`tag` (string): A base64-encoded, 128-bit authentication tag. `iv` (string): A base64-encoded, 96-bit initialization vector. `ciphertext` (string): A base64-encoded, encrypted ciphertext. ### Decrypt symmetric + ```py decryptOptions = DecryptSymmetricOptions( ciphertext=encryptedData.ciphertext, @@ -344,22 +505,24 @@ decryptedString = client.decryptSymmetric(decryptOptions) ``` #### Parameters + - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - + + + The ciphertext you want to decrypt. + + + The symmetric key to use for encryption. + + + The initialization vector to use for decryption. + + + The authentication tag to use for decryption. + + #### Returns (string) + `plaintext` (string): The decrypted plaintext. diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 5233ae910..754409dfe 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -25,6 +25,10 @@ Used to configure platform-specific security and operational settings https://app.infisical.com). + + Telemetry helps us improve Infisical but if you want to dsiable it you may set this to `false`. + + ## Data Layer The platform utilizes Postgres to persist all of its data and Redis for caching and backgroud tasks @@ -48,44 +52,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 +109,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 +131,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 +161,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 +188,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 +229,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 +252,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 +275,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 +291,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 ``` @@ -318,6 +314,12 @@ SMTP_FROM_NAME=Infisical By default, users can only login via email/password based login method. To login into Infisical with OAuth providers such as Google, configure the associated variables. + + +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. + + + Follow detailed guide to configure [Google SSO](/documentation/platform/sso/google) @@ -369,11 +371,6 @@ To login into Infisical with OAuth providers such as Google, configure the assoc information. - - Configure SAML organization slug to automatically redirect all users of your - Infisical instance to the identity provider. - - ## Native secret integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. diff --git a/docs/self-hosting/deployment-options/kubernetes-helm.mdx b/docs/self-hosting/deployment-options/kubernetes-helm.mdx index 0ce979d06..8ba940fee 100644 --- a/docs/self-hosting/deployment-options/kubernetes-helm.mdx +++ b/docs/self-hosting/deployment-options/kubernetes-helm.mdx @@ -33,7 +33,7 @@ description: "Learn how to use Helm chart to install Infisical on your Kubernete pullPolicy: IfNotPresent ``` - Do you not use the latest docker image tag in production deployments as they can introduce unexpected changes + Do not use the latest docker image tag in production deployments as they can introduce unexpected changes diff --git a/docs/style.css b/docs/style.css index b76d06450..3359151e4 100644 --- a/docs/style.css +++ b/docs/style.css @@ -1,7 +1,7 @@ #navbar .max-w-8xl { max-width: 100%; border-bottom: 1px solid #ebebeb; - background-color: #fcfcfc; + background-color: #F4F3EF; } .max-w-8xl { @@ -14,7 +14,7 @@ padding-right: 30px; border-right: 1px; border-color: #cdd64b; - background-color: #fcfcfc; + background-color: #F4F3EF; border-right: 1px solid #ebebeb; } @@ -27,6 +27,13 @@ padding: 5px; } +#sidebar li > a.text-primary { + border-radius: 0; + background-color: #FBFFCC; + border-left: 4px solid #EFFF33; + padding: 5px; +} + #sidebar li > a.mt-2 { border-radius: 0; padding: 5px; @@ -49,10 +56,10 @@ } */ #header { - border-left: 1px solid #26272b; + border-left: 4px solid #EFFF33; padding-left: 16px; padding-right: 16px; - background-color: #f5f5f5; + background-color: #FDFFE5; padding-bottom: 10px; padding-top: 10px; } @@ -60,9 +67,17 @@ #content-area .mt-8 .block{ border-radius: 0; border-width: 1px; + background-color: #FCFBFA; border-color: #ebebeb; } +/* #content-area:hover .mt-8 .block:hover{ + border-radius: 0; + border-width: 1px; + background-color: #FDFFE5; + border-color: #EFFF33; +} */ + #content-area .mt-8 .rounded-xl{ border-radius: 0; } 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..b79dd8815 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,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", @@ -2109,9 +2110,9 @@ } }, "node_modules/@babel/register": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.23.7.tgz", - "integrity": "sha512-EjJeB6+kvpk+Y5DAkEAmbOBEFkh9OASx0huoEkqYTFxAZHzOAX2Oh5uwAUuL2rUddqfM0SA+KPXV2TbzoZ2kvQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/@babel/register/-/register-7.24.6.tgz", + "integrity": "sha512-WSuFCc2wCqMeXkz/i3yfAAsxwWflEgbVkZzivgAmXl/MxrXeoYFZOOPllbC8R8WTF7u61wSRQtDVZ1879cdu6w==", "dev": true, "dependencies": { "clone-deep": "^4.0.1", @@ -3200,6 +3201,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", @@ -6181,15 +6200,15 @@ } }, "node_modules/@storybook/builder-manager": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/builder-manager/-/builder-manager-7.6.8.tgz", - "integrity": "sha512-4CZo1RHPlDJA7G+lJoVdi+/3/L1ERxVxtvwuGgk8CxVDt6vFNpoc7fEGryNv3GRzKN1/luNYNU1MTnCUSn0B2g==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/builder-manager/-/builder-manager-7.6.19.tgz", + "integrity": "sha512-Dt5OLh97xeWh4h2mk9uG0SbCxBKHPhIiHLHAKEIDzIZBdwUhuyncVNDPHW2NlXM+S7U0/iKs2tw05waqh2lHvg==", "dev": true, "dependencies": { "@fal-works/esbuild-plugin-global-externals": "^2.1.2", - "@storybook/core-common": "7.6.8", - "@storybook/manager": "7.6.8", - "@storybook/node-logger": "7.6.8", + "@storybook/core-common": "7.6.19", + "@storybook/manager": "7.6.19", + "@storybook/node-logger": "7.6.19", "@types/ejs": "^3.1.1", "@types/find-cache-dir": "^3.2.1", "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.10", @@ -6208,6 +6227,111 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/channels": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.19.tgz", + "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", + "dev": true, + "dependencies": { + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/client-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.19.tgz", + "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/core-common": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-7.6.19.tgz", + "integrity": "sha512-njwpGzFJrfbJr/AFxGP8KMrfPfxN85KOfSlxYnQwRm5Z0H1D/lT33LhEBf5m37gaGawHeG7KryxO6RvaioMt2Q==", + "dev": true, + "dependencies": { + "@storybook/core-events": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/types": "7.6.19", + "@types/find-cache-dir": "^3.2.1", + "@types/node": "^18.0.0", + "@types/node-fetch": "^2.6.4", + "@types/pretty-hrtime": "^1.0.0", + "chalk": "^4.1.0", + "esbuild": "^0.18.0", + "esbuild-register": "^3.5.0", + "file-system-cache": "2.3.0", + "find-cache-dir": "^3.0.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "glob": "^10.0.0", + "handlebars": "^4.7.7", + "lazy-universal-dotenv": "^4.0.0", + "node-fetch": "^2.0.0", + "picomatch": "^2.3.0", + "pkg-dir": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/core-events": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.19.tgz", + "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/node-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-7.6.19.tgz", + "integrity": "sha512-2g29QC44Zl1jKY37DmQ0/dO7+VSKnGgPI/x0mwVwQffypSapxH3rwLLT5Q5XLHeFyD+fhRu5w9Cj4vTGynJgpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/builder-manager/node_modules/@storybook/types": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.19.tgz", + "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/builder-webpack5": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/builder-webpack5/-/builder-webpack5-7.6.8.tgz", @@ -6314,23 +6438,23 @@ } }, "node_modules/@storybook/cli": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-7.6.8.tgz", - "integrity": "sha512-Is8nkgsbIOu+Jk9Z7x5sgMPgGs9RTVDum3cz9eA4UspPiIBJsf7nGHAWOtc+mCIm6Z3eeNbT1YMOWxz9EuqboA==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/cli/-/cli-7.6.19.tgz", + "integrity": "sha512-7OVy7nPgkLfgivv6/dmvoyU6pKl9EzWFk+g9izyQHiM/jS8jOiEyn6akG8Ebj6k5pWslo5lgiXUSW+cEEZUnqQ==", "dev": true, "dependencies": { "@babel/core": "^7.23.2", "@babel/preset-env": "^7.23.2", "@babel/types": "^7.23.0", "@ndelangen/get-tarball": "^3.0.7", - "@storybook/codemod": "7.6.8", - "@storybook/core-common": "7.6.8", - "@storybook/core-events": "7.6.8", - "@storybook/core-server": "7.6.8", - "@storybook/csf-tools": "7.6.8", - "@storybook/node-logger": "7.6.8", - "@storybook/telemetry": "7.6.8", - "@storybook/types": "7.6.8", + "@storybook/codemod": "7.6.19", + "@storybook/core-common": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/core-server": "7.6.19", + "@storybook/csf-tools": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/telemetry": "7.6.19", + "@storybook/types": "7.6.19", "@types/semver": "^7.3.4", "@yarnpkg/fslib": "2.10.3", "@yarnpkg/libzip": "2.3.0", @@ -6355,7 +6479,6 @@ "puppeteer-core": "^2.1.1", "read-pkg-up": "^7.0.1", "semver": "^7.3.7", - "simple-update-notifier": "^2.0.0", "strip-json-comments": "^3.0.1", "tempy": "^1.0.1", "ts-dedent": "^2.0.0", @@ -6370,6 +6493,132 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/cli/node_modules/@storybook/channels": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.19.tgz", + "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", + "dev": true, + "dependencies": { + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/client-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.19.tgz", + "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/core-common": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-7.6.19.tgz", + "integrity": "sha512-njwpGzFJrfbJr/AFxGP8KMrfPfxN85KOfSlxYnQwRm5Z0H1D/lT33LhEBf5m37gaGawHeG7KryxO6RvaioMt2Q==", + "dev": true, + "dependencies": { + "@storybook/core-events": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/types": "7.6.19", + "@types/find-cache-dir": "^3.2.1", + "@types/node": "^18.0.0", + "@types/node-fetch": "^2.6.4", + "@types/pretty-hrtime": "^1.0.0", + "chalk": "^4.1.0", + "esbuild": "^0.18.0", + "esbuild-register": "^3.5.0", + "file-system-cache": "2.3.0", + "find-cache-dir": "^3.0.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "glob": "^10.0.0", + "handlebars": "^4.7.7", + "lazy-universal-dotenv": "^4.0.0", + "node-fetch": "^2.0.0", + "picomatch": "^2.3.0", + "pkg-dir": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/core-events": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.19.tgz", + "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/csf-tools": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-7.6.19.tgz", + "integrity": "sha512-8Vzia3cHhDdGHuS3XKXJReCRxmfRq3vmTm/Te9yKZnPSAsC58CCKcMh8FNEFJ44vxYF9itKTkRutjGs+DprKLQ==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "@storybook/csf": "^0.1.2", + "@storybook/types": "7.6.19", + "fs-extra": "^11.1.0", + "recast": "^0.23.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/node-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-7.6.19.tgz", + "integrity": "sha512-2g29QC44Zl1jKY37DmQ0/dO7+VSKnGgPI/x0mwVwQffypSapxH3rwLLT5Q5XLHeFyD+fhRu5w9Cj4vTGynJgpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/cli/node_modules/@storybook/types": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.19.tgz", + "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/cli/node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6414,26 +6663,11 @@ "node": ">=10.17.0" } }, - "node_modules/@storybook/cli/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@storybook/cli/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -6441,12 +6675,6 @@ "node": ">=10" } }, - "node_modules/@storybook/cli/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@storybook/client-api": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/client-api/-/client-api-7.6.8.tgz", @@ -6475,18 +6703,18 @@ } }, "node_modules/@storybook/codemod": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-7.6.8.tgz", - "integrity": "sha512-3Gk+ZsD35DUgqbbRNdX547kzZK/ajIbgwynmR0FuPhZhhZuYI4+2eMNzdmI/Oe9Nov4R16senQuAZjw/Dc5LrA==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/codemod/-/codemod-7.6.19.tgz", + "integrity": "sha512-bmHE0iEEgWZ65dXCmasd+GreChjPiWkXu2FEa0cJmNz/PqY12GsXGls4ke1TkNTj4gdSZnbtJxbclPZZnib2tQ==", "dev": true, "dependencies": { "@babel/core": "^7.23.2", "@babel/preset-env": "^7.23.2", "@babel/types": "^7.23.0", "@storybook/csf": "^0.1.2", - "@storybook/csf-tools": "7.6.8", - "@storybook/node-logger": "7.6.8", - "@storybook/types": "7.6.8", + "@storybook/csf-tools": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/types": "7.6.19", "@types/cross-spawn": "^6.0.2", "cross-spawn": "^7.0.3", "globby": "^11.0.2", @@ -6500,6 +6728,97 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/codemod/node_modules/@storybook/channels": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.19.tgz", + "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", + "dev": true, + "dependencies": { + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/codemod/node_modules/@storybook/client-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.19.tgz", + "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/codemod/node_modules/@storybook/core-events": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.19.tgz", + "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/codemod/node_modules/@storybook/csf-tools": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-7.6.19.tgz", + "integrity": "sha512-8Vzia3cHhDdGHuS3XKXJReCRxmfRq3vmTm/Te9yKZnPSAsC58CCKcMh8FNEFJ44vxYF9itKTkRutjGs+DprKLQ==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "@storybook/csf": "^0.1.2", + "@storybook/types": "7.6.19", + "fs-extra": "^11.1.0", + "recast": "^0.23.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/codemod/node_modules/@storybook/node-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-7.6.19.tgz", + "integrity": "sha512-2g29QC44Zl1jKY37DmQ0/dO7+VSKnGgPI/x0mwVwQffypSapxH3rwLLT5Q5XLHeFyD+fhRu5w9Cj4vTGynJgpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/codemod/node_modules/@storybook/types": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.19.tgz", + "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/components": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/components/-/components-7.6.8.tgz", @@ -6744,26 +7063,26 @@ } }, "node_modules/@storybook/core-server": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-7.6.8.tgz", - "integrity": "sha512-/csAFNuAhF11f6D9neYNavmKPFK/ZxTskaktc4iDwBRgBM95kZ6DBFjg9ErRi5Q8Z/i92wk6qORkq4bkN/lI9w==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-server/-/core-server-7.6.19.tgz", + "integrity": "sha512-7mKL73Wv5R2bEl0kJ6QJ9bOu5YY53Idu24QgvTnUdNsQazp2yUONBNwHIrNDnNEXm8SfCi4Mc9o0mmNRMIoiRA==", "dev": true, "dependencies": { "@aw-web-design/x-default-browser": "1.4.126", "@discoveryjs/json-ext": "^0.5.3", - "@storybook/builder-manager": "7.6.8", - "@storybook/channels": "7.6.8", - "@storybook/core-common": "7.6.8", - "@storybook/core-events": "7.6.8", + "@storybook/builder-manager": "7.6.19", + "@storybook/channels": "7.6.19", + "@storybook/core-common": "7.6.19", + "@storybook/core-events": "7.6.19", "@storybook/csf": "^0.1.2", - "@storybook/csf-tools": "7.6.8", + "@storybook/csf-tools": "7.6.19", "@storybook/docs-mdx": "^0.1.0", "@storybook/global": "^5.0.0", - "@storybook/manager": "7.6.8", - "@storybook/node-logger": "7.6.8", - "@storybook/preview-api": "7.6.8", - "@storybook/telemetry": "7.6.8", - "@storybook/types": "7.6.8", + "@storybook/manager": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/preview-api": "7.6.19", + "@storybook/telemetry": "7.6.19", + "@storybook/types": "7.6.19", "@types/detect-port": "^1.3.0", "@types/node": "^18.0.0", "@types/pretty-hrtime": "^1.0.0", @@ -6776,7 +7095,7 @@ "express": "^4.17.3", "fs-extra": "^11.1.0", "globby": "^11.0.2", - "ip": "^2.0.0", + "ip": "^2.0.1", "lodash": "^4.17.21", "open": "^8.4.0", "pretty-hrtime": "^1.0.3", @@ -6796,26 +7115,163 @@ "url": "https://opencollective.com/storybook" } }, - "node_modules/@storybook/core-server/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/@storybook/core-server/node_modules/@storybook/channels": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.19.tgz", + "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", "dev": true, "dependencies": { - "yallist": "^4.0.0" + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" }, - "engines": { - "node": ">=10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/client-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.19.tgz", + "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/core-common": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-7.6.19.tgz", + "integrity": "sha512-njwpGzFJrfbJr/AFxGP8KMrfPfxN85KOfSlxYnQwRm5Z0H1D/lT33LhEBf5m37gaGawHeG7KryxO6RvaioMt2Q==", + "dev": true, + "dependencies": { + "@storybook/core-events": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/types": "7.6.19", + "@types/find-cache-dir": "^3.2.1", + "@types/node": "^18.0.0", + "@types/node-fetch": "^2.6.4", + "@types/pretty-hrtime": "^1.0.0", + "chalk": "^4.1.0", + "esbuild": "^0.18.0", + "esbuild-register": "^3.5.0", + "file-system-cache": "2.3.0", + "find-cache-dir": "^3.0.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "glob": "^10.0.0", + "handlebars": "^4.7.7", + "lazy-universal-dotenv": "^4.0.0", + "node-fetch": "^2.0.0", + "picomatch": "^2.3.0", + "pkg-dir": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/core-events": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.19.tgz", + "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/csf-tools": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-7.6.19.tgz", + "integrity": "sha512-8Vzia3cHhDdGHuS3XKXJReCRxmfRq3vmTm/Te9yKZnPSAsC58CCKcMh8FNEFJ44vxYF9itKTkRutjGs+DprKLQ==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "@storybook/csf": "^0.1.2", + "@storybook/types": "7.6.19", + "fs-extra": "^11.1.0", + "recast": "^0.23.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/node-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-7.6.19.tgz", + "integrity": "sha512-2g29QC44Zl1jKY37DmQ0/dO7+VSKnGgPI/x0mwVwQffypSapxH3rwLLT5Q5XLHeFyD+fhRu5w9Cj4vTGynJgpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/preview-api": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-7.6.19.tgz", + "integrity": "sha512-04hdMSQucroJT4dBjQzRd7ZwH2hij8yx2nm5qd4HYGkd1ORkvlH6GOLph4XewNJl5Um3xfzFQzBhvkqvG0WaCQ==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/csf": "^0.1.2", + "@storybook/global": "^5.0.0", + "@storybook/types": "7.6.19", + "@types/qs": "^6.9.5", + "dequal": "^2.0.2", + "lodash": "^4.17.21", + "memoizerific": "^1.11.3", + "qs": "^6.10.0", + "synchronous-promise": "^2.0.15", + "ts-dedent": "^2.0.0", + "util-deprecate": "^1.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/core-server/node_modules/@storybook/types": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.19.tgz", + "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" } }, "node_modules/@storybook/core-server/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -6823,12 +7279,6 @@ "node": ">=10" } }, - "node_modules/@storybook/core-server/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/@storybook/core-webpack": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/core-webpack/-/core-webpack-7.6.8.tgz", @@ -6922,9 +7372,9 @@ "dev": true }, "node_modules/@storybook/manager": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/manager/-/manager-7.6.8.tgz", - "integrity": "sha512-INoXXoHXyw9PPMJAOAhwf9u2GNDDNdv1JAI1fhrbCAECzDabHT9lRVUo6v8I5XMc+YdMHLM1Vz38DbB+w18hFw==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/manager/-/manager-7.6.19.tgz", + "integrity": "sha512-fZWQcf59x4P0iiBhrL74PZrqKJAPuk9sWjP8BIkGbf8wTZtUunbY5Sv4225fOL4NLJbuX9/RYLUPoxQ3nucGHA==", "dev": true, "funding": { "type": "opencollective", @@ -7360,14 +7810,14 @@ } }, "node_modules/@storybook/telemetry": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-7.6.8.tgz", - "integrity": "sha512-hHUS3fyHjKR3ZdbG+/OVI+pwXXKOmS8L8GMuWKlpUovvCYBLm0/Q0MUQ9XaLuByOCzvAurqB3Owp3ZV7GiY30Q==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/telemetry/-/telemetry-7.6.19.tgz", + "integrity": "sha512-rA5xum4I36M57iiD3uzmW0MOdpl0vEpHWBSAa5hK0a0ALPeY9TgAsQlI/0dSyNYJ/K7aczEEN6d4qm1NC4u10A==", "dev": true, "dependencies": { - "@storybook/client-logger": "7.6.8", - "@storybook/core-common": "7.6.8", - "@storybook/csf-tools": "7.6.8", + "@storybook/client-logger": "7.6.19", + "@storybook/core-common": "7.6.19", + "@storybook/csf-tools": "7.6.19", "chalk": "^4.1.0", "detect-package-manager": "^2.0.1", "fetch-retry": "^5.0.2", @@ -7379,6 +7829,132 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@storybook/telemetry/node_modules/@storybook/channels": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-7.6.19.tgz", + "integrity": "sha512-2JGh+i95GwjtjqWqhtEh15jM5ifwbRGmXeFqkY7dpdHH50EEWafYHr2mg3opK3heVDwg0rJ/VBptkmshloXuvA==", + "dev": true, + "dependencies": { + "@storybook/client-logger": "7.6.19", + "@storybook/core-events": "7.6.19", + "@storybook/global": "^5.0.0", + "qs": "^6.10.0", + "telejson": "^7.2.0", + "tiny-invariant": "^1.3.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/client-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-7.6.19.tgz", + "integrity": "sha512-oGzOxbmLmciSIfd5gsxDzPmX8DttWhoYdPKxjMuCuWLTO2TWpkCWp1FTUMWO72mm/6V/FswT/aqpJJBBvdZ3RQ==", + "dev": true, + "dependencies": { + "@storybook/global": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/core-common": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-common/-/core-common-7.6.19.tgz", + "integrity": "sha512-njwpGzFJrfbJr/AFxGP8KMrfPfxN85KOfSlxYnQwRm5Z0H1D/lT33LhEBf5m37gaGawHeG7KryxO6RvaioMt2Q==", + "dev": true, + "dependencies": { + "@storybook/core-events": "7.6.19", + "@storybook/node-logger": "7.6.19", + "@storybook/types": "7.6.19", + "@types/find-cache-dir": "^3.2.1", + "@types/node": "^18.0.0", + "@types/node-fetch": "^2.6.4", + "@types/pretty-hrtime": "^1.0.0", + "chalk": "^4.1.0", + "esbuild": "^0.18.0", + "esbuild-register": "^3.5.0", + "file-system-cache": "2.3.0", + "find-cache-dir": "^3.0.0", + "find-up": "^5.0.0", + "fs-extra": "^11.1.0", + "glob": "^10.0.0", + "handlebars": "^4.7.7", + "lazy-universal-dotenv": "^4.0.0", + "node-fetch": "^2.0.0", + "picomatch": "^2.3.0", + "pkg-dir": "^5.0.0", + "pretty-hrtime": "^1.0.3", + "resolve-from": "^5.0.0", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/core-events": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-7.6.19.tgz", + "integrity": "sha512-K/W6Uvum0ocZSgjbi8hiotpe+wDEHDZlvN+KlPqdh9ae9xDK8aBNBq9IelCoqM+uKO1Zj+dDfSQds7CD781DJg==", + "dev": true, + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/csf-tools": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/csf-tools/-/csf-tools-7.6.19.tgz", + "integrity": "sha512-8Vzia3cHhDdGHuS3XKXJReCRxmfRq3vmTm/Te9yKZnPSAsC58CCKcMh8FNEFJ44vxYF9itKTkRutjGs+DprKLQ==", + "dev": true, + "dependencies": { + "@babel/generator": "^7.23.0", + "@babel/parser": "^7.23.0", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "@storybook/csf": "^0.1.2", + "@storybook/types": "7.6.19", + "fs-extra": "^11.1.0", + "recast": "^0.23.1", + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/node-logger": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/node-logger/-/node-logger-7.6.19.tgz", + "integrity": "sha512-2g29QC44Zl1jKY37DmQ0/dO7+VSKnGgPI/x0mwVwQffypSapxH3rwLLT5Q5XLHeFyD+fhRu5w9Cj4vTGynJgpA==", + "dev": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, + "node_modules/@storybook/telemetry/node_modules/@storybook/types": { + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/@storybook/types/-/types-7.6.19.tgz", + "integrity": "sha512-DeGYrRPRMGTVfT7o2rEZtRzyLT2yKTI2exgpnxbwPWEFAduZCSfzBrcBXZ/nb5B0pjA9tUNWls1YzGkJGlkhpg==", + "dev": true, + "dependencies": { + "@storybook/channels": "7.6.19", + "@types/babel__core": "^7.0.0", + "@types/express": "^4.7.0", + "file-system-cache": "2.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + } + }, "node_modules/@storybook/testing-library": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@storybook/testing-library/-/testing-library-0.2.2.tgz", @@ -7879,9 +8455,9 @@ "dev": true }, "node_modules/@types/emscripten": { - "version": "1.39.10", - "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.10.tgz", - "integrity": "sha512-TB/6hBkYQJxsZHSqyeuO1Jt0AB/bW6G7rHt9g7lML7SOF6lbgcHvw/Lr+69iqN0qxgXLhWKScAon73JNnptuDw==", + "version": "1.39.13", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.39.13.tgz", + "integrity": "sha512-cFq+fO/isvhvmuP/+Sl4K4jtU6E23DoivtbO4r50e3odaxAiVdbfSYRDdJ4gCdxx+3aRjhphS5ZMwIH4hFy/Cw==", "dev": true }, "node_modules/@types/escodegen": { @@ -10048,12 +10624,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -10513,9 +11089,9 @@ } }, "node_modules/citty": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.5.tgz", - "integrity": "sha512-AS7n5NSc0OQVMV9v6wt3ByujNIrne0/cTjiC2MYqhvao57VNfiuVksTSr2p17nVOhEr2KtqiAkGwHcgMC/qUuQ==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", "dev": true, "dependencies": { "consola": "^3.2.3" @@ -11844,9 +12420,9 @@ } }, "node_modules/detect-port": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.5.1.tgz", - "integrity": "sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/detect-port/-/detect-port-1.6.1.tgz", + "integrity": "sha512-CmnVc+Hek2egPx1PeTFVta2W78xy2K/9Rkf6cC4T59S50tVnzKj+tnx5mmx5lwvCkujZ4uRrpRSuV+IVs3f90Q==", "dev": true, "dependencies": { "address": "^1.0.1", @@ -11855,6 +12431,9 @@ "bin": { "detect": "bin/detect-port.js", "detect-port": "bin/detect-port.js" + }, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/detective": { @@ -12288,9 +12867,9 @@ } }, "node_modules/envinfo": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", - "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.13.0.tgz", + "integrity": "sha512-cvcaMr7KqXVh4nyzGTVqTum+gAiL265x5jUWQIDLq//zOGbW+gSW/C+OWLleY/rs9Qole6AZLMXPbtIFQbqu+Q==", "dev": true, "bin": { "envinfo": "dist/cli.js" @@ -13634,9 +14213,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -13810,9 +14389,9 @@ "dev": true }, "node_modules/flow-parser": { - "version": "0.226.0", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.226.0.tgz", - "integrity": "sha512-YlH+Y/P/5s0S7Vg14RwXlJMF/JsGfkG7gcKB/zljyoqaPNX9YVsGzx+g6MLTbhZaWbPhs4347aTpmSb9GgiPtw==", + "version": "0.237.2", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.237.2.tgz", + "integrity": "sha512-mvI/kdfr3l1waaPbThPA8dJa77nHXrfZIun+SWvFwSwDjmeByU7mGJGRmv1+7guU6ccyLV8e1lqZA1lD4iMGnQ==", "dev": true, "engines": { "node": ">=0.4.0" @@ -14269,18 +14848,18 @@ } }, "node_modules/giget": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.1.tgz", - "integrity": "sha512-4VG22mopWtIeHwogGSy1FViXVo0YT+m6BrqZfz0JJFwbSsePsCdOzdLIIli5BtMp7Xe8f/o2OmBpQX2NBOC24g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/giget/-/giget-1.2.3.tgz", + "integrity": "sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==", "dev": true, "dependencies": { - "citty": "^0.1.5", + "citty": "^0.1.6", "consola": "^3.2.3", - "defu": "^6.1.3", - "node-fetch-native": "^1.6.1", - "nypm": "^0.3.3", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.3", + "nypm": "^0.3.8", "ohash": "^1.1.3", - "pathe": "^1.1.1", + "pathe": "^1.1.2", "tar": "^6.2.0" }, "bin": { @@ -15838,9 +16417,9 @@ } }, "node_modules/jake": { - "version": "10.8.7", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", - "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", + "integrity": "sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w==", "dev": true, "dependencies": { "async": "^3.2.3", @@ -16006,9 +16585,9 @@ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" }, "node_modules/jscodeshift": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.15.1.tgz", - "integrity": "sha512-hIJfxUy8Rt4HkJn/zZPU9ChKfKZM1342waJ1QC2e2YsPcWhM+3BJ4dcfQCzArTrk1jJeNLB341H+qOcEHRxJZg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/jscodeshift/-/jscodeshift-0.15.2.tgz", + "integrity": "sha512-FquR7Okgmc4Sd0aEDwqho3rEiKR3BdvuG9jfdHjLJ6JQoWSMpavug3AoIfnfWhxFlf+5pzQh8qjqz0DWFrNQzA==", "dev": true, "dependencies": { "@babel/core": "^7.23.0", @@ -17879,9 +18458,9 @@ } }, "node_modules/node-fetch-native": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.1.tgz", - "integrity": "sha512-bW9T/uJDPAJB2YNYEpWzE54U5O3MQidXsOyTfnbKYtTtFexRvGzb1waphBN4ZwP6EcIvYYEOwW0b72BpAqydTw==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.4.tgz", + "integrity": "sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==", "dev": true }, "node_modules/node-int64": { @@ -18043,15 +18622,16 @@ } }, "node_modules/nypm": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.3.4.tgz", - "integrity": "sha512-1JLkp/zHBrkS3pZ692IqOaIKSYHmQXgqfELk6YTOfVBnwealAmPA1q2kKK7PHJAHSMBozerThEFZXP3G6o7Ukg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.3.8.tgz", + "integrity": "sha512-IGWlC6So2xv6V4cIDmoV0SwwWx7zLG086gyqkyumteH2fIgCAM4nDVFB2iDRszDvmdSVW9xb1N+2KjQ6C7d4og==", "dev": true, "dependencies": { - "citty": "^0.1.5", + "citty": "^0.1.6", + "consola": "^3.2.3", "execa": "^8.0.1", - "pathe": "^1.1.1", - "ufo": "^1.3.2" + "pathe": "^1.1.2", + "ufo": "^1.4.0" }, "bin": { "nypm": "dist/cli.mjs" @@ -18129,9 +18709,9 @@ } }, "node_modules/nypm/node_modules/npm-run-path": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", - "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", "dev": true, "dependencies": { "path-key": "^4.0.0" @@ -19625,6 +20205,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -19675,6 +20256,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -21635,51 +22217,6 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-update-notifier/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21770,9 +22307,9 @@ } }, "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", "dev": true }, "node_modules/spdx-expression-parse": { @@ -21786,9 +22323,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.16", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.16.tgz", - "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", + "version": "3.0.18", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", + "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", "dev": true }, "node_modules/split-on-first": { @@ -21884,12 +22421,12 @@ "dev": true }, "node_modules/storybook": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-7.6.8.tgz", - "integrity": "sha512-ugRtDSs2eTgHMOZ3wKXbUEbPnlJ2XImPbnvxNssK14py2mHKwPnhSqLNrjlQMkmkO13GdjalLDyj4lZtoYdo0Q==", + "version": "7.6.19", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-7.6.19.tgz", + "integrity": "sha512-xWD1C4vD/4KMffCrBBrUpsLUO/9uNpm8BVW8+Vcb30gkQDfficZ0oziWkmLexpT53VSioa24iazGXMwBqllYjQ==", "dev": true, "dependencies": { - "@storybook/cli": "7.6.8" + "@storybook/cli": "7.6.19" }, "bin": { "sb": "index.js", @@ -21979,9 +22516,9 @@ } }, "node_modules/stream-shift": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.2.tgz", - "integrity": "sha512-rV4Bovi9xx0BFzOb/X0B2GqoIjvqPCttZdu0Wgtx2Dxkj7ETyWl9gmqJ4EutWRLvtZWm8dxE+InQZX1IryZn/w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", "dev": true }, "node_modules/streamx": { @@ -22530,6 +23067,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -22550,6 +23088,7 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -23143,9 +23682,9 @@ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/ufo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.3.2.tgz", - "integrity": "sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==", + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", "dev": true }, "node_modules/uglify-js": { @@ -24123,9 +24662,9 @@ } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", + "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", "dev": true, "engines": { "node": ">=10.0.0" 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/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 451890ef9..cf90ef659 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -32,7 +32,8 @@ const integrationSlugNameMapping: Mapping = { northflank: "Northflank", windmill: "Windmill", "gcp-secret-manager": "GCP Secret Manager", - "hasura-cloud": "Hasura Cloud" + "hasura-cloud": "Hasura Cloud", + rundeck: "Rundeck" }; const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Rundeck.svg b/frontend/public/images/integrations/Rundeck.svg new file mode 100644 index 000000000..4ded97a8a --- /dev/null +++ b/frontend/public/images/integrations/Rundeck.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/images/loading/loading.gif b/frontend/public/images/loading/loading.gif index f15b1b1ce..9cf0f6b6a 100644 Binary files a/frontend/public/images/loading/loading.gif and b/frontend/public/images/loading/loading.gif differ diff --git a/frontend/public/images/loading/loadingblack.gif b/frontend/public/images/loading/loadingblack.gif index 47aec0374..dd45a4824 100644 Binary files a/frontend/public/images/loading/loadingblack.gif and b/frontend/public/images/loading/loadingblack.gif differ diff --git a/frontend/scripts/initialize-standalone-build.sh b/frontend/scripts/initialize-standalone-build.sh index 859814eda..644877d8f 100755 --- a/frontend/scripts/initialize-standalone-build.sh +++ b/frontend/scripts/initialize-standalone-build.sh @@ -4,7 +4,7 @@ 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_SAML_ORG_SLUG" "$NEXT_PUBLIC_SAML_ORG_SLUG" +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" 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/signup/UserInfoStep.tsx b/frontend/src/components/signup/UserInfoStep.tsx index c98998e35..705b490bb 100644 --- a/frontend/src/components/signup/UserInfoStep.tsx +++ b/frontend/src/components/signup/UserInfoStep.tsx @@ -161,6 +161,7 @@ export default function UserInfoStep({ const response = await completeAccountSignup({ email, + password, firstName: name.split(" ")[0], lastName: name.split(" ").slice(1).join(" "), protectedKey, diff --git a/frontend/src/components/utilities/attemptChangePassword.ts b/frontend/src/components/utilities/attemptChangePassword.ts index 5a810334b..45281f2f2 100644 --- a/frontend/src/components/utilities/attemptChangePassword.ts +++ b/frontend/src/components/utilities/attemptChangePassword.ts @@ -72,6 +72,7 @@ const attemptChangePassword = ({ email, currentPassword, newPassword }: Params): }); await changePassword({ + password: newPassword, clientProof, protectedKey, protectedKeyIV, diff --git a/frontend/src/components/utilities/attemptCliLogin.ts b/frontend/src/components/utilities/attemptCliLogin.ts index e95f5bf88..6eba08d61 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) => { @@ -69,8 +71,10 @@ const attemptLogin = async ({ tag } = await login2({ email, + password, 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..120ae62ec 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,7 +60,9 @@ const attemptLogin = async ({ iv, tag } = await login2({ + captchaToken, email, + password, 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/LeaveProjectModal/LeaveProjectModal.tsx b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx new file mode 100644 index 000000000..563c33bef --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/LeaveProjectModal.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; + +import { useToggle } from "@app/hooks"; + +import { Button } from "../Button"; +import { FormControl } from "../FormControl"; +import { Input } from "../Input"; +import { Modal, ModalClose, ModalContent } from "../Modal"; + +type Props = { + deleteKey: string; + title: string; + onLeaveApproved: () => Promise; + onClose?: () => void; + onChange?: (isOpen: boolean) => void; + isOpen?: boolean; + subTitle?: string; + buttonText?: string; +}; + +export const LeaveProjectModal = ({ + isOpen, + onClose, + onChange, + deleteKey, + onLeaveApproved, + title, + subTitle, + buttonText = "Leave Project" +}: Props): JSX.Element => { + const [inputData, setInputData] = useState(""); + const [isLoading, setIsLoading] = useToggle(); + + useEffect(() => { + setInputData(""); + }, [isOpen]); + + const onDelete = async () => { + setIsLoading.on(); + try { + await onLeaveApproved(); + } catch { + setIsLoading.off(); + } finally { + setIsLoading.off(); + } + }; + + return ( + { + setInputData(""); + if (onChange) onChange(isOpenState); + }} + > + + + + + {" "} + + } + onClose={onClose} + > +
{ + evt.preventDefault(); + if (deleteKey === inputData) onDelete(); + }} + > + + Type {deleteKey} to leave the project + + } + className="mb-0" + > + setInputData(e.target.value)} + placeholder="Type to confirm..." + /> + +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/LeaveProjectModal/index.tsx b/frontend/src/components/v2/LeaveProjectModal/index.tsx new file mode 100644 index 000000000..5d33aa108 --- /dev/null +++ b/frontend/src/components/v2/LeaveProjectModal/index.tsx @@ -0,0 +1 @@ +export { LeaveProjectModal } from "./LeaveProjectModal"; 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/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 12a9094e0..29dba23c7 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -62,6 +62,7 @@ export const Select = forwardRef( ( outline-none transition-all hover:bg-mineshaft-500 data-[highlighted]:bg-mineshaft-700/80`, isSelected && "bg-primary", isDisabled && - "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", + "cursor-not-allowed text-gray-600 hover:bg-transparent hover:text-mineshaft-600", className )} ref={forwardedRef} diff --git a/frontend/src/const.ts b/frontend/src/const.ts index 68ba1c497..880d2f021 100644 --- a/frontend/src/const.ts +++ b/frontend/src/const.ts @@ -23,7 +23,9 @@ export const publicPaths = [ "/login/provider/success", // TODO: change "/login/provider/error", // TODO: change "/login/sso", - "/admin/signup" + "/admin/signup", + "/shared/secret/[id]", + "/share-secret" ]; export const languageMap = { 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/helpers/secret.ts b/frontend/src/helpers/secret.ts new file mode 100644 index 000000000..607fa4268 --- /dev/null +++ b/frontend/src/helpers/secret.ts @@ -0,0 +1,175 @@ +import path from "path"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { fetchProjectEncryptedSecrets } from "@app/hooks/api/secrets/queries"; + +const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/g; +export const interpolateSecrets = ({ + projectId, + secretEncKey +}: { + projectId: string; + secretEncKey: string; +}) => { + const fetchSecretsCrossEnv = () => { + const fetchCache: Record> = {}; + + return async (secRefEnv: string, secRefPath: string[], secRefKey: string) => { + const secRefPathUrl = path.join("/", ...secRefPath); + const uniqKey = `${secRefEnv}-${secRefPathUrl}`; + + if (fetchCache?.[uniqKey]) { + return fetchCache[uniqKey][secRefKey]; + } + + // get secrets by projectId, env, path + const encryptedSecrets = await fetchProjectEncryptedSecrets({ + workspaceId: projectId, + environment: secRefEnv, + secretPath: secRefPathUrl + }); + + const decryptedSec = encryptedSecrets.reduce>((prev, secret) => { + const secretKey = decryptSymmetric({ + ciphertext: secret.secretKeyCiphertext, + iv: secret.secretKeyIV, + tag: secret.secretKeyTag, + key: secretEncKey + }); + const secretValue = decryptSymmetric({ + ciphertext: secret.secretValueCiphertext, + iv: secret.secretValueIV, + tag: secret.secretValueTag, + key: secretEncKey + }); + + // eslint-disable-next-line + prev[secretKey] = secretValue; + return prev; + }, {}); + + fetchCache[uniqKey] = decryptedSec; + + return fetchCache[uniqKey][secRefKey]; + }; + }; + + const recursivelyExpandSecret = async ( + expandedSec: Record, + interpolatedSec: Record, + fetchCrossEnv: (env: string, secPath: string[], secKey: string) => Promise, + recursionChainBreaker: Record, + key: string + ) => { + if (expandedSec?.[key] !== undefined) { + return expandedSec[key]; + } + if (recursionChainBreaker?.[key]) { + return ""; + } + // eslint-disable-next-line + recursionChainBreaker[key] = true; + + let interpolatedValue = interpolatedSec[key]; + if (!interpolatedValue) { + // eslint-disable-next-line no-console + console.error(`Couldn't find referenced value - ${key}`); + return ""; + } + + const refs = interpolatedValue.match(INTERPOLATION_SYNTAX_REG); + if (refs) { + await Promise.all( + refs.map(async (interpolationSyntax) => { + const interpolationKey = interpolationSyntax.slice(2, interpolationSyntax.length - 1); + const entities = interpolationKey.trim().split("."); + + if (entities.length === 1) { + const val = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + fetchCrossEnv, + recursionChainBreaker, + interpolationKey + ); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + return; + } + + if (entities.length > 1) { + const secRefEnv = entities[0]; + const secRefPath = entities.slice(1, entities.length - 1); + const secRefKey = entities[entities.length - 1]; + + const val = await fetchCrossEnv(secRefEnv, secRefPath, secRefKey); + if (val) { + interpolatedValue = interpolatedValue.replaceAll(interpolationSyntax, val); + } + } + }) + ); + } + + // eslint-disable-next-line + expandedSec[key] = interpolatedValue; + return interpolatedValue; + }; + + // used to convert multi line ones to quotes ones with \n + const formatMultiValueEnv = (val?: string) => { + if (!val) return ""; + if (!val.match("\n")) return val; + return `"${val.replace(/\n/g, "\\n")}"`; + }; + + const expandSecrets = async ( + secrets: Record + ) => { + const expandedSec: Record = {}; + const interpolatedSec: Record = {}; + + const crossSecEnvFetch = fetchSecretsCrossEnv(); + + Object.keys(secrets).forEach((key) => { + if (secrets[key].value.match(INTERPOLATION_SYNTAX_REG)) { + interpolatedSec[key] = secrets[key].value; + } else { + expandedSec[key] = secrets[key].value; + } + }); + + await Promise.all( + Object.keys(secrets).map(async (key) => { + if (expandedSec?.[key]) { + // 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]); + return; + } + + // this is to avoid recursion loop. So the graph should be direct graph rather than cyclic + // so for any recursion building if there is an entity two times same key meaning it will be looped + const recursionChainBreaker: Record = {}; + const expandedVal = await recursivelyExpandSecret( + expandedSec, + interpolatedSec, + crossSecEnvFetch, + recursionChainBreaker, + key + ); + + // eslint-disable-next-line + secrets[key].value = secrets[key].skipMultilineEncoding + ? expandedVal + : formatMultiValueEnv(expandedVal); + }) + ); + + return secrets; + }; + return expandSecrets; +}; diff --git a/frontend/src/helpers/string.ts b/frontend/src/helpers/string.ts new file mode 100644 index 000000000..8f581a280 --- /dev/null +++ b/frontend/src/helpers/string.ts @@ -0,0 +1,5 @@ +export const removeTrailingSlash = (str: string) => { + if (str === "/") return str; + + return str.endsWith("/") ? str.slice(0, -1) : str; +}; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 6a42e6ed0..8d5f59ede 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -10,6 +10,7 @@ export type TServerConfig = { export type TCreateAdminUserDTO = { email: string; + password: string; firstName: string; lastName?: string; protectedKey: string; 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/index.tsx b/frontend/src/hooks/api/auth/index.tsx index 505f7b05f..66688cbdc 100644 --- a/frontend/src/hooks/api/auth/index.tsx +++ b/frontend/src/hooks/api/auth/index.tsx @@ -1,5 +1,6 @@ export { useGetAuthToken, + useOauthTokenExchange, useResetPassword, useSelectOrganization, useSendMfaToken, @@ -7,4 +8,5 @@ export { useSendVerificationEmail, useVerifyMfaToken, useVerifyPasswordResetCode, - useVerifySignupEmailVerificationCode} from "./queries"; + useVerifySignupEmailVerificationCode +} from "./queries"; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index 20209df71..fcec4f2ef 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -5,6 +5,7 @@ import { apiRequest } from "@app/config/request"; import { setAuthToken } from "@app/reactQuery"; import { organizationKeys } from "../organization/queries"; +import { workspaceKeys } from "../workspace/queries"; import { ChangePasswordDTO, CompleteAccountDTO, @@ -22,6 +23,7 @@ import { SendMfaTokenDTO, SRP1DTO, SRPR1Res, + TOauthTokenExchangeDTO, VerifyMfaTokenDTO, VerifyMfaTokenRes, VerifySignupInviteDTO @@ -78,7 +80,10 @@ export const useSelectOrganization = () => { return data; }, onSuccess: () => { - queryClient.invalidateQueries(organizationKeys.getUserOrganizations); + queryClient.invalidateQueries([ + organizationKeys.getUserOrganizations, + workspaceKeys.getAllUserWorkspace + ]); } }); }; @@ -88,6 +93,7 @@ export const useLogin2 = () => { mutationFn: async (details: { email: string; clientProof: string; + password: string; providerAuthToken?: string; }) => { return login2(details); @@ -95,6 +101,20 @@ export const useLogin2 = () => { }); }; +export const oauthTokenExchange = async (details: TOauthTokenExchangeDTO) => { + const { data } = await apiRequest.post("/api/v1/sso/token-exchange", details); + return data; +}; + +export const useOauthTokenExchange = () => { + // note: use after srp1 + return useMutation({ + mutationFn: async (details: TOauthTokenExchangeDTO) => { + return oauthTokenExchange(details); + } + }); +}; + export const srp1 = async (details: SRP1DTO) => { const { data } = await apiRequest.post("/api/v1/password/srp1", details); return data; diff --git a/frontend/src/hooks/api/auth/types.ts b/frontend/src/hooks/api/auth/types.ts index 41c324bff..1b4dff1f0 100644 --- a/frontend/src/hooks/api/auth/types.ts +++ b/frontend/src/hooks/api/auth/types.ts @@ -23,6 +23,11 @@ export type VerifyMfaTokenRes = { tag: string; }; +export type TOauthTokenExchangeDTO = { + providerAuthToken: string; + email: string; +}; + export type Login1DTO = { email: string; clientPublicKey: string; @@ -30,9 +35,11 @@ export type Login1DTO = { }; export type Login2DTO = { + captchaToken?: string; email: string; clientProof: string; providerAuthToken?: string; + password: string; }; export type Login1Res = { @@ -85,6 +92,7 @@ export type CompleteAccountDTO = { encryptedPrivateKeyTag: string; salt: string; verifier: string; + password: string; }; export type CompleteAccountSignupDTO = CompleteAccountDTO & { @@ -100,6 +108,7 @@ export type VerifySignupInviteDTO = { }; export type ChangePasswordDTO = { + password: string; clientProof: string; protectedKey: string; protectedKeyIV: 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/identities/constants.tsx b/frontend/src/hooks/api/identities/constants.tsx index 8ae22b30c..51495d4f2 100644 --- a/frontend/src/hooks/api/identities/constants.tsx +++ b/frontend/src/hooks/api/identities/constants.tsx @@ -4,5 +4,6 @@ export const identityAuthToNameMap: { [I in IdentityAuthMethod]: string } = { [IdentityAuthMethod.UNIVERSAL_AUTH]: "Universal Auth", [IdentityAuthMethod.KUBERNETES_AUTH]: "Kubernetes Auth", [IdentityAuthMethod.GCP_AUTH]: "GCP Auth", - [IdentityAuthMethod.AWS_AUTH]: "AWS Auth" + [IdentityAuthMethod.AWS_AUTH]: "AWS Auth", + [IdentityAuthMethod.AZURE_AUTH]: "Azure Auth" }; diff --git a/frontend/src/hooks/api/identities/enums.tsx b/frontend/src/hooks/api/identities/enums.tsx index dc9d48cbd..66af91093 100644 --- a/frontend/src/hooks/api/identities/enums.tsx +++ b/frontend/src/hooks/api/identities/enums.tsx @@ -2,5 +2,6 @@ export enum IdentityAuthMethod { UNIVERSAL_AUTH = "universal-auth", KUBERNETES_AUTH = "kubernetes-auth", GCP_AUTH = "gcp-auth", - AWS_AUTH = "aws-auth" + AWS_AUTH = "aws-auth", + AZURE_AUTH = "azure-auth" } diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index be640572b..41b03669b 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -2,6 +2,7 @@ export { identityAuthToNameMap } from "./constants"; export { IdentityAuthMethod } from "./enums"; export { useAddIdentityAwsAuth, + useAddIdentityAzureAuth, useAddIdentityGcpAuth, useAddIdentityKubernetesAuth, useAddIdentityUniversalAuth, @@ -11,11 +12,14 @@ export { useRevokeIdentityUniversalAuthClientSecret, useUpdateIdentity, useUpdateIdentityAwsAuth, + useUpdateIdentityAzureAuth, useUpdateIdentityGcpAuth, useUpdateIdentityKubernetesAuth, - useUpdateIdentityUniversalAuth} from "./mutations"; + useUpdateIdentityUniversalAuth +} from "./mutations"; export { useGetIdentityAwsAuth, + useGetIdentityAzureAuth, useGetIdentityGcpAuth, useGetIdentityKubernetesAuth, useGetIdentityUniversalAuth, diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index d6c93044a..cb1fe4c17 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -6,6 +6,7 @@ import { organizationKeys } from "../organization/queries"; import { identitiesKeys } from "./queries"; import { AddIdentityAwsAuthDTO, + AddIdentityAzureAuthDTO, AddIdentityGcpAuthDTO, AddIdentityKubernetesAuthDTO, AddIdentityUniversalAuthDTO, @@ -17,14 +18,17 @@ import { DeleteIdentityUniversalAuthClientSecretDTO, Identity, IdentityAwsAuth, + IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, IdentityUniversalAuth, UpdateIdentityAwsAuthDTO, + UpdateIdentityAzureAuthDTO, UpdateIdentityDTO, UpdateIdentityGcpAuthDTO, UpdateIdentityKubernetesAuthDTO, - UpdateIdentityUniversalAuthDTO} from "./types"; + UpdateIdentityUniversalAuthDTO +} from "./types"; export const useCreateIdentity = () => { const queryClient = useQueryClient(); @@ -326,7 +330,41 @@ export const useUpdateIdentityAwsAuth = () => { }); }; -// --- K8s auth (TODO: add cert and token reviewer JWT fields) +export const useAddIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.post<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; export const useAddIdentityKubernetesAuth = () => { const queryClient = useQueryClient(); @@ -370,6 +408,42 @@ export const useAddIdentityKubernetesAuth = () => { }); }; +export const useUpdateIdentityAzureAuth = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + identityId, + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + }) => { + const { + data: { identityAzureAuth } + } = await apiRequest.patch<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}`, + { + tenantId, + resource, + allowedServicePrincipalIds, + accessTokenTTL, + accessTokenMaxTTL, + accessTokenNumUsesLimit, + accessTokenTrustedIps + } + ); + + return identityAzureAuth; + }, + onSuccess: (_, { organizationId }) => { + queryClient.invalidateQueries(organizationKeys.getOrgIdentityMemberships(organizationId)); + } + }); +}; + export const useUpdateIdentityKubernetesAuth = () => { const queryClient = useQueryClient(); return useMutation({ @@ -403,6 +477,7 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenTrustedIps } ); + return identityKubernetesAuth; }, onSuccess: (_, { organizationId }) => { diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 270827b52..eb04227eb 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -5,10 +5,10 @@ import { apiRequest } from "@app/config/request"; import { ClientSecretData, IdentityAwsAuth, + IdentityAzureAuth, IdentityGcpAuth, IdentityKubernetesAuth, - IdentityUniversalAuth -} from "./types"; + IdentityUniversalAuth} from "./types"; export const identitiesKeys = { getIdentityUniversalAuth: (identityId: string) => @@ -18,7 +18,8 @@ export const identitiesKeys = { getIdentityKubernetesAuth: (identityId: string) => [{ identityId }, "identity-kubernetes-auth"] as const, getIdentityGcpAuth: (identityId: string) => [{ identityId }, "identity-gcp-auth"] as const, - getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const + getIdentityAwsAuth: (identityId: string) => [{ identityId }, "identity-aws-auth"] as const, + getIdentityAzureAuth: (identityId: string) => [{ identityId }, "identity-azure-auth"] as const }; export const useGetIdentityUniversalAuth = (identityId: string) => { @@ -81,6 +82,21 @@ export const useGetIdentityAwsAuth = (identityId: string) => { }); }; +export const useGetIdentityAzureAuth = (identityId: string) => { + return useQuery({ + enabled: Boolean(identityId), + queryKey: identitiesKeys.getIdentityAzureAuth(identityId), + queryFn: async () => { + const { + data: { identityAzureAuth } + } = await apiRequest.get<{ identityAzureAuth: IdentityAzureAuth }>( + `/api/v1/auth/azure-auth/identities/${identityId}` + ); + return identityAzureAuth; + } + }); +}; + export const useGetIdentityKubernetesAuth = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7e09bf280..80d066c72 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -195,6 +195,45 @@ export type UpdateIdentityAwsAuthDTO = { }[]; }; +export type IdentityAzureAuth = { + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: IdentityTrustedIp[]; +}; + +export type AddIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId: string; + resource: string; + allowedServicePrincipalIds: string; + accessTokenTTL: number; + accessTokenMaxTTL: number; + accessTokenNumUsesLimit: number; + accessTokenTrustedIps: { + ipAddress: string; + }[]; +}; + +export type UpdateIdentityAzureAuthDTO = { + organizationId: string; + identityId: string; + tenantId?: string; + resource?: string; + allowedServicePrincipalIds?: string; + accessTokenTTL?: number; + accessTokenMaxTTL?: number; + accessTokenNumUsesLimit?: number; + accessTokenTrustedIps?: { + ipAddress: string; + }[]; +}; + export type IdentityKubernetesAuth = { identityId: string; kubernetesHost: string; diff --git a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx index fad549e38..df04f3e8a 100644 --- a/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx +++ b/frontend/src/hooks/api/identityProjectAdditionalPrivilege/types.tsx @@ -12,21 +12,30 @@ export type TIdentityProjectPrivilege = { updatedAt: Date; permissions?: TProjectPermission[]; } & ( - | { + | { isTemporary: true; temporaryMode: string; temporaryRange: string; temporaryAccessStartTime: string; temporaryAccessEndTime?: string; } - | { + | { isTemporary: false; temporaryMode?: null; temporaryRange?: null; temporaryAccessStartTime?: null; temporaryAccessEndTime?: null; } - ); +); + +export type TProjectSpecificPrivilegePermission = { + conditions: { + environment: string; + secretPath?: { $glob: string }; + }; + actions: string[]; + subject: string; +}; export type TCreateIdentityProjectPrivilegeDTO = { identityId: string; @@ -36,14 +45,16 @@ export type TCreateIdentityProjectPrivilegeDTO = { temporaryMode?: IdentityProjectAdditionalPrivilegeTemporaryMode; temporaryRange?: string; temporaryAccessStartTime?: string; - permissions: TProjectPermission[]; + privilegePermission: TProjectSpecificPrivilegePermission; }; export type TUpdateIdentityProjectPrivlegeDTO = { projectSlug: string; identityId: string; privilegeSlug: string; - privilegeDetails: Partial>; + privilegeDetails: Partial< + Omit + >; }; export type TDeleteIdentityProjectPrivilegeDTO = { 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/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index b0e1dd9f5..b73528384 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -7,6 +7,7 @@ export type IntegrationAuth = { updatedAt: string; algorithm: string; keyEncoding: string; + url?: string; teamId?: string; }; @@ -30,7 +31,7 @@ export type HerokuPipelineCoupling = { export type Team = { name: string; - teamId: string; + id: string; }; export type Environment = { diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 7325dc4a3..81d0f00ca 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -41,6 +41,7 @@ export const useCreateIntegration = () => { owner, path, region, + url, scope, secretPath, metadata @@ -56,6 +57,7 @@ export const useCreateIntegration = () => { targetService?: string; targetServiceId?: string; owner?: string; + url?: string; path?: string; region?: string; scope?: string; @@ -71,6 +73,9 @@ export const useCreateIntegration = () => { }[]; kmsKeyId?: string; shouldDisableDelete?: boolean; + shouldMaskSecrets?: boolean; + shouldProtectSecrets?: boolean; + shouldEnableDelete?: boolean; }; }) => { const { @@ -85,6 +90,7 @@ export const useCreateIntegration = () => { targetEnvironmentId, targetService, targetServiceId, + url, owner, path, scope, 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/roles/index.tsx b/frontend/src/hooks/api/roles/index.tsx index 50736b30d..53c05a7b6 100644 --- a/frontend/src/hooks/api/roles/index.tsx +++ b/frontend/src/hooks/api/roles/index.tsx @@ -8,6 +8,7 @@ export { } from "./mutation"; export { useGetOrgRoles, + useGetProjectRoleBySlug, useGetProjectRoles, useGetUserOrgPermissions, useGetUserProjectPermissions diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 6df6a3933..ae3e170de 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -17,13 +17,10 @@ export const useCreateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, permissions, ...dto }: TCreateProjectRoleDTO) => - apiRequest.post(`/api/v1/workspace/${projectId}/roles`, { - ...dto, - permissions: permissions.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, ...dto }: TCreateProjectRoleDTO) => + apiRequest.post(`/api/v1/workspace/${projectSlug}/roles`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -32,13 +29,10 @@ export const useUpdateProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ id, projectId, permissions, ...dto }: TUpdateProjectRoleDTO) => - apiRequest.patch(`/api/v1/workspace/${projectId}/roles/${id}`, { - ...dto, - permissions: permissions?.length ? packRules(permissions) : [] - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ id, projectSlug, ...dto }: TUpdateProjectRoleDTO) => + apiRequest.patch(`/api/v1/workspace/${projectSlug}/roles/${id}`, dto), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; @@ -47,12 +41,10 @@ export const useDeleteProjectRole = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ projectId, id }: TDeleteProjectRoleDTO) => - apiRequest.delete(`/api/v1/workspace/${projectId}/roles/${id}`, { - data: { projectId } - }), - onSuccess: (_, { projectId }) => { - queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectId)); + mutationFn: ({ projectSlug, id }: TDeleteProjectRoleDTO) => + apiRequest.delete(`/api/v1/workspace/${projectSlug}/roles/${id}`), + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries(roleQueryKeys.getProjectRoles(projectSlug)); } }); }; diff --git a/frontend/src/hooks/api/roles/queries.tsx b/frontend/src/hooks/api/roles/queries.tsx index 3280bface..f04af697d 100644 --- a/frontend/src/hooks/api/roles/queries.tsx +++ b/frontend/src/hooks/api/roles/queries.tsx @@ -14,7 +14,6 @@ import { TGetUserProjectPermissionDTO, TOrgRole, TPermission, - TProjectPermission, TProjectRole } from "./types"; @@ -37,7 +36,9 @@ const glob: JsInterpreter> = (node, object, context) => { const conditionsMatcher = buildMongoQueryMatcher({ $glob }, { glob }); export const roleQueryKeys = { - getProjectRoles: (projectId: string) => ["roles", { projectId }] as const, + getProjectRoles: (projectSlug: string) => ["roles", { projectSlug }] as const, + getProjectRoleBySlug: (projectSlug: string, roleSlug: string) => + ["roles", { projectSlug, roleSlug }] as const, getOrgRoles: (orgId: string) => ["org-roles", { orgId }] as const, getUserOrgPermissions: ({ orgId }: TGetUserOrgPermissionsDTO) => ["user-permissions", { orgId }] as const, @@ -46,20 +47,29 @@ export const roleQueryKeys = { }; const getProjectRoles = async (projectId: string) => { - const { data } = await apiRequest.get<{ - data: { roles: Array & { permissions: unknown }> }; - }>(`/api/v1/workspace/${projectId}/roles`); - return data.data.roles.map(({ permissions, ...el }) => ({ - ...el, - permissions: unpackRules(permissions as PackRule[]) - })); + const { data } = await apiRequest.get<{ roles: Array> }>( + `/api/v1/workspace/${projectId}/roles` + ); + return data.roles; }; -export const useGetProjectRoles = (projectId: string) => +export const useGetProjectRoles = (projectSlug: string) => useQuery({ - queryKey: roleQueryKeys.getProjectRoles(projectId), - queryFn: () => getProjectRoles(projectId), - enabled: Boolean(projectId) + queryKey: roleQueryKeys.getProjectRoles(projectSlug), + queryFn: () => getProjectRoles(projectSlug), + enabled: Boolean(projectSlug) + }); + +export const useGetProjectRoleBySlug = (projectSlug: string, roleSlug: string) => + useQuery({ + queryKey: roleQueryKeys.getProjectRoleBySlug(projectSlug, roleSlug), + queryFn: async () => { + const { data } = await apiRequest.get<{ role: TProjectRole }>( + `/api/v1/workspace/${projectSlug}/roles/slug/${roleSlug}` + ); + return data.role; + }, + enabled: Boolean(projectSlug && roleSlug) }); const getOrgRoles = async (orgId: string) => { diff --git a/frontend/src/hooks/api/roles/types.ts b/frontend/src/hooks/api/roles/types.ts index 97a90b421..e2d1b533a 100644 --- a/frontend/src/hooks/api/roles/types.ts +++ b/frontend/src/hooks/api/roles/types.ts @@ -71,7 +71,7 @@ export type TDeleteOrgRoleDTO = { }; export type TCreateProjectRoleDTO = { - projectId: string; + projectSlug: string; name: string; description?: string; slug: string; @@ -79,11 +79,11 @@ export type TCreateProjectRoleDTO = { }; export type TUpdateProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; } & Partial>; export type TDeleteProjectRoleDTO = { - projectId: string; + projectSlug: string; id: string; }; diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 23017c15d..aaf84941a 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -220,6 +220,7 @@ export const useGetSecretApprovalRequestCount = ({ }) => useQuery({ queryKey: secretApprovalRequestKeys.count({ workspaceId }), + refetchInterval: 5000, queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }), enabled: Boolean(workspaceId) && (options?.enabled ?? true) }); diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index 32fe31c6b..8c2ba6963 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -44,6 +44,7 @@ export type TSecretApprovalSecChange = { export type TSecretApprovalRequest = { id: string; + isReplicated?: boolean; slug: string; createdAt: string; committerId: string; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 8fde9c63d..412f2686d 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -1,3 +1,7 @@ +export enum ReservedFolders { + SecretReplication = "__reserve_replication_" +} + export type TSecretFolder = { id: string; name: string; diff --git a/frontend/src/hooks/api/secretImports/index.ts b/frontend/src/hooks/api/secretImports/index.ts index f30506b6b..fed0f13d4 100644 --- a/frontend/src/hooks/api/secretImports/index.ts +++ b/frontend/src/hooks/api/secretImports/index.ts @@ -1,4 +1,9 @@ -export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; +export { + useCreateSecretImport, + useDeleteSecretImport, + useResyncSecretReplication, + useUpdateSecretImport +} from "./mutation"; export { useGetImportedFoldersByEnv, useGetImportedSecretsAllEnvs, diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx index 928322a3c..04f1f01e6 100644 --- a/frontend/src/hooks/api/secretImports/mutation.tsx +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -3,18 +3,24 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { secretImportKeys } from "./queries"; -import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; +import { + TCreateSecretImportDTO, + TDeleteSecretImportDTO, + TResyncSecretReplicationDTO, + TUpdateSecretImportDTO +} from "./types"; export const useCreateSecretImport = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TCreateSecretImportDTO>({ - mutationFn: async ({ import: secretImport, environment, projectId, path }) => { + mutationFn: async ({ import: secretImport, environment, isReplication, projectId, path }) => { const { data } = await apiRequest.post("/api/v1/secret-imports", { import: secretImport, environment, workspaceId: projectId, - path + path, + isReplication }); return data; }, @@ -53,6 +59,19 @@ export const useUpdateSecretImport = () => { }); }; +export const useResyncSecretReplication = () => { + return useMutation<{}, {}, TResyncSecretReplicationDTO>({ + mutationFn: async ({ environment, projectId, path, id }) => { + const { data } = await apiRequest.post(`/api/v1/secret-imports/${id}/replication-resync`, { + environment, + path, + workspaceId: projectId + }); + return data; + } + }); +}; + export const useDeleteSecretImport = () => { const queryClient = useQueryClient(); 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/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts index 950fc20c4..1a6c06dd3 100644 --- a/frontend/src/hooks/api/secretImports/types.ts +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -10,6 +10,11 @@ export type TSecretImport = { position: string; createdAt: string; updatedAt: string; + isReserved?: boolean; + isReplication?: boolean; + isReplicationSuccess?: boolean; + replicationStatus?: string; + lastReplicated?: string; }; export type TGetImportedFoldersByEnvDTO = { @@ -60,6 +65,7 @@ export type TCreateSecretImportDTO = { environment: string; path: string; }; + isReplication?: boolean; }; export type TUpdateSecretImportDTO = { @@ -74,6 +80,13 @@ export type TUpdateSecretImportDTO = { }>; }; +export type TResyncSecretReplicationDTO = { + id: string; + projectId: string; + environment: string; + path?: string; +}; + export type TDeleteSecretImportDTO = { id: string; projectId: string; diff --git a/frontend/src/hooks/api/secretSharing/index.ts b/frontend/src/hooks/api/secretSharing/index.ts new file mode 100644 index 000000000..177955438 --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/index.ts @@ -0,0 +1,3 @@ +export * from "./mutations"; +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/secretSharing/mutations.ts b/frontend/src/hooks/api/secretSharing/mutations.ts new file mode 100644 index 000000000..e0c1dcc3c --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/mutations.ts @@ -0,0 +1,45 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TCreateSharedSecretRequest, TDeleteSharedSecretRequest, TSharedSecret } from "./types"; + +export const useCreateSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSharedSecretRequest) => { + const { data } = await apiRequest.post("/api/v1/secret-sharing", inputData); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + +export const useCreatePublicSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (inputData: TCreateSharedSecretRequest) => { + const { data } = await apiRequest.post( + "/api/v1/secret-sharing/public", + inputData + ); + return data; + }, + onSuccess: () => queryClient.invalidateQueries(["sharedSecrets"]) + }); +}; + +export const useDeleteSharedSecret = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ sharedSecretId }: TDeleteSharedSecretRequest) => { + const { data } = await apiRequest.delete( + `/api/v1/secret-sharing/${sharedSecretId}` + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(["sharedSecrets"]); + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/queries.ts b/frontend/src/hooks/api/secretSharing/queries.ts new file mode 100644 index 000000000..886b0a82e --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/queries.ts @@ -0,0 +1,31 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TSharedSecret, TViewSharedSecretResponse } from "./types"; + +export const useGetSharedSecrets = () => { + return useQuery({ + queryKey: ["sharedSecrets"], + queryFn: async () => { + const { data } = await apiRequest.get("/api/v1/secret-sharing/"); + return data; + } + }); +}; + +export const useGetActiveSharedSecretByIdAndHashedHex = (id: string, hashedHex: string) => { + return useQuery({ + queryFn: async () => { + if(!id || !hashedHex) return Promise.resolve({ encryptedValue: "", iv: "", tag: "" }); + const { data } = await apiRequest.get( + `/api/v1/secret-sharing/public/${id}?hashedHex=${hashedHex}` + ); + return { + encryptedValue: data.encryptedValue, + iv: data.iv, + tag: data.tag, + }; + } + }); +}; diff --git a/frontend/src/hooks/api/secretSharing/types.ts b/frontend/src/hooks/api/secretSharing/types.ts new file mode 100644 index 000000000..424e3525c --- /dev/null +++ b/frontend/src/hooks/api/secretSharing/types.ts @@ -0,0 +1,26 @@ +export type TSharedSecret = { + id: string; + userId: string; + orgId: string; + createdAt: Date; + updatedAt: Date; +} & TCreateSharedSecretRequest; + +export type TCreateSharedSecretRequest = { + encryptedValue: string; + iv: string; + tag: string; + hashedHex: string; + expiresAt: Date; + expiresAfterViews: number; +}; + +export type TViewSharedSecretResponse = { + encryptedValue: string; + iv: string; + tag: string; +}; + +export type TDeleteSharedSecretRequest = { + sharedSecretId: string; +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 1ba9a5251..28999389e 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -98,7 +98,7 @@ export const decryptSecrets = ( return secrets; }; -const fetchProjectEncryptedSecrets = async ({ +export const fetchProjectEncryptedSecrets = async ({ workspaceId, environment, secretPath diff --git a/frontend/src/hooks/api/serverDetails/types.ts b/frontend/src/hooks/api/serverDetails/types.ts index 80d34a150..911526404 100644 --- a/frontend/src/hooks/api/serverDetails/types.ts +++ b/frontend/src/hooks/api/serverDetails/types.ts @@ -4,4 +4,5 @@ export type ServerStatus = { emailConfigured: boolean; secretScanningConfigured: boolean; redisConfigured: boolean; + samlDefaultOrgSlug: boolean }; 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/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index a443c6750..fa0b932ea 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -20,6 +20,7 @@ import { export const userKeys = { getUser: ["user"] as const, + getPrivateKey: ["user"] as const, userAction: ["user-action"] as const, getOrgUsers: (orgId: string) => [{ orgId }, "user"], myIp: ["ip"] as const, @@ -351,3 +352,11 @@ export const useGetMyOrganizationProjects = (orgId: string) => { enabled: true }); }; + +export const fetchMyPrivateKey = async () => { + const { + data: { privateKey } + } = await apiRequest.get<{ privateKey: string }>("/api/v1/user/private-key"); + + return privateKey; +}; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index b0cadac23..f5e5855d4 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -1,6 +1,7 @@ export { useAddGroupToWorkspace, useDeleteGroupFromWorkspace, + useLeaveProject, useUpdateGroupWorkspaceRole } from "./mutations"; export { @@ -21,6 +22,8 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceCas, + useListWorkspaceCertificates, useListWorkspaceGroups, useNameWorkspaceSecrets, useRenameWorkspace, diff --git a/frontend/src/hooks/api/workspace/mutations.tsx b/frontend/src/hooks/api/workspace/mutations.tsx index 11853157f..5aba02098 100644 --- a/frontend/src/hooks/api/workspace/mutations.tsx +++ b/frontend/src/hooks/api/workspace/mutations.tsx @@ -62,3 +62,15 @@ export const useDeleteGroupFromWorkspace = () => { } }); }; + +export const useLeaveProject = () => { + const queryClient = useQueryClient(); + return useMutation<{}, {}, { workspaceId: string }>({ + mutationFn: ({ workspaceId }) => { + return apiRequest.delete(`/api/v1/workspace/${workspaceId}/leave`); + }, + onSuccess: () => { + queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + } + }); +}; 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 2fcdc9339..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 + + + {(window.location.origin.includes("https://app.infisical.com") || window.location.origin.includes("https://gamma.infisical.com")) && ( 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/lib/fn/string.ts b/frontend/src/lib/fn/string.ts new file mode 100644 index 000000000..9d3d01cc5 --- /dev/null +++ b/frontend/src/lib/fn/string.ts @@ -0,0 +1,9 @@ +import { ReservedFolders } from "@app/hooks/api/secretFolders/types"; + +export const formatReservedPaths = (secretPath: string) => { + const i = secretPath.indexOf(ReservedFolders.SecretReplication); + if (i !== -1) { + return `${secretPath.slice(0, i)} - (replication)`; + } + return secretPath; +}; diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index 2c04702e1..e6c2e7618 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -169,12 +169,12 @@ export default function AWSSecretManagerCreateIntegrationPage() { mappingBehavior: selectedMappingBehavior } }); - setIsLoading(false); setTargetSecretNameErrorText(""); router.push(`/integrations/${localStorage.getItem("projectData.id")}`); } catch (err) { + setIsLoading(false); console.error(err); } }; diff --git a/frontend/src/pages/integrations/cloudflare-pages/create.tsx b/frontend/src/pages/integrations/cloudflare-pages/create.tsx index 570b2b83a..d7cb6bdab 100644 --- a/frontend/src/pages/integrations/cloudflare-pages/create.tsx +++ b/frontend/src/pages/integrations/cloudflare-pages/create.tsx @@ -7,7 +7,15 @@ import { createNotification } from "@app/components/notifications"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api"; -import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; +import { + Button, + Card, + CardTitle, + FormControl, + Select, + SelectItem, + Switch +} from "../../../components/v2"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById @@ -34,6 +42,7 @@ export default function CloudflarePagesIntegrationPage() { const [targetApp, setTargetApp] = useState(""); const [targetAppId, setTargetAppId] = useState(""); const [targetEnvironment, setTargetEnvironment] = useState(""); + const [shouldAutoRedeploy, setShouldAutoRedeploy] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -69,7 +78,10 @@ export default function CloudflarePagesIntegrationPage() { appId: targetAppId, sourceEnvironment: selectedSourceEnvironment, targetEnvironment, - secretPath + secretPath, + metadata: { + shouldAutoRedeploy + } }); setIsLoading(false); @@ -169,6 +181,15 @@ export default function CloudflarePagesIntegrationPage() { ))} +
+ setShouldAutoRedeploy(isChecked)} + isChecked={shouldAutoRedeploy} + > + Auto-redeploy service upon secret change + +
+ +
+ + ); +} + +RundeckAuthorizeIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/rundeck/create.tsx b/frontend/src/pages/integrations/rundeck/create.tsx new file mode 100644 index 000000000..543d9f4b0 --- /dev/null +++ b/frontend/src/pages/integrations/rundeck/create.tsx @@ -0,0 +1,217 @@ +import { Controller, useForm } from "react-hook-form"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowUpRightFromSquare, faBookOpen, faBugs } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import queryString from "query-string"; +import { z } from "zod"; + +import { + Button, + Card, + CardTitle, + FormControl, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; +import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthById } from "@app/hooks/api/integrationAuth"; +import { useGetWorkspaceById } from "@app/hooks/api/workspace"; + +const schema = z.object({ + keyStoragePath: z.string().trim().min(1, { message: "Rundeck Key Storage path is required" }), + secretPath: z.string().trim().min(1, { message: "Secret path is required" }), + sourceEnvironment: z.string().trim().min(1, { message: "Source environment is required" }) +}); + +type TFormSchema = z.infer; + +export default function RundeckCreateIntegrationPage() { + const { + control, + handleSubmit, + watch, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + secretPath: "/" + } + }); + const router = useRouter(); + const { mutateAsync } = useCreateIntegration(); + const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); + const { data: integrationAuth, isLoading: isIntegrationAuthLoading } = useGetIntegrationAuthById( + (integrationAuthId as string) ?? "" + ); + + const selectedSourceEnvironment = watch("sourceEnvironment"); + + const onFormSubmit = async ({ secretPath, sourceEnvironment, keyStoragePath }: TFormSchema) => { + try { + if (!integrationAuth?.id) return; + + await mutateAsync({ + integrationAuthId: integrationAuth?.id, + isActive: true, + path: keyStoragePath, + sourceEnvironment, + url: integrationAuth.url, + secretPath + }); + + router.push(`/integrations/${localStorage.getItem("projectData.id")}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && workspace ? ( +
+ + Set Up Rundeck Integration + + + + +
+
+ Rundeck logo +
+ Rundeck Integration + + +
+ + Docs + +
+
+ +
+
+ +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + ( + + + + )} + /> + + + +
+
+ ) : ( +
+ + Set Up Rundeck Integration + + + {isIntegrationAuthLoading ? ( + infisical loading indicator + ) : ( +
+ +

+ Something went wrong. Please contact{" "} + + support@infisical.com + {" "} + if the issue persists. +

+
+ )} +
+ ); +} + +RundeckCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/org/[id]/overview/index.tsx b/frontend/src/pages/org/[id]/overview/index.tsx index 17cf8537c..177a6586b 100644 --- a/frontend/src/pages/org/[id]/overview/index.tsx +++ b/frontend/src/pages/org/[id]/overview/index.tsx @@ -12,11 +12,14 @@ import { faFolderOpen } from "@fortawesome/free-regular-svg-icons"; import { faArrowRight, faArrowUpRightFromSquare, + faBorderAll, faCheck, faCheckCircle, faClipboard, faExclamationCircle, + faFileShield, faHandPeace, + faList, faMagnifyingGlass, faNetworkWired, faPlug, @@ -35,6 +38,7 @@ import { Button, Checkbox, FormControl, + IconButton, Input, Modal, ModalContent, @@ -86,6 +90,11 @@ type ItemProps = { link?: string; }; +enum ProjectsViewMode { + GRID = "grid", + LIST = "list" +} + function copyToClipboard(id: string, setState: (value: boolean) => 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/org/[id]/secret-sharing/index.tsx b/frontend/src/pages/org/[id]/secret-sharing/index.tsx new file mode 100644 index 000000000..28bcb7831 --- /dev/null +++ b/frontend/src/pages/org/[id]/secret-sharing/index.tsx @@ -0,0 +1,27 @@ +import { useTranslation } from "react-i18next"; +import Head from "next/head"; + +import { ShareSecretPage } from "@app/views/ShareSecretPage"; + +const SecretApproval = () => { + const { t } = useTranslation(); + + return ( + <> + + {t("common.head-title", { title: t("approval.title") })} + + + + + +
+ +
+ + ); +}; + +export default SecretApproval; + +SecretApproval.requireAuth = true; 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/pages/share-secret/index.tsx b/frontend/src/pages/share-secret/index.tsx new file mode 100644 index 000000000..53b034650 --- /dev/null +++ b/frontend/src/pages/share-secret/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const ShareNewPublicSecretPage = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default ShareNewPublicSecretPage; + +ShareNewPublicSecretPage.requireAuth = false; diff --git a/frontend/src/pages/shared/secret/[id]/index.tsx b/frontend/src/pages/shared/secret/[id]/index.tsx new file mode 100644 index 000000000..bda56347b --- /dev/null +++ b/frontend/src/pages/shared/secret/[id]/index.tsx @@ -0,0 +1,24 @@ +import Head from "next/head"; + +import { ShareSecretPublicPage } from "@app/views/ShareSecretPublicPage"; + +const SecretSharedPublicPage = () => { + return ( + <> + + Securely Share Secrets | Infisical + + + + + +
+ +
+ + ); +}; + +export default SecretSharedPublicPage; + +SecretSharedPublicPage.requireAuth = false; diff --git a/frontend/src/pages/signupinvite.tsx b/frontend/src/pages/signupinvite.tsx index 17ac5b4ff..725dc6113 100644 --- a/frontend/src/pages/signupinvite.tsx +++ b/frontend/src/pages/signupinvite.tsx @@ -75,7 +75,12 @@ export default function SignupInvite() { // Verifies if the information that the users entered (name, workspace) is there, and if the password matched the criteria. const signupErrorCheck = async () => { setIsLoading(true); - let errorCheck = false; + + let errorCheck = await checkPassword({ + password, + setErrors + }); + if (!firstName) { setFirstNameError(true); errorCheck = true; @@ -89,11 +94,6 @@ export default function SignupInvite() { setLastNameError(false); } - errorCheck = await checkPassword({ - password, - setErrors - }); - if (!errorCheck) { // Generate a random pair of a public and a private key const pair = nacl.box.keyPair(); @@ -149,6 +149,7 @@ export default function SignupInvite() { const { token: jwtToken } = await completeAccountSignupInvite({ email, + password, firstName, lastName, protectedKey, diff --git a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx index 10ef21e27..1aee4c656 100644 --- a/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx +++ b/frontend/src/views/IntegrationsPage/IntegrationPage.utils.tsx @@ -128,6 +128,9 @@ export const redirectForProviderAuth = (integrationOption: TCloudIntegration) => case "hasura-cloud": link = `${window.location.origin}/integrations/hasura-cloud/authorize`; break; + case "rundeck": + link = `${window.location.origin}/integrations/rundeck/authorize`; + break; default: break; } diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index 267ff8580..d56010278 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -141,7 +141,8 @@ export const IntegrationsSection = ({ label={ (integration.integration === "qovery" && integration?.scope) || (integration.integration === "aws-secret-manager" && "Secret") || - (integration.integration === "aws-parameter-store" && "Path") || + (["aws-parameter-store", "rundeck"].includes(integration.integration) && + "Path") || (integration?.integration === "terraform-cloud" && "Project") || (integration?.scope === "github-org" && "Organization") || (["github-repo", "github-env"].includes(integration?.scope as string) && @@ -153,7 +154,7 @@ export const IntegrationsSection = ({ {(integration.integration === "hashicorp-vault" && `${integration.app} - path: ${integration.path}`) || (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && + (["aws-parameter-store", "rundeck"].includes(integration.integration) && `${integration.path}`) || (integration.scope?.startsWith("github-") && `${integration.owner}/${integration.app}`) || diff --git a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx index f3b40300e..6e2c788ae 100644 --- a/frontend/src/views/Login/components/InitialStep/InitialStep.tsx +++ b/frontend/src/views/Login/components/InitialStep/InitialStep.tsx @@ -1,17 +1,20 @@ -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"; import { navigateUserToSelectOrg } from "../../Login.utils"; @@ -31,21 +34,18 @@ 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(() => { - if ( - process.env.NEXT_PUBLIC_SAML_ORG_SLUG && - process.env.NEXT_PUBLIC_SAML_ORG_SLUG !== "saml-org-slug-default" - ) { - const callbackPort = queryParams.get("callback_port"); - window.open( - `/api/v1/sso/redirect/saml2/organizations/${process.env.NEXT_PUBLIC_SAML_ORG_SLUG}${ - callbackPort ? `?callback_port=${callbackPort}` : "" - }` - ); - window.close(); - } - }, []); + if (serverDetails?.samlDefaultOrgSlug){ + const callbackPort = queryParams.get("callback_port"); + const redirectUrl = `/api/v1/sso/redirect/saml2/organizations/${serverDetails?.samlDefaultOrgSlug}${callbackPort ? `?callback_port=${callbackPort}` : ""}` + router.push(redirectUrl); + } + }, [serverDetails?.samlDefaultOrgSlug]); const handleLogin = async (e: FormEvent) => { e.preventDefault(); @@ -61,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) { @@ -83,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) { @@ -105,8 +107,24 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } } - } catch (err) { + } catch (err: any) { console.error(err); + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + setIsLoading(false); + 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.", @@ -114,6 +132,11 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: }); } + if (captchaRef.current) { + captchaRef.current.resetCaptcha(); + } + + setCaptchaToken(""); setIsLoading(false); }; @@ -235,8 +258,19 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }: className="select:-webkit-autofill:focus h-10" />
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
{typeof triesLeft === "number" && ( - + )}
diff --git a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx index f9b086f6d..06438a2f8 100644 --- a/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx +++ b/frontend/src/views/Login/components/PasswordStep/PasswordStep.tsx @@ -1,18 +1,20 @@ -import { useState } from "react"; +import { useEffect, useRef,useState } from "react"; import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/router"; +import HCaptcha from "@hcaptcha/react-hcaptcha"; import axios from "axios"; import jwt_decode from "jwt-decode"; import { createNotification } from "@app/components/notifications"; import attemptCliLogin from "@app/components/utilities/attemptCliLogin"; import attemptLogin from "@app/components/utilities/attemptLogin"; -import { Button, Input } from "@app/components/v2"; -import { useUpdateUserAuthMethods } from "@app/hooks/api"; -import { useSelectOrganization } from "@app/hooks/api/auth/queries"; +import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config"; +import SecurityClient from "@app/components/utilities/SecurityClient"; +import { Button, Input, Spinner } from "@app/components/v2"; +import { useOauthTokenExchange, useSelectOrganization } from "@app/hooks/api"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { fetchUserDetails } from "@app/hooks/api/users/queries"; +import { fetchMyPrivateKey } from "@app/hooks/api/users/queries"; import { navigateUserToOrg, navigateUserToSelectOrg } from "../../Login.utils"; @@ -31,16 +33,101 @@ export const PasswordStep = ({ setPassword, setStep }: Props) => { - const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const router = useRouter(); - const { mutateAsync } = useUpdateUserAuthMethods(); const { mutateAsync: selectOrganization } = useSelectOrganization(); + const { mutateAsync: oauthTokenExchange } = useOauthTokenExchange(); - const { callbackPort, isLinkingRequired, authMethod, organizationId } = jwt_decode( - providerAuthToken - ) as any; + const { callbackPort, organizationId, hasExchangedPrivateKey } = + jwt_decode(providerAuthToken) as any; + + const handleExchange = async () => { + try { + setIsLoading(true); + const oauthLogin = await oauthTokenExchange({ + email, + providerAuthToken + }); + + // attemptCliLogin + if (oauthLogin.mfaEnabled) { + SecurityClient.setMfaToken(oauthLogin.token); + // case: login requires MFA step + setStep(2); + setIsLoading(false); + return; + } + const cliUrl = `http://127.0.0.1:${callbackPort}/`; + + // case: MFA is not enabled + + // unset provider auth token in case it was used + SecurityClient.setProviderAuthToken(""); + // set JWT token + SecurityClient.setToken(oauthLogin.token); + + const privateKey = await fetchMyPrivateKey(); + localStorage.setItem("PRIVATE_KEY", privateKey); + + // case: organization ID is present from the provider auth token -- select the org and use the new jwt token in the CLI, then navigate to the org + if (organizationId) { + const { token: newJwtToken } = await selectOrganization({ organizationId }); + if (callbackPort) { + console.log("organization id was present. new JWT token to be used in CLI:", newJwtToken); + const instance = axios.create(); + await instance.post(cliUrl, { + privateKey, + email, + JTWToken: newJwtToken + }); + } + + await navigateUserToOrg(router, organizationId); + } + // case: no organization ID is present -- navigate to the select org page IF the user has any orgs + // if the user has no orgs, navigate to the create org page + else { + const userOrgs = await fetchOrganizations(); + + // case: user has orgs, so we navigate the user to select an org + if (userOrgs.length > 0) { + navigateUserToSelectOrg(router, callbackPort); + } + // case: no orgs found, so we navigate the user to create an org + else { + await navigateUserToOrg(router); + } + } + } catch (err: any) { + setIsLoading(false); + console.error(err); + + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + return; + } + + createNotification({ + text: "Login unsuccessful. Double-check your master password and try again.", + type: "error" + }); + } + }; + + useEffect(() => { + if (hasExchangedPrivateKey) { + handleExchange(); + } + }, []); + + const [captchaToken, setCaptchaToken] = useState(""); + const [shouldShowCaptcha, setShouldShowCaptcha] = useState(false); + const captchaRef = useRef(null); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -52,7 +139,8 @@ export const PasswordStep = ({ const isCliLoginSuccessful = await attemptCliLogin({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }); if (isCliLoginSuccessful && isCliLoginSuccessful.success) { @@ -100,7 +188,8 @@ export const PasswordStep = ({ const loginAttempt = await attemptLogin({ email, password, - providerAuthToken + providerAuthToken, + captchaToken }); if (loginAttempt && loginAttempt.success) { @@ -121,14 +210,6 @@ export const PasswordStep = ({ type: "success" }); - if (isLinkingRequired) { - const user = await fetchUserDetails(); - const newAuthMethods = [...user.authMethods, authMethod]; - await mutateAsync({ - authMethods: newAuthMethods - }); - } - // case: organization ID is present from the provider auth token -- navigate directly to the org if (organizationId) { await navigateUserToOrg(router, organizationId); @@ -146,30 +227,51 @@ export const PasswordStep = ({ } } } - } catch (err) { + } catch (err: any) { setIsLoading(false); + console.error(err); + + if (err.response.data.error === "User Locked") { + createNotification({ + title: err.response.data.error, + text: err.response.data.message, + type: "error" + }); + return; + } + + if (err.response.data.error === "Captcha Required") { + setShouldShowCaptcha(true); + return; + } + createNotification({ text: "Login unsuccessful. Double-check your master password and try again.", type: "error" }); - console.error(err); } + + if (captchaRef.current) { + captchaRef.current.resetCaptcha(); + } + setCaptchaToken(""); }; + if (hasExchangedPrivateKey) { + return ( +
+ +

Loading, please wait

+
+ ); + } + return (

- {isLinkingRequired ? "Link your account" : "What's your Infisical password?"} + What's your Infisical password?

- {isLinkingRequired && ( -
- - An existing account without this SSO authentication method enabled was found under the - same email. Login with your password to link the account. - -
- )}
@@ -185,8 +287,19 @@ export const PasswordStep = ({ />
+ {shouldShowCaptcha && ( +
+ setCaptchaToken(token)} + ref={captchaRef} + /> +
+ )}
+
+
+ + +
+
+ ); +}; diff --git a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 0ed9cd9a4..294544093 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -1,9 +1,22 @@ -import { faKey, faLock, faPencil, faServer, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { + faCopy, + faEllipsis, + faKey, + faLock, + faPencil, + faServer, + faXmark +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, EmptyState, IconButton, Select, @@ -80,7 +93,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { Name - ID Role Auth Method @@ -95,7 +107,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { return ( {name} - {id} { {authMethod ? identityAuthToNameMap[authMethod] : "Not configured"} -
+
{authMethod === IdentityAuthMethod.UNIVERSAL_AUTH && ( { colorSchema="primary" variant="plain" ariaLabel="update" - // isDisabled={!isAllowed} > @@ -165,7 +175,6 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { colorSchema="primary" variant="plain" ariaLabel="update" - className="ml-4" isDisabled={!isAllowed} > @@ -173,54 +182,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { )} - - {(isAllowed) => ( - { - handlePopUpOpen("identity", { - identityId: id, - name, - role, - customRole - }); - }} - size="lg" - colorSchema="primary" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + + +
+ + + +
+
+ + - -
- )} -
- - {(isAllowed) => ( - ( + { + if (!isAllowed) return; + handlePopUpOpen("identity", { + identityId: id, + name, + role, + customRole + }); + }} + disabled={!isAllowed} + icon={} + > + Update identity + + )} + + + {(isAllowed) => ( + { + if (!isAllowed) return; + handlePopUpOpen("deleteIdentity", { + identityId: id, + name + }); + }} + icon={} + > + Delete identity + + )} + + { - handlePopUpOpen("deleteIdentity", { - identityId: id, - name + navigator.clipboard.writeText(id); + createNotification({ + text: "Copied identity internal ID to clipboard", + type: "success" }); }} - size="lg" - colorSchema="danger" - variant="plain" - ariaLabel="update" - className="ml-4" - isDisabled={!isAllowed} + icon={} > - - - )} - + Copy Identity ID + + +
diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx index 57fdf46d4..011e1a915 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsFilter.tsx @@ -179,8 +179,9 @@ export const LogsFilter = ({ control, reset }: Props) => { { - onChange(date); + onChange={(pickedDate) => { + pickedDate?.setHours(23, 59, 59, 999); // we choose the end of today not the start of it (going off of aws cloud watch) + onChange(pickedDate); setIsEndDatePickerOpen(false); }} popUpProps={{ diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx index fcf63bece..e47b423a4 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsSection.tsx @@ -23,7 +23,8 @@ export const LogsSection = () => { defaultValues: { page: 1, perPage: 10, - startDate: new Date(new Date().setDate(new Date().getDate() - 1)) + startDate: new Date(new Date().setDate(new Date().getDate() - 1)), // day before today + endDate: new Date(new Date(Date.now()).setHours(23, 59, 59, 999)) // end of today } }); diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 7fc69400e..f4b740e35 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -320,6 +320,30 @@ export const LogsTableRow = ({ auditLog }: Props) => { })} ); + case EventType.CREATE_CA: + case EventType.GET_CA: + case EventType.UPDATE_CA: + case EventType.DELETE_CA: + case EventType.GET_CA_CSR: + case EventType.GET_CA_CERT: + case EventType.IMPORT_CA_CERT: + case EventType.GET_CA_CRL: + case EventType.SIGN_INTERMEDIATE: + case EventType.ISSUE_CERT: + return ( + +

{`CA DN: ${event.metadata.dn}`}

+ + ); + case EventType.GET_CERT: + case EventType.DELETE_CERT: + case EventType.REVOKE_CERT: + case EventType.GET_CERT_BODY: + return ( + +

{`Cert CN: ${event.metadata.cn}`}

+ + ); default: return ; } diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx new file mode 100644 index 000000000..30e531277 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -0,0 +1,35 @@ +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { withProjectPermission } from "@app/hoc"; + +import { CaTab, CertificatesTab } from "./components"; + +enum TabSections { + Ca = "certificate-authorities", + Certificates = "certificates" +} + +export const CertificatesPage = withProjectPermission( + () => { + return ( +
+
+

Internal PKI

+ + + Certificates + Certificate Authorities + + + + + + + + +
+
+ ); + }, + { action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs } +); diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx new file mode 100644 index 000000000..aadca4dfe --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/CaTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { CaSection } from "./components"; + +export const CaTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx new file mode 100644 index 000000000..97970d94f --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCertModal.tsx @@ -0,0 +1,34 @@ +import { Modal, ModalContent } from "@app/components/v2"; +import { useGetCaCert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "../../CertificatesTab/components/CertificateContent"; + +type Props = { + popUp: UsePopUpState<["caCert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCert"]>, state?: boolean) => void; +}; + +export const CaCertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { data } = useGetCaCert((popUp?.caCert?.data as { caId: string })?.caId || ""); + return ( + { + handlePopUpToggle("caCert", isOpen); + }} + > + + {data ? ( + + ) : ( +
+ )} + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx new file mode 100644 index 000000000..77e67f32e --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/CaTab/components/CaCrlModal.tsx @@ -0,0 +1,106 @@ +import { useEffect } from "react"; +import { faCheck, faCopy, faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Modal, ModalContent } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { useGetCaCrl } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["caCrl"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["caCrl"]>, state?: boolean) => void; +}; + +export const CaCrlModal = ({ popUp, handlePopUpToggle }: Props) => { + const [isCrlCopied, setIsCrlCopied] = useToggle(false); + const { data: crl } = useGetCaCrl((popUp?.caCrl?.data as { caId: string })?.caId || ""); + + useEffect(() => { + let timer: NodeJS.Timeout; + if (isCrlCopied) { + timer = setTimeout(() => setIsCrlCopied.off(), 2000); + } + + return () => clearTimeout(timer); + }, [isCrlCopied]); + + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + return ( + { + handlePopUpToggle("caCrl", isOpen); + }} + > + +
+ {crl && ( + <> + {/*
+

Manual CRL Rotation

+ +
*/} +
+

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/GroupsTab/components/GroupsSection/GroupModal.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx index 818c6eae0..f0ed1c5b7 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupModal.tsx @@ -5,25 +5,19 @@ 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 { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useOrganization, useWorkspace } from "@app/context"; -import { - useAddGroupToWorkspace, - useGetOrganizationGroups, - useGetProjectRoles, - useListWorkspaceGroups, +import { + useAddGroupToWorkspace, + useGetOrganizationGroups, + useGetProjectRoles, + useListWorkspaceGroups } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z.object({ - slug: z.string(), - role: z.string() + slug: z.string(), + role: z.string() }); export type FormData = z.infer; @@ -33,150 +27,146 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["group"]>, state?: boolean) => void; }; -export const GroupModal = ({ - popUp, - handlePopUpToggle -}: Props) => { - const { currentOrg } = useOrganization(); - const { currentWorkspace } = useWorkspace(); +export const GroupModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentOrg } = useOrganization(); + const { currentWorkspace } = useWorkspace(); - const orgId = currentOrg?.id || ""; - const workspaceId = currentWorkspace?.id || ""; - - const { data: groups } = useGetOrganizationGroups(orgId); - const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - - const { data: roles } = useGetProjectRoles(workspaceId); - - const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); - - const filteredGroupMembershipOrgs = useMemo(() => { - const wsGroupIds = new Map(); + const orgId = currentOrg?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - groupMemberships?.forEach((groupMembership) => { - wsGroupIds.set(groupMembership.group.id, true); - }); + const { data: groups } = useGetOrganizationGroups(orgId); + const { data: groupMemberships } = useListWorkspaceGroups(currentWorkspace?.slug || ""); - return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); - }, [groups, groupMemberships]); - - const { - control, - handleSubmit, - reset, - formState: { isSubmitting } - } = useForm({ - resolver: zodResolver(schema) + const { data: roles } = useGetProjectRoles(projectSlug); + + const { mutateAsync: addGroupToWorkspaceMutateAsync } = useAddGroupToWorkspace(); + + const filteredGroupMembershipOrgs = useMemo(() => { + const wsGroupIds = new Map(); + + groupMemberships?.forEach((groupMembership) => { + wsGroupIds.set(groupMembership.group.id, true); + }); + + return (groups || []).filter(({ id }) => !wsGroupIds.has(id)); + }, [groups, groupMemberships]); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ slug, role }: FormData) => { + try { + await addGroupToWorkspaceMutateAsync({ + projectSlug: currentWorkspace?.slug || "", + groupSlug: slug, + role: role || undefined }); - const onFormSubmit = async ({ slug, role }: FormData) => { - try { - await addGroupToWorkspaceMutateAsync({ - projectSlug: currentWorkspace?.slug || "", - groupSlug: slug, - role: role || undefined - }); - - reset(); - handlePopUpToggle("group", false); - - createNotification({ - text: "Successfully added group to project", - type: "success" - }); - - } catch (err) { - createNotification({ - text: "Failed to add group to project", - type: "error" - }); - } + reset(); + handlePopUpToggle("group", false); + + createNotification({ + text: "Successfully added group to project", + type: "success" + }); + } catch (err) { + createNotification({ + text: "Failed to add group to project", + type: "error" + }); } - - return ( - { - handlePopUpToggle("group", isOpen); - reset(); - }} - > - - {filteredGroupMembershipOrgs.length ? ( -
- ( - - - - )} - /> - ( - - - - )} - /> -
- - -
- - ) : ( -
-
- All groups in your organization have already been added to this project. -
- - - -
- )} -
-
- ); -} \ No newline at end of file + }; + + return ( + { + handlePopUpToggle("group", isOpen); + reset(); + }} + > + + {filteredGroupMembershipOrgs.length ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + +
+ + ) : ( +
+
+ All groups in your organization have already been added to this project. +
+ + + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx index 83ea00ede..449c0c95f 100644 --- a/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx +++ b/frontend/src/views/Project/MembersPage/components/GroupsTab/components/GroupsSection/GroupRoles.tsx @@ -201,11 +201,7 @@ export type TMemberRolesProp = { const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; -export const GroupRoles = ({ - roles = [], - disableEdit = false, - groupSlug -}: TMemberRolesProp) => { +export const GroupRoles = ({ roles = [], disableEdit = false, groupSlug }: TMemberRolesProp) => { const { currentWorkspace } = useWorkspace(); const { popUp, handlePopUpToggle } = usePopUp(["editRole"] as const); const [searchRoles, setSearchRoles] = useState(""); @@ -220,9 +216,9 @@ export const GroupRoles = ({ resolver: zodResolver(formSchema) }); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const userRolesGroupBySlug = groupBy(roles, ({ customRoleSlug, role }) => customRoleSlug || role); const updateGroupWorkspaceRole = useUpdateGroupWorkspaceRole(); @@ -317,7 +313,7 @@ export const GroupRoles = ({ icon={faClock} className={twMerge( new Date() > new Date(temporaryAccessEndTime as string) && - "text-red-600" + "text-red-600" )} /> @@ -390,14 +386,14 @@ export const GroupRoles = ({ defaultValue={ userProjectRoleDetails?.isTemporary ? { - isTemporary: true, - temporaryAccessStartTime: - userProjectRoleDetails.temporaryAccessStartTime as string, - temporaryRange: - userProjectRoleDetails.temporaryRange as string, - temporaryAccessEndTime: - userProjectRoleDetails.temporaryAccessEndTime - } + isTemporary: true, + temporaryAccessStartTime: + userProjectRoleDetails.temporaryAccessStartTime as string, + temporaryRange: + userProjectRoleDetails.temporaryRange as string, + temporaryAccessEndTime: + userProjectRoleDetails.temporaryAccessEndTime + } : false } render={({ field }) => ( diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx index 72d39537e..5637b2f1a 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityModal.tsx @@ -30,17 +30,17 @@ type Props = { }; export const IdentityModal = ({ popUp, handlePopUpToggle }: Props) => { - const { currentOrg } = useOrganization(); const { currentWorkspace } = useWorkspace(); const orgId = currentOrg?.id || ""; const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; const { data: identityMembershipOrgs } = useGetIdentityMembershipOrgs(orgId); const { data: identityMemberships } = useGetWorkspaceIdentityMemberships(workspaceId); - const { data: roles } = useGetProjectRoles(workspaceId); + const { data: roles } = useGetProjectRoles(projectSlug); const { mutateAsync: addIdentityToWorkspaceMutateAsync } = useAddIdentityToWorkspace(); diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx index ca7e31464..05aeb1fb0 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/IdentityRbacSection.tsx @@ -65,7 +65,8 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -338,7 +339,7 @@ export const IdentityRbacSection = ({ identityProjectMember, onOpenUpgradeModal type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 6c1d4654d..7f76992bb 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -131,20 +131,17 @@ const SpecificPrivilegeSecretForm = ({ { action: ProjectPermissionActions.Delete, allowed: data.delete }, { action: ProjectPermissionActions.Edit, allowed: data.edit } ]; - const conditions: Record = { environment: data.environmentSlug }; - if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; - } await updateIdentityPrivilege.mutateAsync({ privilegeDetails: { ...data.temporaryAccess, - permissions: actions - .filter(({ allowed }) => allowed) - .map(({ action }) => ({ - action, - subject: ProjectPermissionSub.Secrets, - conditions - })) + privilegePermission: { + actions: actions.filter(({ allowed }) => allowed).map(({ action }) => action), + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: data.environmentSlug, + ...(data.secretPath ? { secretPath: { $glob: data.secretPath } } : {}) + } + } }, privilegeSlug: privilege.slug, identityId, @@ -474,15 +471,13 @@ export const SpecificPrivilegeSection = ({ identityId }: Props) => { if (createIdentityPrivilege.isLoading) return; try { await createIdentityPrivilege.mutateAsync({ - permissions: [ - { - action: ProjectPermissionActions.Read, - subject: ProjectPermissionSub.Secrets, - conditions: { - environment: currentWorkspace?.environments?.[0].slug - } + privilegePermission: { + actions: [ProjectPermissionActions.Read], + subject: ProjectPermissionSub.Secrets, + conditions: { + environment: currentWorkspace?.environments?.[0].slug as string } - ], + }, identityId, projectSlug }); diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx index 04497c62c..5cad54801 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/MemberRbacSection.tsx @@ -65,7 +65,8 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); const workspaceId = currentWorkspace?.id || ""; - const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); + const projectSlug = currentWorkspace?.slug || ""; + const { data: projectRoles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { permission } = useProjectPermission(); const isMemberEditDisabled = permission.cannot( ProjectPermissionActions.Edit, @@ -79,14 +80,14 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) slug: customRoleSlug || role, temporaryAccess: dto.isTemporary ? { - isTemporary: true, - temporaryRange: dto.temporaryRange, - temporaryAccessEndTime: dto.temporaryAccessEndTime, - temporaryAccessStartTime: dto.temporaryAccessStartTime - } + isTemporary: true, + temporaryRange: dto.temporaryRange, + temporaryAccessEndTime: dto.temporaryAccessEndTime, + temporaryAccessStartTime: dto.temporaryAccessStartTime + } : { - isTemporary: dto.isTemporary - } + isTemporary: dto.isTemporary + } })) } }); @@ -191,9 +192,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Timed Access Expired" : `Until ${format( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - "yyyy-MM-dd HH:mm:ss" - )}` + new Date(temporaryAccess.temporaryAccessEndTime || ""), + "yyyy-MM-dd HH:mm:ss" + )}` : "Non expiry access" } > @@ -212,9 +213,9 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) ? isExpired ? "Access Expired" : formatDistance( - new Date(temporaryAccess.temporaryAccessEndTime || ""), - new Date() - ) + new Date(temporaryAccess.temporaryAccessEndTime || ""), + new Date() + ) : "Permanent"} @@ -335,7 +336,7 @@ export const MemberRbacSection = ({ projectMember, onOpenUpgradeModal }: Props) type="submit" className={twMerge( "transition-all", - "opacity-0 cursor-default", + "cursor-default opacity-0", roleForm.formState.isDirty && "cursor-pointer opacity-100" )} isDisabled={!roleForm.formState.isDirty} diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 6fb97063b..7894d0e78 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -43,6 +43,7 @@ import { useProjectPermission, useWorkspace } from "@app/context"; +import { removeTrailingSlash } from "@app/helpers/string"; import { usePopUp } from "@app/hooks"; import { TProjectUserPrivilege, @@ -104,7 +105,9 @@ export const SpecificPrivilegeSecretForm = ({ ? { environmentSlug: privilege.permissions?.[0]?.conditions?.environment, // secret path will be inside $glob operator - secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob || "", + secretPath: privilege.permissions?.[0]?.conditions?.secretPath?.$glob + ? removeTrailingSlash(privilege.permissions?.[0]?.conditions?.secretPath?.$glob) + : "", read: privilege.permissions?.some(({ action }) => action.includes(ProjectPermissionActions.Read) ), @@ -183,7 +186,7 @@ export const SpecificPrivilegeSecretForm = ({ ]; const conditions: Record = { environment: data.environmentSlug }; if (data.secretPath) { - conditions.secretPath = { $glob: data.secretPath }; + conditions.secretPath = { $glob: removeTrailingSlash(data.secretPath) }; } await updateUserPrivilege.mutateAsync({ privilegeId: privilege.id, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx index 6cfec25ea..5eb443fb6 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/ProjectRoleListTab.tsx @@ -3,7 +3,6 @@ import { motion } from "framer-motion"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; import { usePopUp } from "@app/hooks"; -import { TProjectRole } from "@app/hooks/api/roles/types"; import { ProjectRoleList } from "./components/ProjectRoleList"; import { ProjectRoleModifySection } from "./components/ProjectRoleModifySection"; @@ -21,7 +20,7 @@ export const ProjectRoleListTab = withProjectPermission( exit={{ opacity: 0, translateX: 30 }} > handlePopUpClose("editRole")} /> @@ -33,7 +32,7 @@ export const ProjectRoleListTab = withProjectPermission( animate={{ opacity: 1, translateX: 0 }} exit={{ opacity: 0, translateX: -30 }} > - handlePopUpOpen("editRole", role)} /> + handlePopUpOpen("editRole", slug)} /> ); }, diff --git a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index d8a22bdb4..e19e61aec 100644 --- a/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/views/Project/MembersPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -24,7 +24,7 @@ import { useDeleteProjectRole, useGetProjectRoles } from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; type Props = { - onSelectRole: (role?: TProjectRole) => void; + onSelectRole: (slug?: string) => void; }; export const ProjectRoleList = ({ onSelectRole }: Props) => { @@ -32,10 +32,9 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { popUp, handlePopUpOpen, handlePopUpClose } = usePopUp(["deleteRole"] as const); const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; - const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(workspaceId); - console.log(roles); + const { data: roles, isLoading: isRolesLoading } = useGetProjectRoles(projectSlug); const { mutateAsync: deleteRole } = useDeleteProjectRole(); @@ -43,7 +42,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { const { id } = popUp?.deleteRole?.data as TProjectRole; try { await deleteRole({ - projectId: workspaceId, + projectSlug, id }); createNotification({ type: "success", text: "Successfully removed the role" }); @@ -109,7 +108,7 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => { onSelectRole(role)} + onClick={() => onSelectRole(role.slug)} variant="plain" > @@ -146,9 +145,8 @@ export const ProjectRoleList = ({ onSelectRole }: Props) => {
handlePopUpClose("deleteRole")} onDeleteApproved={handleRoleDelete} 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 4a57e2810..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,15 +14,18 @@ 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"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FormControl, Input, Spinner } from "@app/components/v2"; import { ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useCreateProjectRole, useUpdateProjectRole } from "@app/hooks/api"; +import { + useCreateProjectRole, + useGetProjectRoleBySlug, + useUpdateProjectRole +} from "@app/hooks/api"; import { TProjectRole } from "@app/hooks/api/roles/types"; import { MultiEnvProjectPermission } from "./MultiEnvProjectPermission"; @@ -113,21 +117,36 @@ 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; type Props = { - role?: TProjectRole; + roleSlug?: string; onGoBack: VoidFunction; }; -export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { - const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(role?.slug || ""); - const isNewRole = !role?.slug; +export const ProjectRoleModifySection = ({ roleSlug, onGoBack }: Props) => { + const isNonEditable = ["admin", "member", "viewer", "no-access"].includes(roleSlug || ""); + const isNewRole = !roleSlug; - const { currentWorkspace } = useWorkspace(); - const workspaceId = currentWorkspace?.id || ""; + const projectSlug = currentWorkspace?.slug || ""; + const { data: roleDetails, isLoading: isRoleDetailsLoading } = useGetProjectRoleBySlug( + currentWorkspace?.slug || "", + roleSlug as string + ); const { handleSubmit, @@ -137,19 +156,21 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { getValues, control } = useForm({ - defaultValues: role ? { ...role, permissions: rolePermission2Form(role.permissions) } : {}, + values: roleDetails + ? { ...roleDetails, permissions: rolePermission2Form(roleDetails.permissions) } + : ({} as TProjectRole), resolver: zodResolver(formSchema) }); const { mutateAsync: createRole } = useCreateProjectRole(); const { mutateAsync: updateRole } = useUpdateProjectRole(); const handleRoleUpdate = async (el: TFormSchema) => { - if (!role?.id) return; + if (!roleDetails?.id) return; try { await updateRole({ - id: role?.id, - projectId: workspaceId, + id: roleDetails?.id as string, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -169,7 +190,7 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { try { await createRole({ - projectId: workspaceId, + projectSlug, ...el, permissions: formRolePermission2API(el.permissions) }); @@ -181,6 +202,14 @@ export const ProjectRoleModifySection = ({ role, onGoBack }: Props) => { } }; + if (!isNewRole && isRoleDetailsLoading) { + return ( +
+ +
+ ); + } + return (
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 5dc90cf8b..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 @@ -95,10 +97,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const formVal: Record = {}; permissions.forEach((permission) => { - const { - subject: [subject], - action - } = permission; + const { subject: caslSub, action } = permission; + const subject = typeof caslSub === "string" ? caslSub : caslSub[0]; if (!formVal?.[subject]) formVal[subject] = {}; if (subject === "secrets") { @@ -123,7 +123,7 @@ const multiEnvForm2Api = ( const isFullAccess = PERMISSION_ACTIONS.every((action) => formVal?.all?.[action]); // if any of them is set in all push it without any condition PERMISSION_ACTIONS.forEach((action) => { - if (formVal?.all?.[action]) permissions.push({ action, subject: [subject] }); + if (formVal?.all?.[action]) permissions.push({ action, subject }); }); if (!isFullAccess) { @@ -144,7 +144,7 @@ const multiEnvForm2Api = ( if (formVal[slug]?.secretPath) conditions.secretPath = { $glob: formVal?.[slug]?.secretPath }; - permissions.push({ action, subject: [subject], conditions }); + permissions.push({ action, subject, conditions }); } }); }); @@ -161,7 +161,7 @@ export const formRolePermission2API = (formVal: TFormSchema["permissions"]) => { } else { Object.entries(actions).forEach(([action, isAllowed]) => { if (isAllowed) { - permissions.push({ subject: [rule], action }); + permissions.push({ subject: rule, action }); } }); } 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/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx index 0d0c6213a..ca4d3b897 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/SecretApprovalRequest.tsx @@ -212,7 +212,8 @@ export const SecretApprovalRequest = () => { createdAt, policy, reviewers, - status + status, + isReplicated: isReplication } = secretApproval; const isApprover = policy?.approvers?.indexOf(myMembershipId || "") !== -1; const isReviewed = @@ -240,8 +241,9 @@ export const SecretApprovalRequest = () => { Opened {formatDistance(new Date(createdAt), new Date())} ago by{" "} {membersGroupById?.[committerId]?.user?.firstName}{" "} {membersGroupById?.[committerId]?.user?.lastName} ( - {membersGroupById?.[committerId]?.user?.email}){" "} - {isApprover && !isReviewed && status === "open" && "- Review required"} + {membersGroupById?.[committerId]?.user?.email}) + {isReplication && " via replication"} + {isApprover && !isReviewed && status === "open" && " - Review required"}
); diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 80dbe9f73..85d970d4b 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -20,6 +20,7 @@ import { useUpdateSecretApprovalReviewStatus } from "@app/hooks/api"; import { ApprovalStatus, CommitType, TWorkspaceUser } from "@app/hooks/api/types"; +import { formatReservedPaths } from "@app/lib/fn/string"; import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; @@ -185,6 +186,9 @@ export const SecretApprovalRequestChanges = ({
{generateCommitText(secretApprovalRequestDetails.commits)} + {secretApprovalRequestDetails.isReplicated && ( + (replication) + )}
{committer?.user?.firstName} @@ -197,7 +201,11 @@ export const SecretApprovalRequestChanges = ({
-
{secretApprovalRequestDetails.secretPath}
+ +
+ {formatReservedPaths(secretApprovalRequestDetails.secretPath)} +
+
diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx index 61c7cf8c0..bed5d6a84 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/ActionBar.tsx @@ -23,6 +23,7 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { decryptAssymmetric } from "@app/components/utilities/cryptography/crypto"; import { Button, DeleteActionModal, @@ -43,8 +44,9 @@ import { UpgradePlanModal } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context"; +import { interpolateSecrets } from "@app/helpers/secret"; import { usePopUp } from "@app/hooks"; -import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api"; +import { useCreateFolder, useDeleteSecretBatch, useGetUserWsKey } from "@app/hooks/api"; import { DecryptedSecret, TImportedSecrets, WsTag } from "@app/hooks/api/types"; import { debounce } from "@app/lib/fn/debounce"; @@ -112,6 +114,7 @@ export const ActionBar = ({ const { mutateAsync: createFolder } = useCreateFolder(); const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch(); + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); const selectedSecrets = useSelectedSecrets(); const { reset: resetSelectedSecret } = useSelectedSecretActions(); @@ -144,30 +147,59 @@ export const ActionBar = ({ const handleSecretDownload = async () => { const secPriority: Record = {}; const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = []; + + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const workspaceKey = decryptAssymmetric({ + ciphertext: decryptFileKey!.encryptedKey, + nonce: decryptFileKey!.nonce, + publicKey: decryptFileKey!.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + const expandSecrets = interpolateSecrets({ + projectId: workspaceId, + secretEncKey: workspaceKey + }); + + const secretRecord: Record< + string, + { value: string; comment?: string; skipMultilineEncoding?: boolean } + > = {}; + // load up secrets in dashboard - secrets?.forEach(({ key, value, comment }) => { + secrets?.forEach(({ key, value, valueOverride, comment }) => { secPriority[key] = true; - downloadedSecrets.push({ key, value, comment }); + downloadedSecrets.push({ key, value: valueOverride || value, comment }); }); // now load imported secrets with secPriority for (let i = importedSecrets.length - 1; i >= 0; i -= 1) { - importedSecrets[i].secrets.forEach(({ key, value, comment }) => { + importedSecrets[i].secrets.forEach(({ key, value, valueOverride, comment }) => { if (secPriority?.[key]) return; - downloadedSecrets.unshift({ key, value, comment }); + downloadedSecrets.unshift({ key, value: valueOverride || value, comment }); secPriority[key] = true; }); } + downloadedSecrets.forEach((secret) => { + secretRecord[secret.key] = { + value: secret.value, + comment: secret.comment + }; + }); + + await expandSecrets(secretRecord); + const file = downloadedSecrets .sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase())) .reduce( - (prev, { key, value, comment }, index) => + (prev, { key, comment }, index) => prev + (comment - ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n` - : `${key}=${value}\n`), + ? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${secretRecord[key].value}\n` + : `${key}=${secretRecord[key].value}\n`), "" ); + const blob = new Blob([file], { type: "text/plain;charset=utf-8" }); FileSaver.saveAs(blob, `${environment}.env`); }; @@ -450,6 +482,7 @@ export const ActionBar = ({ environment={environment} workspaceId={workspaceId} secretPath={secretPath} + onUpgradePlan={() => handlePopUpOpen("upgradePlan")} isOpen={popUp.addSecretImport.isOpen} onClose={() => handlePopUpClose("addSecretImport")} onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx index a6dfe7c0e..7f9f2a4fe 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx @@ -4,9 +4,16 @@ import { AxiosError } from "axios"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { + Button, + FormControl, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; import { SecretPathInput } from "@app/components/v2/SecretPathInput"; -import { useWorkspace } from "@app/context"; +import { useSubscription, useWorkspace } from "@app/context"; import { useCreateSecretImport } from "@app/hooks/api"; const typeSchema = z.object({ @@ -16,7 +23,8 @@ const typeSchema = z.object({ .trim() .transform((val) => typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val - ) + ), + isReplication: z.boolean().default(false) }); type TFormSchema = z.infer; @@ -29,6 +37,7 @@ type Props = { isOpen?: boolean; onClose: () => void; onTogglePopUp: (isOpen: boolean) => void; + onUpgradePlan: () => void; }; export const CreateSecretImportForm = ({ @@ -37,7 +46,8 @@ export const CreateSecretImportForm = ({ secretPath = "/", isOpen, onClose, - onTogglePopUp + onTogglePopUp, + onUpgradePlan }: Props) => { const { handleSubmit, @@ -49,18 +59,26 @@ export const CreateSecretImportForm = ({ const { currentWorkspace } = useWorkspace(); const environments = currentWorkspace?.environments || []; const selectedEnvironment = watch("environment"); + const { subscription } = useSubscription(); const { mutateAsync: createSecretImport } = useCreateSecretImport(); const handleFormSubmit = async ({ environment: importedEnv, - secretPath: importedSecPath + secretPath: importedSecPath, + isReplication }: TFormSchema) => { try { + if (isReplication && !subscription?.secretApproval) { + onUpgradePlan(); + return; + } + await createSecretImport({ environment, projectId: workspaceId, path: secretPath, + isReplication, import: { environment: importedEnv, path: importedSecPath @@ -70,7 +88,8 @@ export const CreateSecretImportForm = ({ reset(); createNotification({ type: "success", - text: "Successfully linked" + text: `Successfully linked. ${isReplication ? "Please refresh the dashboard to view changes" : "" + }` }); } catch (err) { console.error(err); @@ -127,7 +146,31 @@ export const CreateSecretImportForm = ({ )} /> - + ( + + + + )} + />
+ )} + + {!isOnlyAdminMember && ( )} - +
+ { buttonText="Delete Project" onDeleteApproved={handleDeleteWorkspaceSubmit} /> + + handlePopUpToggle("leaveWorkspace", isOpen)} + deleteKey="confirm" + buttonText="Leave Project" + onLeaveApproved={handleLeaveWorkspaceSubmit} + />
); }; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/PointInTimeVersionLimitSection.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/PointInTimeVersionLimitSection.tsx new file mode 100644 index 000000000..0ce5c7058 --- /dev/null +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/PointInTimeVersionLimitSection/PointInTimeVersionLimitSection.tsx @@ -0,0 +1,92 @@ +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 } from "@app/components/v2"; +import { useProjectPermission, useWorkspace } from "@app/context"; +import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; +import { useUpdateWorkspaceVersionLimit } from "@app/hooks/api/workspace/queries"; + +const formSchema = z.object({ + pitVersionLimit: z.coerce.number().min(1).max(100) +}); + +type TForm = z.infer; + +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/ShareSecretPage/ShareSecretPage.tsx b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx new file mode 100644 index 000000000..95861ec61 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/ShareSecretPage.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ShareSecretSection } from "./components"; + +export const ShareSecretPage = () => { + return ( +
+
+
+

Secret Sharing

+

Share secrets securely using a shareable link

+
+ +
+ +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx new file mode 100644 index 000000000..f8201fb9e --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretForm.tsx @@ -0,0 +1,229 @@ +import crypto from "crypto"; + +import { Controller } from "react-hook-form"; +import { AxiosError } from "axios"; +import * as yup from "yup"; + +import { createNotification } from "@app/components/notifications"; +import { encryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { useCreatePublicSharedSecret, useCreateSharedSecret } from "@app/hooks/api/secretSharing"; + +const schema = yup.object({ + value: yup.string().max(10000).required().label("Shared Secret Value"), + expiresAfterViews: yup.number().min(1).required().label("Expires After Views"), + expiresInValue: yup.number().min(1).required().label("Expiration Value"), + expiresInUnit: yup.string().required().label("Expiration Unit") +}); + +export type FormData = yup.InferType; + +export const AddShareSecretForm = ({ + isPublic, + inModal, + handleSubmit, + control, + isSubmitting, + setNewSharedSecret +}: { + isPublic: boolean; + inModal: boolean; + handleSubmit: any; + control: any; + isSubmitting: boolean; + setNewSharedSecret: (value: string) => void; +}) => { + const publicSharedSecretCreator = useCreatePublicSharedSecret(); + const privateSharedSecretCreator = useCreateSharedSecret(); + const createSharedSecret = isPublic ? publicSharedSecretCreator : privateSharedSecretCreator; + + const expirationUnitsAndActions = [ + { + unit: "Minutes", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setMinutes(expiresAt.getMinutes() + expiresInValue) + }, + { + unit: "Hours", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setHours(expiresAt.getHours() + expiresInValue) + }, + { + unit: "Days", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue) + }, + { + unit: "Weeks", + action: (expiresAt: Date, expiresInValue: number) => + expiresAt.setDate(expiresAt.getDate() + expiresInValue * 7) + } + ]; + const onFormSubmit = async ({ + value, + expiresInValue, + expiresInUnit, + expiresAfterViews + }: FormData) => { + try { + const key = crypto.randomBytes(16).toString("hex"); + const hashedHex = crypto.createHash("sha256").update(key).digest("hex"); + const { ciphertext, iv, tag } = encryptSymmetric({ + plaintext: value, + key + }); + + const expiresAt = new Date(); + const updateExpiresAt = expirationUnitsAndActions.find( + (item) => item.unit === expiresInUnit + )?.action; + if (updateExpiresAt && expiresInValue) { + updateExpiresAt(expiresAt, expiresInValue); + } + + const { id } = await createSharedSecret.mutateAsync({ + encryptedValue: ciphertext, + iv, + tag, + hashedHex, + expiresAt, + expiresAfterViews + }); + setNewSharedSecret( + `${window.location.origin}/shared/secret/${id}?key=${encodeURIComponent( + hashedHex + )}-${encodeURIComponent(key)}` + ); + + createNotification({ + text: "Successfully created a shared secret", + type: "success" + }); + } catch (err) { + console.error(err); + const axiosError = err as AxiosError; + if (axiosError?.response?.status === 401) { + createNotification({ + text: "You do not have access to create shared secrets", + type: "error" + }); + } else { + createNotification({ + text: "Failed to create a shared secret", + type: "error" + }); + } + } + }; + return ( +
+
+
+ ( + + + + )} + /> +
+
+
+ ( + + + + )} + /> +
+
+

OR

+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+
+
+
+
+ + {inModal && ( + + + + )} +
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx new file mode 100644 index 000000000..d30432982 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/AddShareSecretModal.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { AddShareSecretForm } from "./AddShareSecretForm"; +import { ViewAndCopySharedSecret } from "./ViewAndCopySharedSecret"; + +const schema = yup.object({ + value: yup.string().max(10000).required().label("Shared Secret Value"), + expiresAfterViews: yup.number().min(1).required().label("Expires After Views"), + expiresInValue: yup.number().min(1).required().label("Expiration Value"), + expiresInUnit: yup.string().required().label("Expiration Unit") +}); + +export type FormData = yup.InferType; + +type Props = { + popUp: UsePopUpState<["createSharedSecret"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["createSharedSecret"]>, + state?: boolean + ) => void; + isPublic: boolean; + inModal: boolean; +}; + +export const AddShareSecretModal = ({ popUp, handlePopUpToggle, isPublic, inModal }: Props) => { + const { + control, + reset, + handleSubmit, + formState: { isSubmitting } + } = useForm({ + resolver: yupResolver(schema) + }); + + const [newSharedSecret, setNewSharedSecret] = useState(""); + const hasSharedSecret = Boolean(newSharedSecret); + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false + }); + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(newSharedSecret); + setIsUrlCopied(true); + }; + useEffect(() => { + if (isUrlCopied) { + setTimeout(() => setIsUrlCopied(false), 2000); + } + }, [isUrlCopied]); + + // eslint-disable-next-line no-nested-ternary + return inModal ? ( + { + handlePopUpToggle("createSharedSecret", open); + reset(); + setNewSharedSecret(""); + }} + > + + {!hasSharedSecret ? ( + + ) : ( + + )} + + + ) : !hasSharedSecret ? ( + + ) : ( + + ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx new file mode 100644 index 000000000..a450d61f5 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretSection.tsx @@ -0,0 +1,81 @@ +import Head from "next/head"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { usePopUp } from "@app/hooks"; +import { useDeleteSharedSecret } from "@app/hooks/api/secretSharing"; + +import { AddShareSecretModal } from "./AddShareSecretModal"; +import { ShareSecretsTable } from "./ShareSecretsTable"; + +type DeleteModalData = { name: string; id: string }; + +export const ShareSecretSection = () => { + const deleteSharedSecret = useDeleteSharedSecret(); + const { popUp, handlePopUpToggle, handlePopUpClose, handlePopUpOpen } = usePopUp([ + "createSharedSecret", + "deleteSharedSecretConfirmation" + ] as const); + + const onDeleteApproved = async () => { + try { + deleteSharedSecret.mutateAsync({ + sharedSecretId: (popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.id + }); + createNotification({ + text: "Successfully deleted shared secret", + type: "success" + }); + + handlePopUpClose("deleteSharedSecretConfirmation"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete shared secret", + type: "error" + }); + } + }; + + return ( +
+ + Secret Sharing + + + +
+

Shared Secrets

+ + +
+ + + handlePopUpToggle("deleteSharedSecretConfirmation", isOpen)} + deleteKey={(popUp?.deleteSharedSecretConfirmation?.data as DeleteModalData)?.name} + onClose={() => handlePopUpClose("deleteSharedSecretConfirmation")} + onDeleteApproved={onDeleteApproved} + /> +
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx new file mode 100644 index 000000000..f731fe19b --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsRow.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from "react"; +import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, Td, Tr } from "@app/components/v2"; +import { TSharedSecret } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const formatDate = (date: Date): string => (date ? new Date(date).toUTCString() : ""); + +const isExpired = (expiresAt: Date | number | undefined): boolean => { + if (typeof expiresAt === "number") { + return expiresAt <= 0; + } + if (expiresAt instanceof Date) { + return new Date(expiresAt) < new Date(); + } + return false; +}; + +const getValidityStatusText = (expiresAt: Date): string => + isExpired(expiresAt) ? "Expired " : "Valid for "; + +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" + }`; +}; + +export const ShareSecretsRow = ({ + row, + handlePopUpOpen, + onSecretExpiration +}: { + row: TSharedSecret; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; + onSecretExpiration: (expiredSecretId: string) => void; +}) => { + const [currentTime, setCurrentTime] = useState(new Date()); + + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentTime(new Date()); + }, 1000); + + return () => clearInterval(intervalId); + }, []); + + useEffect(() => { + if (isExpired(row.expiresAt || row.expiresAfterViews)) { + onSecretExpiration(row.id); + } + }, [isExpired(row.expiresAt || row.expiresAfterViews)]); + + return ( + + {`${row.encryptedValue.substring(0, 5)}...`} + +

{timeAgo(row.createdAt, currentTime)}

+

{formatDate(row.createdAt)}

+ + + <> +

+ {getValidityStatusText(row.expiresAt!) + timeAgo(row.expiresAt!, currentTime)} +

+

{formatDate(row.expiresAt!)}

+ + + +

+ {row.expiresAfterViews} +

+ + + + handlePopUpOpen("deleteSharedSecretConfirmation", { + name: "delete", + id: row.id + }) + } + colorSchema="danger" + ariaLabel="delete" + > + + + + + ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx new file mode 100644 index 000000000..fe02af864 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ShareSecretsTable.tsx @@ -0,0 +1,76 @@ +import { faKey } from "@fortawesome/free-solid-svg-icons"; + +import { + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useGetSharedSecrets } from "@app/hooks/api/secretSharing"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { ShareSecretsRow } from "./ShareSecretsRow"; + +type Props = { + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["deleteSharedSecretConfirmation"]>, + { + name, + id + }: { + name: string; + id: string; + } + ) => void; +}; + +export const ShareSecretsTable = ({ handlePopUpOpen }: Props) => { + const { isLoading, data = [] } = useGetSharedSecrets(); + + let tableData = data.filter( + (secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0 + ); + const handleSecretExpiration = () => { + tableData = data.filter( + (secret) => new Date(secret.expiresAt) > new Date() && secret.expiresAfterViews > 0 + ); + }; + + return ( + + + + + + + + + {isLoading && } + {!isLoading && + tableData && + tableData.map((row) => ( + + ))} + {!isLoading && tableData && tableData?.length === 0 && ( + + + + )} + +
Encrypted Secret Created Valid Until Views Left +
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx new file mode 100644 index 000000000..1efec0c36 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/ViewAndCopySharedSecret.tsx @@ -0,0 +1,37 @@ +import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton } from "@app/components/v2"; + +export const ViewAndCopySharedSecret = ({ + inModal, + newSharedSecret, + isUrlCopied, + copyUrlToClipboard +}: { + inModal: boolean; + newSharedSecret: string; + isUrlCopied: boolean; + copyUrlToClipboard: () => void; +}) => { + return ( +
+
+
+

{newSharedSecret}

+ + + + Click to Copy + + +
+
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPage/components/index.tsx b/frontend/src/views/ShareSecretPage/components/index.tsx new file mode 100644 index 000000000..64a0c2774 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/components/index.tsx @@ -0,0 +1 @@ +export { ShareSecretSection } from "./ShareSecretSection"; diff --git a/frontend/src/views/ShareSecretPage/index.tsx b/frontend/src/views/ShareSecretPage/index.tsx new file mode 100644 index 000000000..fa8198494 --- /dev/null +++ b/frontend/src/views/ShareSecretPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPage } from "./ShareSecretPage"; diff --git a/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx new file mode 100644 index 000000000..e21400817 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/ShareSecretPublicPage.tsx @@ -0,0 +1,161 @@ +import { useEffect, useMemo } from "react"; +import Head from "next/head"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { decryptSymmetric } from "@app/components/utilities/cryptography/crypto"; +import { Button } from "@app/components/v2"; +import { usePopUp, useTimedReset } from "@app/hooks"; +import { useGetActiveSharedSecretByIdAndHashedHex } from "@app/hooks/api/secretSharing"; + +import { AddShareSecretModal } from "../ShareSecretPage/components/AddShareSecretModal"; +import { SecretTable } from "./components"; + +export const ShareSecretPublicPage = ({ isNewSession }: { isNewSession: boolean }) => { + const router = useRouter(); + const { id, key: urlEncodedPublicKey } = router.query; + const [hashedHex, key] = urlEncodedPublicKey + ? urlEncodedPublicKey.toString().split("-") + : ["", ""]; + + const publicKey = decodeURIComponent(urlEncodedPublicKey as string); + const { isLoading, data } = useGetActiveSharedSecretByIdAndHashedHex( + id as string, + hashedHex as string + ); + + const decryptedSecret = useMemo(() => { + if (data && data.encryptedValue && publicKey) { + const res = decryptSymmetric({ + ciphertext: data.encryptedValue, + iv: data.iv, + tag: data.tag, + key + }); + return res; + } + return ""; + }, [data, publicKey]); + + const [isUrlCopied, , setIsUrlCopied] = useTimedReset({ + initialState: false + }); + + useEffect(() => { + if (isUrlCopied) { + setTimeout(() => setIsUrlCopied(false), 2000); + } + }, [isUrlCopied]); + + const copyUrlToClipboard = () => { + navigator.clipboard.writeText(decryptedSecret); + setIsUrlCopied(true); + }; + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["createSharedSecret"] as const); + + return ( +
+ + Secret Shared | Infisical + + +
+
+ + Infisical logo + +
+

+ {id ? "Someone shared a secret on Infisical with you." : "Share Secrets with Infisical"} +

+
+ {id && ( + + )} +
+ + {isNewSession && ( + + )} + +
+
+
+ +
+ {!isNewSession && ( +
+ +
+ )} +
+

+ Safe, Secure, & Open Source +

+

+ Infisical is the #1 {" "} + + open source + {" "} + secrets management platform for developers.
+

+ Infisical Secret Sharing uses end-to-end encrypted architecture to ensure that your secrets are truly private, even from our servers. +

+ + + Learn More + + +
+
+
+

+ © 2024{" "} + + Infisical + + . All rights reserved. +
+ 156 2nd st, 3rd Floor, San Francisco, California, 94105, United States. 🇺🇸 +

+
+ +
+
+ ); +}; diff --git a/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx new file mode 100644 index 000000000..d2a17e566 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/SecretTable.tsx @@ -0,0 +1,48 @@ +import { faCheck, faCopy, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, IconButton, Td, Tr } from "@app/components/v2"; + +type Props = { + isLoading: boolean; + decryptedSecret: string; + isUrlCopied: boolean; + copyUrlToClipboard: () => void; +}; + +export const SecretTable = ({ + isLoading, + decryptedSecret, + isUrlCopied, + copyUrlToClipboard +}: Props) => ( +
+ {isLoading &&
Loading...
} + {!isLoading && !decryptedSecret && ( + + + + + + )} + {!isLoading && decryptedSecret && ( +
+
+
+ {decryptedSecret} +
+
+ + Copy + +
+ )} +
+); diff --git a/frontend/src/views/ShareSecretPublicPage/components/index.tsx b/frontend/src/views/ShareSecretPublicPage/components/index.tsx new file mode 100644 index 000000000..530af7c2f --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/components/index.tsx @@ -0,0 +1 @@ +export { SecretTable } from "./SecretTable"; diff --git a/frontend/src/views/ShareSecretPublicPage/index.tsx b/frontend/src/views/ShareSecretPublicPage/index.tsx new file mode 100644 index 000000000..778e8ee58 --- /dev/null +++ b/frontend/src/views/ShareSecretPublicPage/index.tsx @@ -0,0 +1 @@ +export { ShareSecretPublicPage } from "./ShareSecretPublicPage"; diff --git a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx index 69168e0af..c65317662 100644 --- a/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx +++ b/frontend/src/views/Signup/components/UserInfoSSOStep/UserInfoSSOStep.tsx @@ -2,14 +2,10 @@ import crypto from "crypto"; import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { faInfoCircle, faXmark } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import jsrp from "jsrp"; import nacl from "tweetnacl"; import { encodeBase64 } from "tweetnacl-util"; -import InputField from "@app/components/basic/InputField"; -import checkPassword from "@app/components/utilities/checks/password/checkPassword"; import Aes256Gcm from "@app/components/utilities/cryptography/aes-256-gcm"; import { deriveArgonKey } from "@app/components/utilities/cryptography/crypto"; import { saveTokenToLocalStorage } from "@app/components/utilities/saveTokenToLocalStorage"; @@ -32,17 +28,6 @@ type Props = { providerAuthToken?: string; }; -type Errors = { - tooShort?: string; - tooLong?: string; - noLetterChar?: string; - noNumOrSpecialChar?: string; - repeatedChar?: string; - escapeChar?: string; - lowEntropy?: string; - breached?: string; -}; - /** * This is the step of the sign up flow where people provife their name/surname and password * @param {object} obj @@ -69,12 +54,13 @@ export const UserInfoSSOStep = ({ const [organizationName, setOrganizationName] = useState(""); const [organizationNameError, setOrganizationNameError] = useState(false); const [attributionSource, setAttributionSource] = useState(""); - const [errors, setErrors] = useState({}); const [isLoading, setIsLoading] = useState(false); const { t } = useTranslation(); const { mutateAsync: selectOrganization } = useSelectOrganization(); useEffect(() => { + const randomPassword = crypto.randomBytes(32).toString("hex"); + setPassword(randomPassword); if (providerOrganizationName !== undefined) { setOrganizationName(providerOrganizationName); } @@ -98,11 +84,6 @@ export const UserInfoSSOStep = ({ setOrganizationNameError(false); } - errorCheck = await checkPassword({ - password, - setErrors - }); - if (!errorCheck) { // Generate a random pair of a public and a private key const pair = nacl.box.keyPair(); @@ -158,6 +139,7 @@ export const UserInfoSSOStep = ({ const response = await completeAccountSignup({ email: username, + password, firstName: name.split(" ")[0], lastName: name.split(" ").slice(1).join(" "), protectedKey, @@ -214,6 +196,12 @@ export const UserInfoSSOStep = ({ } }; + useEffect(() => { + if (password && providerOrganizationName) { + signupErrorCheck(); + } + }, [providerOrganizationName, password]); + return (

@@ -272,53 +260,6 @@ export const UserInfoSSOStep = ({ />

)} -
- { - setPassword(pass); - await checkPassword({ - password: pass, - setErrors - }); - }} - type="password" - value={password} - isRequired - error={Object.keys(errors).length > 0} - autoComplete="new-password" - id="new-password" - /> -
- - Infisical Password is used as part of the encryption mechanism so that even the - authentication provider is not able to access your secrets. -
- {Object.keys(errors).length > 0 && ( -
-
- {t("section.password.validate-base")} -
- {Object.keys(errors).map((key) => { - if (errors[key as keyof Errors]) { - return ( -
-
- -
-

{errors[key as keyof Errors]}

-
- ); - } - - return null; - })} -
- )} -
+ + +
)} 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/frontend/src/views/admin/SignUpPage/SignUpPage.tsx b/frontend/src/views/admin/SignUpPage/SignUpPage.tsx index bdbc8ac86..2db9041f8 100644 --- a/frontend/src/views/admin/SignUpPage/SignUpPage.tsx +++ b/frontend/src/views/admin/SignUpPage/SignUpPage.tsx @@ -73,6 +73,7 @@ export const SignUpPage = () => { const { privateKey, ...userPass } = await generateUserPassKey(email, password); const res = await createAdminUser({ email, + password, firstName, lastName, ...userPass diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index 9c4f83248..88fd68827 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.1 +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.0" +appVersion: "v0.6.1" 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 8054b1b0c..001ef4b10 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.1 # fixed to prevent accidental upgrade + tag: v0.6.1 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..788888101 100644 --- a/k8-operator/controllers/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret_controller.go @@ -5,14 +5,21 @@ import ( "fmt" "time" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + controllerUtil "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/source" - "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 +34,84 @@ 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) handleManagedSecretDeletion(secret client.Object) []ctrl.Request { + var requests []ctrl.Request + infisicalSecrets := &secretsv1alpha1.InfisicalSecretList{} + err := r.List(context.Background(), infisicalSecrets) + if err != nil { + fmt.Printf("unable to list Infisical Secrets from cluster because [err=%v]", err) + return requests + } + + for _, infisicalSecret := range infisicalSecrets.Items { + if secret.GetName() == infisicalSecret.Spec.ManagedSecretReference.SecretName && + secret.GetNamespace() == infisicalSecret.Spec.ManagedSecretReference.SecretNamespace { + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: infisicalSecret.Namespace, + Name: infisicalSecret.Name, + }, + }) + fmt.Printf("\nManaged secret deleted in resource %s: [name=%v] [namespace=%v]\n", infisicalSecret.Name, secret.GetName(), secret.GetNamespace()) + } + } + + return requests +} + 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 +125,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 @@ -106,9 +185,18 @@ func (r *InfisicalSecretReconciler) Reconcile(ctx context.Context, req ctrl.Requ }, nil } -// SetupWithManager sets up the controller with the Manager. func (r *InfisicalSecretReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&secretsv1alpha1.InfisicalSecret{}). + Watches( + &source.Kind{Type: &corev1.Secret{}}, + handler.EnqueueRequestsFromMapFunc(r.handleManagedSecretDeletion), + builder.WithPredicates(predicate.Funcs{ + // Always return true to ensure we process all delete events + DeleteFunc: func(e event.DeleteEvent) bool { + return true + }, + }), + ). Complete(r) } 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/api/api.go b/k8-operator/packages/api/api.go index 233925dda..5ca3bdba3 100644 --- a/k8-operator/packages/api/api.go +++ b/k8-operator/packages/api/api.go @@ -156,6 +156,7 @@ func CallGetDecryptedSecretsV3(httpClient *resty.Client, request GetDecryptedSec R(). SetResult(&decryptedSecretsResponse). SetHeader("User-Agent", USER_AGENT_NAME). + SetQueryParam("include_imports", "true"). SetQueryParam("secretPath", request.SecretPath). SetQueryParam("workspaceSlug", request.ProjectSlug). SetQueryParam("environment", request.Environment) @@ -163,6 +164,9 @@ func CallGetDecryptedSecretsV3(httpClient *resty.Client, request GetDecryptedSec if request.Recursive { req.SetQueryParam("recursive", "true") } + if request.ExpandSecretReferences { + req.SetQueryParam("expandSecretReferences", "true") + } response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", API_HOST_URL)) diff --git a/k8-operator/packages/api/models.go b/k8-operator/packages/api/models.go index ccb8d0d00..8439e4918 100644 --- a/k8-operator/packages/api/models.go +++ b/k8-operator/packages/api/models.go @@ -84,6 +84,13 @@ type ImportedSecretV3 struct { Secrets []EncryptedSecretV3 `json:"secrets"` } +type ImportedRawSecretV3 struct { + Environment string `json:"environment"` + FolderId string `json:"folderId"` + SecretPath string `json:"secretPath"` + Secrets []DecryptedSecretV3 `json:"secrets"` +} + type GetEncryptedSecretsV3Response struct { Secrets []EncryptedSecretV3 `json:"secrets"` ImportedSecrets []ImportedSecretV3 `json:"imports,omitempty"` @@ -92,17 +99,19 @@ type GetEncryptedSecretsV3Response struct { } type GetDecryptedSecretsV3Response struct { - Secrets []DecryptedSecretV3 `json:"secrets"` - ETag string `json:"ETag,omitempty"` - Modified bool `json:"modified,omitempty"` + Secrets []DecryptedSecretV3 `json:"secrets"` + ETag string `json:"ETag,omitempty"` + Modified bool `json:"modified,omitempty"` + Imports []ImportedRawSecretV3 `json:"imports,omitempty"` } type GetDecryptedSecretsV3Request struct { - ProjectSlug string `json:"workspaceSlug"` - Environment string `json:"environment"` - SecretPath string `json:"secretPath"` - Recursive bool `json:"recursive"` - ETag string `json:"etag,omitempty"` + ProjectSlug string `json:"workspaceSlug"` + Environment string `json:"environment"` + SecretPath string `json:"secretPath"` + Recursive bool `json:"recursive"` + ExpandSecretReferences bool `json:"expandSecretReferences"` + ETag string `json:"etag,omitempty"` } type GetServiceTokenDetailsResponse struct { 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 290e1340c..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,28 +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{ - ProjectSlug: secretScope.ProjectSlug, - Environment: secretScope.EnvSlug, - Recursive: secretScope.Recursive, - SecretPath: secretScope.SecretsPath, - ETag: etag, + secrets, err := infisicalClient.Secrets().List(infisical.ListSecretsOptions{ + ProjectSlug: secretScope.ProjectSlug, + Environment: secretScope.EnvSlug, + Recursive: secretScope.Recursive, + SecretPath: secretScope.SecretsPath, + IncludeImports: true, + ExpandSecretReferences: true, }) 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, @@ -80,9 +79,11 @@ func GetPlainTextSecretsViaUniversalAuth(accessToken string, etag string, secret }) } - return secrets, model.RequestUpdateUpdateDetails{ - Modified: secretsResponse.Modified, - ETag: secretsResponse.ETag, + newEtag := crypto.ComputeEtag([]byte(fmt.Sprintf("%v", environmentVariables))) + + return environmentVariables, model.RequestUpdateUpdateDetails{ + Modified: etag != newEtag, + ETag: newEtag, }, nil } @@ -435,3 +436,32 @@ func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []model.SingleEn return secrets, nil } + +func MergeRawImportedSecrets(secrets []model.SingleEnvironmentVariable, importedSecrets []api.ImportedRawSecretV3) []model.SingleEnvironmentVariable { + if importedSecrets == nil { + return secrets + } + + hasOverriden := make(map[string]bool) + for _, sec := range secrets { + hasOverriden[sec.Key] = true + } + + for i := len(importedSecrets) - 1; i >= 0; i-- { + importSec := importedSecrets[i] + + for _, sec := range importSec.Secrets { + if _, ok := hasOverriden[sec.SecretKey]; !ok { + secrets = append(secrets, model.SingleEnvironmentVariable{ + Key: sec.SecretKey, + Value: sec.SecretValue, + Type: sec.Type, + ID: sec.ID, + }) + hasOverriden[sec.SecretKey] = true + } + } + } + + return secrets +} diff --git a/standalone-entrypoint.sh b/standalone-entrypoint.sh index 3f88260e9..8fcdb26e7 100755 --- a/standalone-entrypoint.sh +++ b/standalone-entrypoint.sh @@ -1,5 +1,7 @@ #!/bin/sh +update-ca-certificates + cd frontend-build scripts/initialize-standalone-build.sh