diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index c592daf81..c386b132e 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -1,132 +1,147 @@ name: Build and release CLI on: - workflow_dispatch: + workflow_dispatch: - push: - # run only against tags - tags: - - "infisical-cli/v*.*.*" + push: + # run only against tags + tags: + - "infisical-cli/v*.*.*" permissions: - contents: write + contents: write jobs: - cli-integration-tests: - name: Run tests before deployment - uses: ./.github/workflows/run-cli-tests.yml - secrets: - CLI_TESTS_UA_CLIENT_ID: ${{ secrets.CLI_TESTS_UA_CLIENT_ID }} - CLI_TESTS_UA_CLIENT_SECRET: ${{ secrets.CLI_TESTS_UA_CLIENT_SECRET }} - 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 }} + cli-integration-tests: + name: Run tests before deployment + uses: ./.github/workflows/run-cli-tests.yml + secrets: + CLI_TESTS_UA_CLIENT_ID: ${{ secrets.CLI_TESTS_UA_CLIENT_ID }} + CLI_TESTS_UA_CLIENT_SECRET: ${{ secrets.CLI_TESTS_UA_CLIENT_SECRET }} + 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 }} - npm-release: - runs-on: ubuntu-latest + npm-release: + runs-on: ubuntu-latest + env: + working-directory: ./npm + needs: + - cli-integration-tests + - goreleaser + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Extract version + run: | + VERSION=$(echo ${{ github.ref_name }} | sed 's/infisical-cli\/v//') + echo "Version extracted: $VERSION" + echo "CLI_VERSION=$VERSION" >> $GITHUB_ENV + + - name: Print version + run: echo ${{ env.CLI_VERSION }} + + - name: Setup Node + uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 + with: + node-version: 20 + cache: "npm" + cache-dependency-path: ./npm/package-lock.json + - name: Install dependencies + working-directory: ${{ env.working-directory }} + run: npm install --ignore-scripts + + - name: Set NPM version + working-directory: ${{ env.working-directory }} + run: npm version ${{ env.CLI_VERSION }} --allow-same-version --no-git-tag-version + + - name: Setup NPM + working-directory: ${{ env.working-directory }} + run: | + echo 'registry="https://registry.npmjs.org/"' > ./.npmrc + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ./.npmrc + + echo 'registry="https://registry.npmjs.org/"' > ~/.npmrc + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc env: - working-directory: ./npm - needs: - - cli-integration-tests - - goreleaser - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Extract version - run: | - VERSION=$(echo ${{ github.ref_name }} | sed 's/infisical-cli\/v//') - echo "Version extracted: $VERSION" - echo "CLI_VERSION=$VERSION" >> $GITHUB_ENV + - name: Pack NPM + working-directory: ${{ env.working-directory }} + run: npm pack - - name: Print version - run: echo ${{ env.CLI_VERSION }} + - name: Publish NPM + working-directory: ${{ env.working-directory }} + run: npm publish --tarball=./infisical-sdk-${{github.ref_name}} --access public --registry=https://registry.npmjs.org/ + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - name: Setup Node - uses: actions/setup-node@8f152de45cc393bb48ce5d89d36b731f54556e65 # v4.0.0 - with: - node-version: 20 - cache: "npm" - cache-dependency-path: ./npm/package-lock.json - - name: Install dependencies - working-directory: ${{ env.working-directory }} - run: npm install --ignore-scripts - - - name: Set NPM version - working-directory: ${{ env.working-directory }} - run: npm version ${{ env.CLI_VERSION }} --allow-same-version --no-git-tag-version - - - name: Setup NPM - working-directory: ${{ env.working-directory }} - run: | - echo 'registry="https://registry.npmjs.org/"' > ./.npmrc - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ./.npmrc - - echo 'registry="https://registry.npmjs.org/"' > ~/.npmrc - echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Pack NPM - working-directory: ${{ env.working-directory }} - run: npm pack - - - name: Publish NPM - working-directory: ${{ env.working-directory }} - run: npm publish --tarball=./infisical-sdk-${{github.ref_name}} --access public --registry=https://registry.npmjs.org/ - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - goreleaser: - runs-on: ubuntu-latest - needs: [cli-integration-tests] - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: 🐋 Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: 🔧 Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - run: git fetch --force --tags - - run: echo "Ref name ${{github.ref_name}}" - - uses: actions/setup-go@v3 - with: - go-version: ">=1.19.3" - cache: true - cache-dependency-path: cli/go.sum - - name: Setup for libssl1.0-dev - run: | - echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list - sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32 - sudo apt update - sudo apt-get install -y libssl1.0-dev - - name: OSXCross for CGO Support - run: | - mkdir ../../osxcross - git clone https://github.com/plentico/osxcross-target.git ../../osxcross/target - - uses: goreleaser/goreleaser-action@v4 - with: - distribution: goreleaser-pro - version: v1.26.2-pro - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} - POSTHOG_API_KEY_FOR_CLI: ${{ secrets.POSTHOG_API_KEY_FOR_CLI }} - FURY_TOKEN: ${{ secrets.FURYPUSHTOKEN }} - AUR_KEY: ${{ secrets.AUR_KEY }} - GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} - - uses: actions/setup-python@v4 - - run: pip install --upgrade cloudsmith-cli - - name: Publish to CloudSmith - run: sh cli/upload_to_cloudsmith.sh - env: - CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + goreleaser: + runs-on: ubuntu-latest + needs: [cli-integration-tests] + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: 🐋 Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - run: git fetch --force --tags + - run: echo "Ref name ${{github.ref_name}}" + - uses: actions/setup-go@v3 + with: + go-version: ">=1.19.3" + cache: true + cache-dependency-path: cli/go.sum + - name: Setup for libssl1.0-dev + run: | + echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list + sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32 + sudo apt update + sudo apt-get install -y libssl1.0-dev + - name: OSXCross for CGO Support + run: | + mkdir ../../osxcross + git clone https://github.com/plentico/osxcross-target.git ../../osxcross/target + - uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser-pro + version: v1.26.2-pro + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GO_RELEASER_GITHUB_TOKEN }} + POSTHOG_API_KEY_FOR_CLI: ${{ secrets.POSTHOG_API_KEY_FOR_CLI }} + FURY_TOKEN: ${{ secrets.FURYPUSHTOKEN }} + AUR_KEY: ${{ secrets.AUR_KEY }} + GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }} + - uses: actions/setup-python@v4 + - run: pip install --upgrade cloudsmith-cli + - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 + with: + ruby-version: "3.3" # Not needed with a .ruby-version, .tool-versions or mise.toml + bundler-cache: true # runs 'bundle install' and caches installed gems automatically + - name: Install deb-s3 + run: gem install deb-s3 + - name: Configure GPG Key + run: echo -n "$GPG_SIGNING_KEY" | base64 --decode | gpg --batch --import + env: + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} + - name: Publish to CloudSmith + run: sh cli/upload_to_cloudsmith.sh + env: + CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} + INFISICAL_CLI_S3_BUCKET: ${{ secrets.INFISICAL_CLI_S3_BUCKET }} + INFISICAL_CLI_REPO_SIGNING_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_SIGNING_KEY_ID }} + AWS_ACCESS_KEY_ID: ${{ secrets.INFISICAL_CLI_REPO_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.INFISICAL_CLI_REPO_AWS_SECRET_ACCESS_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8f608c40c..e3147d650 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -162,6 +162,24 @@ scoop: description: "The official Infisical CLI" license: MIT +winget: + - name: infisical + publisher: infisical + license: MIT + homepage: https://infisical.com + short_description: "The official Infisical CLI" + repository: + owner: infisical + name: winget-pkgs + branch: "infisical-{{.Version}}" + pull_request: + enabled: true + draft: false + base: + owner: microsoft + name: winget-pkgs + branch: master + aurs: - name: infisical-bin homepage: "https://infisical.com" diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips new file mode 100644 index 000000000..8c40404dc --- /dev/null +++ b/backend/Dockerfile.dev.fips @@ -0,0 +1,85 @@ +FROM node:20-slim + +# ? Setup a test SoftHSM module. In production a real HSM is used. + +ARG SOFTHSM2_VERSION=2.5.0 + +ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \ + SOFTHSM2_SOURCES=/tmp/softhsm2 + +# Install build dependencies including python3 (required for pkcs11js and partially TDS driver) +RUN apt-get update && apt-get install -y \ + build-essential \ + autoconf \ + automake \ + git \ + libtool \ + libssl-dev \ + python3 \ + make \ + g++ \ + openssh-client \ + curl \ + pkg-config \ + perl \ + wget + +# Install dependencies for TDS driver (required for SAP ASE dynamic secrets) +RUN apt-get install -y \ + unixodbc \ + unixodbc-dev \ + freetds-dev \ + freetds-bin \ + tdsodbc + +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nSetup = /usr/lib/x86_64-linux-gnu/odbc/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini + +# Build and install SoftHSM2 +RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES} +WORKDIR ${SOFTHSM2_SOURCES} + +RUN git checkout ${SOFTHSM2_VERSION} -b ${SOFTHSM2_VERSION} \ + && sh autogen.sh \ + && ./configure --prefix=/usr/local --disable-gost \ + && make \ + && make install + +WORKDIR /root +RUN rm -fr ${SOFTHSM2_SOURCES} + +# Install pkcs11-tool +RUN apt-get install -y opensc + +RUN mkdir -p /etc/softhsm2/tokens && \ + softhsm2-util --init-token --slot 0 --label "auth-app" --pin 1234 --so-pin 0000 + +WORKDIR /openssl-build +RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ + && tar -xf openssl-3.1.2.tar.gz \ + && cd openssl-3.1.2 \ + && ./Configure enable-fips \ + && make \ + && make install_fips + +# ? App setup + +# Install Infisical CLI +RUN curl -1sLf 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash && \ + apt-get update && \ + apt-get install -y infisical=0.8.1 + +WORKDIR /app + +COPY package.json package.json +COPY package-lock.json package-lock.json + +RUN npm install + +COPY . . + +ENV HOST=0.0.0.0 +ENV OPENSSL_CONF=/app/nodejs.cnf +ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules +ENV NODE_OPTIONS=--force-fips + +CMD ["npm", "run", "dev:docker"] diff --git a/backend/e2e-test/mocks/queue.ts b/backend/e2e-test/mocks/queue.ts index 99e3999e1..3f49bcfea 100644 --- a/backend/e2e-test/mocks/queue.ts +++ b/backend/e2e-test/mocks/queue.ts @@ -11,6 +11,7 @@ export const mockQueue = (): TQueueServiceFactory => { job[name] = jobData; }, queuePg: async () => {}, + schedulePg: async () => {}, initialize: async () => {}, shutdown: async () => undefined, stopRepeatableJob: async () => true, diff --git a/backend/nodejs.cnf b/backend/nodejs.cnf new file mode 100644 index 000000000..47d4a3fe3 --- /dev/null +++ b/backend/nodejs.cnf @@ -0,0 +1,16 @@ +nodejs_conf = nodejs_init + +.include /usr/local/ssl/fipsmodule.cnf + +[nodejs_init] +providers = provider_sect + +[provider_sect] +default = default_sect +fips = fips_sect + +[default_sect] +activate = 1 + +[algorithm_sect] +default_properties = fips=yes diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 5d10830ad..d3aed3543 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -33,6 +33,7 @@ import { TScimServiceFactory } from "@app/ee/services/scim/scim-service"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { TSecretApprovalRequestServiceFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-service"; import { TSecretRotationServiceFactory } from "@app/ee/services/secret-rotation/secret-rotation-service"; +import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/secret-scanning-service"; import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TSshCertificateAuthorityServiceFactory } from "@app/ee/services/ssh/ssh-certificate-authority-service"; @@ -237,6 +238,7 @@ declare module "fastify" { kmip: TKmipServiceFactory; kmipOperation: TKmipOperationServiceFactory; gateway: TGatewayServiceFactory; + secretRotationV2: TSecretRotationV2ServiceFactory; }; // 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 346b08e4c..82582bfed 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -17,6 +17,9 @@ import { TApiKeys, TApiKeysInsert, TApiKeysUpdate, + TAppConnections, + TAppConnectionsInsert, + TAppConnectionsUpdate, TAuditLogs, TAuditLogsInsert, TAuditLogStreams, @@ -65,6 +68,9 @@ import { TDynamicSecrets, TDynamicSecretsInsert, TDynamicSecretsUpdate, + TExternalGroupOrgRoleMappings, + TExternalGroupOrgRoleMappingsInsert, + TExternalGroupOrgRoleMappingsUpdate, TExternalKms, TExternalKmsInsert, TExternalKmsUpdate, @@ -299,6 +305,12 @@ import { TSecretRotations, TSecretRotationsInsert, TSecretRotationsUpdate, + TSecretRotationsV2, + TSecretRotationsV2Insert, + TSecretRotationsV2Update, + TSecretRotationV2SecretMappings, + TSecretRotationV2SecretMappingsInsert, + TSecretRotationV2SecretMappingsUpdate, TSecrets, TSecretScanningGitRisks, TSecretScanningGitRisksInsert, @@ -320,15 +332,27 @@ import { TSecretSnapshotsInsert, TSecretSnapshotsUpdate, TSecretsUpdate, + TSecretsV2, + TSecretsV2Insert, + TSecretsV2Update, + TSecretSyncs, + TSecretSyncsInsert, + TSecretSyncsUpdate, TSecretTagJunction, TSecretTagJunctionInsert, TSecretTagJunctionUpdate, TSecretTags, TSecretTagsInsert, TSecretTagsUpdate, + TSecretV2TagJunction, + TSecretV2TagJunctionInsert, + TSecretV2TagJunctionUpdate, TSecretVersions, TSecretVersionsInsert, TSecretVersionsUpdate, + TSecretVersionsV2, + TSecretVersionsV2Insert, + TSecretVersionsV2Update, TSecretVersionTagJunction, TSecretVersionTagJunctionInsert, TSecretVersionTagJunctionUpdate, @@ -387,24 +411,6 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; -import { TAppConnections, TAppConnectionsInsert, TAppConnectionsUpdate } from "@app/db/schemas/app-connections"; -import { - TExternalGroupOrgRoleMappings, - TExternalGroupOrgRoleMappingsInsert, - TExternalGroupOrgRoleMappingsUpdate -} from "@app/db/schemas/external-group-org-role-mappings"; -import { TSecretSyncs, TSecretSyncsInsert, TSecretSyncsUpdate } from "@app/db/schemas/secret-syncs"; -import { - TSecretV2TagJunction, - TSecretV2TagJunctionInsert, - TSecretV2TagJunctionUpdate -} from "@app/db/schemas/secret-v2-tag-junction"; -import { - TSecretVersionsV2, - TSecretVersionsV2Insert, - TSecretVersionsV2Update -} from "@app/db/schemas/secret-versions-v2"; -import { TSecretsV2, TSecretsV2Insert, TSecretsV2Update } from "@app/db/schemas/secrets-v2"; declare module "knex" { namespace Knex { @@ -950,5 +956,15 @@ declare module "knex/types/tables" { TOrgGatewayConfigInsert, TOrgGatewayConfigUpdate >; + [TableName.SecretRotationV2]: KnexOriginal.CompositeTableType< + TSecretRotationsV2, + TSecretRotationsV2Insert, + TSecretRotationsV2Update + >; + [TableName.SecretRotationV2SecretMapping]: KnexOriginal.CompositeTableType< + TSecretRotationV2SecretMappings, + TSecretRotationV2SecretMappingsInsert, + TSecretRotationV2SecretMappingsUpdate + >; } } diff --git a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts index 9823f4d8e..5a16666af 100644 --- a/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts +++ b/backend/src/db/migrations/20250313124706_add-privilege-upgrade-field.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { diff --git a/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts new file mode 100644 index 000000000..79d455605 --- /dev/null +++ b/backend/src/db/migrations/20250324142104_app-connection-is-platform-managed-credentials-col.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials"))) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.boolean("isPlatformManagedCredentials").defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.AppConnection, "isPlatformManagedCredentials")) { + await knex.schema.alterTable(TableName.AppConnection, (t) => { + t.dropColumn("isPlatformManagedCredentials"); + }); + } +} diff --git a/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts new file mode 100644 index 000000000..dfe0e6888 --- /dev/null +++ b/backend/src/db/migrations/20250324142105_secret-rotation-v2.ts @@ -0,0 +1,58 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.SecretRotationV2))) { + await knex.schema.createTable(TableName.SecretRotationV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("type").notNullable(); + t.jsonb("parameters").notNullable(); + t.jsonb("secretsMapping").notNullable(); + t.binary("encryptedGeneratedCredentials").notNullable(); + t.boolean("isAutoRotationEnabled").notNullable().defaultTo(true); + t.integer("activeIndex").notNullable().defaultTo(0); + t.uuid("folderId").notNullable(); + t.foreign("folderId").references("id").inTable(TableName.SecretFolder).onDelete("CASCADE"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + t.integer("rotationInterval").notNullable(); + t.jsonb("rotateAtUtc").notNullable(); // { hours: number; minutes: number } + t.string("rotationStatus").notNullable(); + t.datetime("lastRotationAttemptedAt").notNullable(); + t.datetime("lastRotatedAt").notNullable(); + t.binary("encryptedLastRotationMessage"); // we encrypt this because it may contain sensitive info (SQL errors showing credentials) + t.string("lastRotationJobId"); + t.datetime("nextRotationAt"); + t.boolean("isLastRotationManual").notNullable().defaultTo(true); // creation is considered a "manual" rotation + }); + + await createOnUpdateTrigger(knex, TableName.SecretRotationV2); + + await knex.schema.alterTable(TableName.SecretRotationV2, (t) => { + t.unique(["folderId", "name"]); + }); + } + + if (!(await knex.schema.hasTable(TableName.SecretRotationV2SecretMapping))) { + await knex.schema.createTable(TableName.SecretRotationV2SecretMapping, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("secretId").notNullable(); + // scott: this is deferred to block secret deletion but not prevent folder/environment/project deletion + // ie, if rotation is being deleted as well we permit it, otherwise throw + t.foreign("secretId").references("id").inTable(TableName.SecretV2).deferrable("deferred"); + t.uuid("rotationId").notNullable(); + t.foreign("rotationId").references("id").inTable(TableName.SecretRotationV2).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretRotationV2SecretMapping); + await knex.schema.dropTableIfExists(TableName.SecretRotationV2); + await dropOnUpdateTrigger(knex, TableName.SecretRotationV2); +} diff --git a/backend/src/db/schemas/app-connections.ts b/backend/src/db/schemas/app-connections.ts index 8c9dff236..ee4282b73 100644 --- a/backend/src/db/schemas/app-connections.ts +++ b/backend/src/db/schemas/app-connections.ts @@ -19,7 +19,8 @@ export const AppConnectionsSchema = z.object({ version: z.number().default(1), orgId: z.string().uuid(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isPlatformManagedCredentials: z.boolean().default(false).nullable().optional() }); export type TAppConnections = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 92fc47c23..5b78cf86f 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -3,6 +3,7 @@ export * from "./access-approval-policies-approvers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; +export * from "./app-connections"; export * from "./audit-log-streams"; export * from "./audit-logs"; export * from "./auth-token-sessions"; @@ -19,6 +20,7 @@ export * from "./certificate-templates"; export * from "./certificates"; export * from "./dynamic-secret-leases"; export * from "./dynamic-secrets"; +export * from "./external-group-org-role-mappings"; export * from "./external-kms"; export * from "./gateways"; export * from "./git-app-install-sessions"; @@ -97,13 +99,16 @@ export * from "./secret-references"; export * from "./secret-references-v2"; export * from "./secret-rotation-output-v2"; export * from "./secret-rotation-outputs"; +export * from "./secret-rotation-v2-secret-mappings"; export * from "./secret-rotations"; +export * from "./secret-rotations-v2"; export * from "./secret-scanning-git-risks"; export * from "./secret-sharing"; export * from "./secret-snapshot-folders"; export * from "./secret-snapshot-secrets"; export * from "./secret-snapshot-secrets-v2"; export * from "./secret-snapshots"; +export * from "./secret-syncs"; export * from "./secret-tag-junction"; export * from "./secret-tags"; export * from "./secret-v2-tag-junction"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e626443c3..e2a0f153f 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -140,7 +140,9 @@ export enum TableName { KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", - KmipClientCertificates = "kmip_client_certificates" + KmipClientCertificates = "kmip_client_certificates", + SecretRotationV2 = "secret_rotations_v2", + SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; @@ -233,3 +235,8 @@ export enum ActionProjectType { // project operations that happen on all types Any = "any" } + +export enum SortDirection { + ASC = "asc", + DESC = "desc" +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 0bcea146b..a18e258c7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), - secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts new file mode 100644 index 000000000..5baf6942c --- /dev/null +++ b/backend/src/db/schemas/secret-rotation-v2-secret-mappings.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretRotationV2SecretMappingsSchema = z.object({ + id: z.string().uuid(), + secretId: z.string().uuid(), + rotationId: z.string().uuid() +}); + +export type TSecretRotationV2SecretMappings = z.infer; +export type TSecretRotationV2SecretMappingsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretRotationV2SecretMappingsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/secret-rotations-v2.ts b/backend/src/db/schemas/secret-rotations-v2.ts new file mode 100644 index 000000000..95873b447 --- /dev/null +++ b/backend/src/db/schemas/secret-rotations-v2.ts @@ -0,0 +1,39 @@ +// 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 SecretRotationsV2Schema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + type: z.string(), + parameters: z.unknown(), + secretsMapping: z.unknown(), + encryptedGeneratedCredentials: zodBuffer, + isAutoRotationEnabled: z.boolean().default(true), + activeIndex: z.number().default(0), + folderId: z.string().uuid(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + rotationInterval: z.number(), + rotateAtUtc: z.unknown(), + rotationStatus: z.string(), + lastRotationAttemptedAt: z.date(), + lastRotatedAt: z.date(), + encryptedLastRotationMessage: zodBuffer.nullable().optional(), + lastRotationJobId: z.string().nullable().optional(), + nextRotationAt: z.date().nullable().optional(), + isLastRotationManual: z.boolean().default(true) +}); + +export type TSecretRotationsV2 = z.infer; +export type TSecretRotationsV2Insert = Omit, TImmutableDBKeys>; +export type TSecretRotationsV2Update = Partial, TImmutableDBKeys>>; 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 98cb9244d..7d2cdcc0c 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -277,8 +277,10 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), secretPath: z.string(), commits: secretRawSchema - .omit({ _id: true, environment: true, workspace: true, type: true, version: true }) + .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) .extend({ + secretValue: z.string().optional(), + isRotatedSecret: z.boolean().optional(), op: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.nullish(), diff --git a/backend/src/ee/routes/v1/secret-rotation-router.ts b/backend/src/ee/routes/v1/secret-rotation-router.ts index 936459fa1..1efc2c8aa 100644 --- a/backend/src/ee/routes/v1/secret-rotation-router.ts +++ b/backend/src/ee/routes/v1/secret-rotation-router.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { SecretRotationOutputsSchema, SecretRotationsSchema } from "@app/db/schemas"; +import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -40,16 +41,10 @@ export const registerSecretRotationRouter = async (server: FastifyZodProvider) = } }, onRequest: verifyAuth([AuthMode.JWT]), - handler: async (req) => { - const secretRotation = await server.services.secretRotation.createRotation({ - actor: req.permission.type, - actorAuthMethod: req.permission.authMethod, - actorId: req.permission.id, - actorOrgId: req.permission.orgId, - ...req.body, - projectId: req.body.workspaceId + handler: async () => { + throw new BadRequestError({ + message: `This version of Secret Rotations has been deprecated. Please see docs for new version.` }); - return { secretRotation }; } }); diff --git a/backend/src/ee/routes/v1/snapshot-router.ts b/backend/src/ee/routes/v1/snapshot-router.ts index 283b9b31e..494871ec1 100644 --- a/backend/src/ee/routes/v1/snapshot-router.ts +++ b/backend/src/ee/routes/v1/snapshot-router.ts @@ -33,7 +33,8 @@ export const registerSnapshotRouter = async (server: FastifyZodProvider) => { .extend({ secretValueHidden: z.boolean(), secretId: z.string(), - tags: SanitizedTagSchema.array() + tags: SanitizedTagSchema.array(), + isRotatedSecret: z.boolean().optional() }) .array(), folderVersion: z.object({ id: z.string(), name: z.string() }).array(), diff --git a/backend/src/ee/routes/v2/index.ts b/backend/src/ee/routes/v2/index.ts index bede5a1cf..70e5005a4 100644 --- a/backend/src/ee/routes/v2/index.ts +++ b/backend/src/ee/routes/v2/index.ts @@ -1,3 +1,8 @@ +import { + registerSecretRotationV2Router, + SECRET_ROTATION_REGISTER_ROUTER_MAP +} from "@app/ee/routes/v2/secret-rotation-v2-routers"; + import { registerIdentityProjectAdditionalPrivilegeRouter } from "./identity-project-additional-privilege-router"; import { registerProjectRoleRouter } from "./project-role-router"; @@ -13,4 +18,17 @@ export const registerV2EERoutes = async (server: FastifyZodProvider) => { await server.register(registerIdentityProjectAdditionalPrivilegeRouter, { prefix: "/identity-project-additional-privilege" }); + + await server.register( + async (secretRotationV2Router) => { + // register generic secret rotation endpoints + await secretRotationV2Router.register(registerSecretRotationV2Router); + + // register service specific secret rotation endpoints (secret-rotations/postgres-credentials, etc.) + for await (const [type, router] of Object.entries(SECRET_ROTATION_REGISTER_ROUTER_MAP)) { + await secretRotationV2Router.register(router, { prefix: `/${type}` }); + } + }, + { prefix: "/secret-rotations" } + ); }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts new file mode 100644 index 000000000..d641ec689 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -0,0 +1,14 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerMsSqlCredentialsRotationRouter } from "./mssql-credentials-rotation-router"; +import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; + +export * from "./secret-rotation-v2-router"; + +export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< + SecretRotation, + (server: FastifyZodProvider) => Promise +> = { + [SecretRotation.PostgresCredentials]: registerPostgresCredentialsRotationRouter, + [SecretRotation.MsSqlCredentials]: registerMsSqlCredentialsRotationRouter +}; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts new file mode 100644 index 000000000..4fea8869b --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/mssql-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateMsSqlCredentialsRotationSchema, + MsSqlCredentialsRotationSchema, + UpdateMsSqlCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerMsSqlCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.MsSqlCredentials, + server, + responseSchema: MsSqlCredentialsRotationSchema, + createSchema: CreateMsSqlCredentialsRotationSchema, + updateSchema: UpdateMsSqlCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts new file mode 100644 index 000000000..ab5ef5768 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/postgres-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreatePostgresCredentialsRotationSchema, + PostgresCredentialsRotationSchema, + UpdatePostgresCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerPostgresCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.PostgresCredentials, + server, + responseSchema: PostgresCredentialsRotationSchema, + createSchema: CreatePostgresCredentialsRotationSchema, + updateSchema: UpdatePostgresCredentialsRotationSchema, + generatedCredentialsSchema: SqlCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts new file mode 100644 index 000000000..05d3c7961 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-endpoints.ts @@ -0,0 +1,429 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { + TRotateAtUtc, + TSecretRotationV2, + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2Input +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { SecretRotations } from "@app/lib/api-docs"; +import { startsWithVowel } from "@app/lib/fn"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +export const registerSecretRotationEndpoints = < + T extends TSecretRotationV2, + I extends TSecretRotationV2Input, + C extends TSecretRotationV2GeneratedCredentials +>({ + server, + type, + createSchema, + updateSchema, + responseSchema, + generatedCredentialsSchema +}: { + type: SecretRotation; + server: FastifyZodProvider; + createSchema: z.ZodType<{ + name: string; + environment: string; + secretPath: string; + projectId: string; + connectionId: string; + parameters: I["parameters"]; + secretsMapping: I["secretsMapping"]; + description?: string | null; + isAutoRotationEnabled?: boolean; + rotationInterval: number; + rotateAtUtc?: TRotateAtUtc; + }>; + updateSchema: z.ZodType<{ + connectionId?: string; + name?: string; + environment?: string; + secretPath?: string; + parameters?: I["parameters"]; + secretsMapping?: I["secretsMapping"]; + description?: string | null; + isAutoRotationEnabled?: boolean; + rotationInterval?: number; + rotateAtUtc?: TRotateAtUtc; + }>; + responseSchema: z.ZodTypeAny; + generatedCredentialsSchema: z.ZodTypeAny; +}) => { + const rotationType = SECRET_ROTATION_NAME_MAP[type]; + + server.route({ + method: "GET", + url: `/`, + config: { + rateLimit: readLimit + }, + schema: { + description: `List the ${rotationType} Rotations for the specified project.`, + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST(type).projectId) + }), + response: { + 200: z.object({ secretRotations: responseSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId } + } = req; + + const secretRotations = (await server.services.secretRotationV2.listSecretRotationsByProjectId( + { projectId, type }, + req.permission + )) as T[]; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + type, + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id) + } + } + }); + + return { secretRotations }; + } + }); + + server.route({ + method: "GET", + url: "/:rotationId", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${rotationType} Rotation by ID.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.GET_BY_ID(type).rotationId) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.findSecretRotationById( + { rotationId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.GET_SECRET_ROTATION, + metadata: { + rotationId, + type, + secretPath: secretRotation.folder.path, + environment: secretRotation.environment.slug + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "GET", + url: `/rotation-name/:rotationName`, + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the specified ${rotationType} Rotation by name, secret path, environment and project ID.`, + params: z.object({ + rotationName: z + .string() + .trim() + .min(1, "Rotation name required") + .describe(SecretRotations.GET_BY_NAME(type).rotationName) + }), + querystring: z.object({ + projectId: z + .string() + .trim() + .min(1, "Project ID required") + .describe(SecretRotations.GET_BY_NAME(type).projectId), + secretPath: z + .string() + .trim() + .min(1, "Secret path required") + .describe(SecretRotations.GET_BY_NAME(type).secretPath), + environment: z + .string() + .trim() + .min(1, "Environment required") + .describe(SecretRotations.GET_BY_NAME(type).environment) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationName } = req.params; + const { projectId, secretPath, environment } = req.query; + + const secretRotation = (await server.services.secretRotationV2.findSecretRotationByName( + { rotationName, projectId, type, secretPath, environment }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATION, + metadata: { + rotationId: secretRotation.id, + type, + secretPath, + environment + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Create ${ + startsWithVowel(rotationType) ? "an" : "a" + } ${rotationType} Rotation for the specified project.`, + body: createSchema, + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const secretRotation = (await server.services.secretRotationV2.createSecretRotation( + { ...req.body, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.CREATE_SECRET_ROTATION, + metadata: { + rotationId: secretRotation.id, + type, + ...req.body + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "PATCH", + url: "/:rotationId", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Update the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.UPDATE(type).rotationId) + }), + body: updateSchema, + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.updateSecretRotation( + { ...req.body, rotationId, type }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.UPDATE_SECRET_ROTATION, + metadata: { + rotationId, + type, + ...req.body + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "DELETE", + url: `/:rotationId`, + config: { + rateLimit: writeLimit + }, + schema: { + description: `Delete the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.DELETE(type).rotationId) + }), + querystring: z.object({ + deleteSecrets: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .describe(SecretRotations.DELETE(type).deleteSecrets), + revokeGeneratedCredentials: z + .enum(["true", "false"]) + .transform((value) => value === "true") + .describe(SecretRotations.DELETE(type).revokeGeneratedCredentials) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + const { deleteSecrets, revokeGeneratedCredentials } = req.query; + + const secretRotation = (await server.services.secretRotationV2.deleteSecretRotation( + { type, rotationId, deleteSecrets, revokeGeneratedCredentials }, + req.permission + )) as T; + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: secretRotation.projectId, + event: { + type: EventType.DELETE_SECRET_ROTATION, + metadata: { + type, + rotationId, + deleteSecrets, + revokeGeneratedCredentials + } + } + }); + + return { secretRotation }; + } + }); + + server.route({ + method: "GET", + url: "/:rotationId/generated-credentials", + config: { + rateLimit: readLimit + }, + schema: { + description: `Get the generated credentials for the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.GET_GENERATED_CREDENTIALS_BY_ID(type).rotationId) + }), + response: { + 200: z.object({ + generatedCredentials: generatedCredentialsSchema, + activeIndex: z.number(), + rotationId: z.string().uuid(), + type: z.literal(type) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const { + generatedCredentials, + secretRotation: { activeIndex, projectId, folder, environment } + } = await server.services.secretRotationV2.findSecretRotationGeneratedCredentialsById( + { + rotationId, + type + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS, + metadata: { + type, + rotationId, + secretPath: folder.path, + environment: environment.slug + } + } + }); + + return { generatedCredentials: generatedCredentials as C, activeIndex, rotationId, type }; + } + }); + + server.route({ + method: "POST", + url: "/:rotationId/rotate-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + description: `Rotate the generated credentials for the specified ${rotationType} Rotation.`, + params: z.object({ + rotationId: z.string().uuid().describe(SecretRotations.ROTATE(type).rotationId) + }), + response: { + 200: z.object({ secretRotation: responseSchema }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { rotationId } = req.params; + + const secretRotation = (await server.services.secretRotationV2.rotateSecretRotation( + { + rotationId, + type, + auditLogInfo: req.auditLogInfo + }, + req.permission + )) as T; + + return { secretRotation }; + } + }); +}; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts new file mode 100644 index 000000000..abdfc14f6 --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { MsSqlCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; +import { SecretRotations } 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"; + +const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ + PostgresCredentialsRotationListItemSchema, + MsSqlCredentialsRotationListItemSchema +]); + +export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "List the available Secret Rotation Options.", + response: { + 200: z.object({ + secretRotationOptions: SecretRotationV2OptionsSchema.array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: () => { + const secretRotationOptions = server.services.secretRotationV2.listSecretRotationOptions(); + return { secretRotationOptions }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List all the Secret Rotations for the specified project.", + querystring: z.object({ + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.LIST().projectId) + }), + response: { + 200: z.object({ secretRotations: SecretRotationV2Schema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { + query: { projectId }, + permission + } = req; + + const secretRotations = await server.services.secretRotationV2.listSecretRotationsByProjectId( + { projectId }, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + rotationIds: secretRotations.map((sync) => sync.id), + count: secretRotations.length + } + } + }); + + return { secretRotations }; + } + }); +}; 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 11904a48b..eb9deb3ae 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2,6 +2,13 @@ import { TCreateProjectTemplateDTO, TUpdateProjectTemplateDTO } from "@app/ee/services/project-template/project-template-types"; +import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + TCreateSecretRotationV2DTO, + TDeleteSecretRotationV2DTO, + TSecretRotationV2Raw, + TUpdateSecretRotationV2DTO +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { SshCaStatus, SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; import { SshCertTemplateStatus } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-types"; import { SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher"; @@ -57,6 +64,8 @@ export type TCreateAuditLogDTO = { projectId?: string; } & BaseAuthData; +export type AuditLogInfo = Pick; + interface BaseAuthData { ipAddress?: string; userAgent?: string; @@ -291,7 +300,17 @@ export enum EventType { KMIP_OPERATION_ACTIVATE = "kmip-operation-activate", KMIP_OPERATION_REVOKE = "kmip-operation-revoke", KMIP_OPERATION_LOCATE = "kmip-operation-locate", - KMIP_OPERATION_REGISTER = "kmip-operation-register" + KMIP_OPERATION_REGISTER = "kmip-operation-register", + + GET_SECRET_ROTATIONS = "get-secret-rotations", + GET_SECRET_ROTATION = "get-secret-rotation", + GET_SECRET_ROTATION_GENERATED_CREDENTIALS = "get-secret-rotation-generated-credentials", + CREATE_SECRET_ROTATION = "create-secret-rotation", + UPDATE_SECRET_ROTATION = "update-secret-rotation", + DELETE_SECRET_ROTATION = "delete-secret-rotation", + SECRET_ROTATION_ROTATE_SECRETS = "secret-rotation-rotate-secrets", + + PROJECT_ACCESS_REQUEST = "project-access-request" } export const filterableSecretEvents: EventType[] = [ @@ -2316,6 +2335,15 @@ interface KmipOperationRegisterEvent { }; } +interface ProjectAccessRequestEvent { + type: EventType.PROJECT_ACCESS_REQUEST; + metadata: { + projectId: string; + requesterId: string; + requesterEmail: string; + }; +} + interface SetupKmipEvent { type: EventType.SETUP_KMIP; metadata: { @@ -2341,6 +2369,63 @@ interface RegisterKmipServerEvent { }; } +interface GetSecretRotationsEvent { + type: EventType.GET_SECRET_ROTATIONS; + metadata: { + type?: SecretRotation; + count: number; + rotationIds: string[]; + secretPath?: string; + environment?: string; + }; +} + +interface GetSecretRotationEvent { + type: EventType.GET_SECRET_ROTATION; + metadata: { + type: SecretRotation; + rotationId: string; + secretPath: string; + environment: string; + }; +} + +interface GetSecretRotationCredentialsEvent { + type: EventType.GET_SECRET_ROTATION_GENERATED_CREDENTIALS; + metadata: { + type: SecretRotation; + rotationId: string; + secretPath: string; + environment: string; + }; +} + +interface CreateSecretRotationEvent { + type: EventType.CREATE_SECRET_ROTATION; + metadata: Omit & { rotationId: string }; +} + +interface UpdateSecretRotationEvent { + type: EventType.UPDATE_SECRET_ROTATION; + metadata: TUpdateSecretRotationV2DTO; +} + +interface DeleteSecretRotationEvent { + type: EventType.DELETE_SECRET_ROTATION; + metadata: TDeleteSecretRotationV2DTO; +} + +interface RotateSecretRotationEvent { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS; + metadata: Pick & { + status: SecretRotationStatus; + rotationId: string; + jobId?: string | undefined; + occurredAt: Date; + message?: string | null | undefined; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -2554,5 +2639,13 @@ export type Event = | KmipOperationRevokeEvent | KmipOperationLocateEvent | KmipOperationRegisterEvent + | ProjectAccessRequestEvent | CreateSecretRequestEvent - | SecretApprovalRequestReview; + | SecretApprovalRequestReview + | GetSecretRotationsEvent + | GetSecretRotationEvent + | GetSecretRotationCredentialsEvent + | CreateSecretRotationEvent + | UpdateSecretRotationEvent + | DeleteSecretRotationEvent + | RotateSecretRotationEvent; diff --git a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts index 02b83c0f9..4bd384bcf 100644 --- a/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -8,11 +8,13 @@ import { getDbConnectionHost } from "@app/lib/knex"; export const verifyHostInputValidity = async (host: string, isGateway = false) => { const appCfg = getConfig(); - // if (appCfg.NODE_ENV === "development") return ["host.docker.internal"]; // incase you want to remove this check in dev + + if (appCfg.isDevelopmentMode) return [host]; const reservedHosts = [appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI)].concat( (appCfg.DB_READ_REPLICAS || []).map((el) => getDbConnectionHost(el.DB_CONNECTION_URI)), - getDbConnectionHost(appCfg.REDIS_URL) + getDbConnectionHost(appCfg.REDIS_URL), + getDbConnectionHost(appCfg.AUDIT_LOGS_DB_CONNECTION_URI) ); // get host db ip @@ -40,7 +42,7 @@ export const verifyHostInputValidity = async (host: string, isGateway = false) = inputHostIps.push(...resolvedIps); } - if (!isGateway) { + if (!isGateway && !appCfg.DYNAMIC_SECRET_ALLOW_INTERNAL_IP) { const isInternalIp = inputHostIps.some((el) => isPrivateIp(el)); if (isInternalIp) throw new BadRequestError({ message: "Invalid db host" }); } diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 21d378802..3f4af174b 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -39,7 +39,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ trial_end: null, has_used_trial: true, secretApproval: false, - secretRotation: true, + secretRotation: false, caCrl: false, instanceUserManagement: false, externalKms: false, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 29c36c7fe..cf9818658 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -5,6 +5,7 @@ // TODO(akhilmhdh): With tony find out the api structure and fill it here import { ForbiddenError } from "@casl/ability"; +import { CronJob } from "cron"; import { Knex } from "knex"; import { TKeyStoreFactory } from "@app/keystore/keystore"; @@ -85,6 +86,20 @@ export const licenseServiceFactory = ({ appCfg.LICENSE_KEY || "" ); + const syncLicenseKeyOnPremFeatures = async (shouldThrow: boolean = false) => { + logger.info("Start syncing license key features"); + try { + const { + data: { currentPlan } + } = await licenseServerOnPremApi.request.get<{ currentPlan: TFeatureSet }>("/api/license/v1/plan"); + onPremFeatures = currentPlan; + logger.info("Successfully synchronized license key features"); + } catch (error) { + logger.error(error, "Failed to synchronize license key features"); + if (shouldThrow) throw error; + } + }; + const init = async () => { try { if (appCfg.LICENSE_SERVER_KEY) { @@ -98,10 +113,7 @@ export const licenseServiceFactory = ({ if (appCfg.LICENSE_KEY) { const token = await licenseServerOnPremApi.refreshLicense(); if (token) { - const { - data: { currentPlan } - } = await licenseServerOnPremApi.request.get<{ currentPlan: TFeatureSet }>("/api/license/v1/plan"); - onPremFeatures = currentPlan; + await syncLicenseKeyOnPremFeatures(true); instanceType = InstanceType.EnterpriseOnPrem; logger.info(`Instance type: ${InstanceType.EnterpriseOnPrem}`); isValidLicense = true; @@ -147,6 +159,15 @@ export const licenseServiceFactory = ({ } }; + const initializeBackgroundSync = async () => { + if (appCfg.LICENSE_KEY) { + logger.info("Setting up background sync process for refresh onPremFeatures"); + const job = new CronJob("*/10 * * * *", syncLicenseKeyOnPremFeatures); + job.start(); + return job; + } + }; + const getPlan = async (orgId: string, projectId?: string) => { logger.info(`getPlan: attempting to fetch plan for [orgId=${orgId}] [projectId=${projectId}]`); try { @@ -662,6 +683,7 @@ export const licenseServiceFactory = ({ getOrgTaxInvoices, getOrgTaxIds, addOrgTaxId, - delOrgTaxId + delOrgTaxId, + initializeBackgroundSync }; }; diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 34e350cf6..c2bf42e2e 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -56,7 +56,7 @@ export type TFeatureSet = { trial_end: null; has_used_trial: true; secretApproval: false; - secretRotation: true; + secretRotation: false; caCrl: false; instanceUserManagement: false; externalKms: false; diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 79112afeb..49f706688 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -79,6 +79,15 @@ export enum ProjectPermissionSecretSyncActions { RemoveSecrets = "remove-secrets" } +export enum ProjectPermissionSecretRotationActions { + Read = "read", + ReadGeneratedCredentials = "read-generated-credentials", + Create = "create", + Edit = "edit", + Delete = "delete", + RotateSecrets = "rotate-secrets" +} + export enum ProjectPermissionKmipActions { CreateClients = "create-clients", UpdateClients = "update-clients", @@ -144,6 +153,11 @@ export type SecretImportSubjectFields = { secretPath: string; }; +export type SecretRotationsSubjectFields = { + environment: string; + secretPath: string; +}; + export type IdentityManagementSubjectFields = { identityId: string; }; @@ -186,7 +200,13 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] - | [ProjectPermissionActions, ProjectPermissionSub.SecretRotation] + | [ + ProjectPermissionSecretRotationActions, + ( + | ProjectPermissionSub.SecretRotation + | (ForcedSubject & SecretRotationsSubjectFields) + ) + ] | [ ProjectPermissionIdentityActions, ProjectPermissionSub.Identity | (ForcedSubject & IdentityManagementSubjectFields) @@ -302,12 +322,6 @@ const GeneralPermissionSchema = [ "Describe what action an entity can take." ) }), - z.object({ - subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( - "Describe what action an entity can take." - ) - }), z.object({ subject: z.literal(ProjectPermissionSub.SecretRollback).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_ENUM([ProjectPermissionActions.Read, ProjectPermissionActions.Create]).describe( @@ -489,6 +503,12 @@ export const ProjectPermissionV1Schema = z.discriminatedUnion("subject", [ "Describe what action an entity can take." ) }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); @@ -543,6 +563,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.SecretRotation).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionSecretRotationActions).describe( + "Describe what action an entity can take." + ), + conditions: SecretConditionV1Schema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), ...GeneralPermissionSchema ]); @@ -556,7 +586,6 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretFolders, ProjectPermissionSub.SecretImports, ProjectPermissionSub.SecretApproval, - ProjectPermissionSub.SecretRotation, ProjectPermissionSub.Role, ProjectPermissionSub.Integrations, ProjectPermissionSub.Webhooks, @@ -682,6 +711,18 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Kmip ); + can( + [ + ProjectPermissionSecretRotationActions.Create, + ProjectPermissionSecretRotationActions.Edit, + ProjectPermissionSecretRotationActions.Delete, + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, + ProjectPermissionSecretRotationActions.RotateSecrets + ], + ProjectPermissionSub.SecretRotation + ); + return rules; }; @@ -731,7 +772,7 @@ const buildMemberPermissionRules = () => { ); can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); - can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretRotation); + can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -879,7 +920,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); - can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); can(ProjectPermissionGroupActions.Read, ProjectPermissionSub.Groups); can(ProjectPermissionActions.Read, ProjectPermissionSub.Role); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts index f72977a9b..c1b18e43d 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-secret-dal.ts @@ -257,6 +257,11 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema("secVerTag") ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select(selectAllTableCols(TableName.SecretApprovalRequestSecretV2)) .select({ secVerTagId: "secVerTag.id", @@ -285,7 +290,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") - ); + ) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); const formatedDoc = sqlNestRelationships({ data: doc, key: "id", @@ -304,14 +310,16 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => { { key: "secretId", label: "secret" as const, - mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId }) => + mapper: ({ orgSecVersion, orgSecKey, orgSecValue, orgSecComment, secretId, rotationId }) => secretId ? { id: secretId, version: orgSecVersion, key: orgSecKey, encryptedValue: orgSecValue, - encryptedComment: orgSecComment + encryptedComment: orgSecComment, + isRotatedSecret: Boolean(rotationId), + rotationId } : undefined }, 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 879065ef5..e24cd923e 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 @@ -262,7 +262,13 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - secretValue: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + isRotatedSecret: el.secret.isRotatedSecret, + // eslint-disable-next-line no-nested-ternary + secretValue: el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -609,7 +615,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - typeof el.encryptedValue !== "undefined" + !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts new file mode 100644 index 000000000..3ee1cf450 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./mssql-credentials-rotation-constants"; +export * from "./mssql-credentials-rotation-schemas"; +export * from "./mssql-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts new file mode 100644 index 000000000..b256bd77f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-constants.ts @@ -0,0 +1,29 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MSSQL_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Microsoft SQL Server Credentials", + type: SecretRotation.MsSqlCredentials, + connection: AppConnection.MsSql, + template: { + createUserStatement: `-- Create login at the server level +CREATE LOGIN [infisical_user] WITH PASSWORD = 'my-password'; + +-- Grant server-level connect permission +GRANT CONNECT SQL TO [infisical_user]; + +-- Switch to the database where you want to create the user +USE my_database; + +-- Create the database user mapped to the login +CREATE USER [infisical_user] FOR LOGIN [infisical_user]; + +-- Grant permissions to the user on the schema in this database +GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_user];`, + secretsMapping: { + username: "MSSQL_DB_USERNAME", + password: "MSSQL_DB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..3f02d8144 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const MsSqlCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.MsSqlCredentials).extend({ + type: z.literal(SecretRotation.MsSqlCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreateMsSqlCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.MsSqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdateMsSqlCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.MsSqlCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const MsSqlCredentialsRotationListItemSchema = z.object({ + name: z.literal("Microsoft SQL Server Credentials"), + connection: z.literal(AppConnection.MsSql), + type: z.literal(SecretRotation.MsSqlCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts new file mode 100644 index 000000000..ed707c4e4 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/mssql-credentials/mssql-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TMsSqlConnection } from "@app/services/app-connection/mssql"; + +import { + CreateMsSqlCredentialsRotationSchema, + MsSqlCredentialsRotationListItemSchema, + MsSqlCredentialsRotationSchema +} from "./mssql-credentials-rotation-schemas"; + +export type TMsSqlCredentialsRotation = z.infer; + +export type TMsSqlCredentialsRotationInput = z.infer; + +export type TMsSqlCredentialsRotationListItem = z.infer; + +export type TMsSqlCredentialsRotationWithConnection = TMsSqlCredentialsRotation & { + connection: TMsSqlConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts new file mode 100644 index 000000000..aba568d1d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/index.ts @@ -0,0 +1,3 @@ +export * from "./postgres-credentials-rotation-constants"; +export * from "./postgres-credentials-rotation-schemas"; +export * from "./postgres-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts new file mode 100644 index 000000000..395ed46d1 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-constants.ts @@ -0,0 +1,23 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "PostgreSQL Credentials", + type: SecretRotation.PostgresCredentials, + connection: AppConnection.Postgres, + template: { + createUserStatement: `-- create user role +CREATE USER infisical_user WITH ENCRYPTED PASSWORD 'temporary_password'; + +-- grant database connection permissions +GRANT CONNECT ON DATABASE my_database TO infisical_user; + +-- grant relevant table permissions +GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_user;`, + secretsMapping: { + username: "POSTGRES_DB_USERNAME", + password: "POSTGRES_DB_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts new file mode 100644 index 000000000..0527a6116 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-schemas.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { + SqlCredentialsRotationParametersSchema, + SqlCredentialsRotationSecretsMappingSchema, + SqlCredentialsRotationTemplateSchema +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const PostgresCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.PostgresCredentials).extend({ + type: z.literal(SecretRotation.PostgresCredentials), + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const CreatePostgresCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.PostgresCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema, + secretsMapping: SqlCredentialsRotationSecretsMappingSchema +}); + +export const UpdatePostgresCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.PostgresCredentials +).extend({ + parameters: SqlCredentialsRotationParametersSchema.optional(), + secretsMapping: SqlCredentialsRotationSecretsMappingSchema.optional() +}); + +export const PostgresCredentialsRotationListItemSchema = z.object({ + name: z.literal("PostgreSQL Credentials"), + connection: z.literal(AppConnection.Postgres), + type: z.literal(SecretRotation.PostgresCredentials), + template: SqlCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts new file mode 100644 index 000000000..28e9fb29f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/postgres-credentials/postgres-credentials-rotation-types.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { TPostgresConnection } from "@app/services/app-connection/postgres"; + +import { + CreatePostgresCredentialsRotationSchema, + PostgresCredentialsRotationListItemSchema, + PostgresCredentialsRotationSchema +} from "./postgres-credentials-rotation-schemas"; + +export type TPostgresCredentialsRotation = z.infer; + +export type TPostgresCredentialsRotationInput = z.infer; + +export type TPostgresCredentialsRotationListItem = z.infer; + +export type TPostgresCredentialsRotationWithConnection = TPostgresCredentialsRotation & { + connection: TPostgresConnection; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts new file mode 100644 index 000000000..c3e7a12fb --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-dal.ts @@ -0,0 +1,467 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TSecretRotationsV2 } from "@app/db/schemas/secret-rotations-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { + buildFindFilter, + ormify, + prependTableNameToFindFilter, + selectAllTableCols, + sqlNestRelationships, + TFindOpt +} from "@app/lib/knex"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; + +export type TSecretRotationV2DALFactory = ReturnType; + +type TSecretRotationFindFilter = Parameters>[0]; +type TSecretRotationFindOptions = TFindOpt; + +const baseSecretRotationV2Query = ({ + filter = {}, + options, + db, + tx +}: { + db: TDbClient; + filter?: { projectId?: string } & TSecretRotationFindFilter; + options?: TSecretRotationFindOptions; + tx?: Knex; +}) => { + const { projectId, ...filters } = filter; + + const query = (tx || db.replicaNode())(TableName.SecretRotationV2) + .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join(TableName.AppConnection, `${TableName.SecretRotationV2}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.SecretRotationV2)) + .select( + // environment + db.ref("name").withSchema(TableName.Environment).as("envName"), + db.ref("id").withSchema(TableName.Environment).as("envId"), + db.ref("slug").withSchema(TableName.Environment).as("envSlug"), + db.ref("projectId").withSchema(TableName.Environment), + // entire connection + db.ref("name").withSchema(TableName.AppConnection).as("connectionName"), + db.ref("method").withSchema(TableName.AppConnection).as("connectionMethod"), + db.ref("app").withSchema(TableName.AppConnection).as("connectionApp"), + db.ref("orgId").withSchema(TableName.AppConnection).as("connectionOrgId"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("connectionEncryptedCredentials"), + db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") + ); + + if (filter) { + /* eslint-disable @typescript-eslint/no-misused-promises */ + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filters))); + } + + if (projectId) { + void query.where(`${TableName.Environment}.projectId`, projectId); + } + + if (options) { + const { offset, limit, sort, count, countDistinct } = options; + if (countDistinct) { + void query.countDistinct(countDistinct); + } else if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + void query.select("*"); + } + if (limit) void query.limit(limit); + if (offset) void query.offset(offset); + if (sort) { + void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); + } + } + + return query; +}; + +const expandSecretRotation = >[number]>( + secretRotation: T, + folder: Awaited>[number] +) => { + const { + envId, + envName, + envSlug, + connectionApp, + connectionName, + connectionId, + connectionOrgId, + connectionEncryptedCredentials, + connectionMethod, + connectionDescription, + connectionCreatedAt, + connectionUpdatedAt, + connectionVersion, + connectionIsPlatformManagedCredentials, + ...el + } = secretRotation; + + return { + ...el, + connectionId, + environment: { id: envId, name: envName, slug: envSlug }, + connection: { + app: connectionApp, + id: connectionId, + name: connectionName, + orgId: connectionOrgId, + encryptedCredentials: connectionEncryptedCredentials, + method: connectionMethod, + description: connectionDescription, + createdAt: connectionCreatedAt, + updatedAt: connectionUpdatedAt, + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials + }, + folder: { + id: folder!.id, + path: folder!.path + } + }; +}; + +export const secretRotationV2DALFactory = ( + db: TDbClient, + folderDAL: Pick +) => { + const secretRotationV2Orm = ormify(db, TableName.SecretRotationV2); + const secretRotationV2SecretMappingOrm = ormify(db, TableName.SecretRotationV2SecretMapping); + + const find = async ( + filter: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string }, + options?: TSecretRotationFindOptions, + tx?: Knex + ) => { + try { + const secretRotations = await baseSecretRotationV2Query({ filter, db, tx, options }); + + if (!secretRotations.length) return []; + + const foldersWithPath = await folderDAL.findSecretPathByFolderIds( + filter.projectId, + secretRotations.map((rotation) => rotation.folderId), + tx + ); + + const folderRecord: Record = {}; + + foldersWithPath.forEach((folder) => { + if (folder) folderRecord[folder.id] = folder; + }); + + return secretRotations.map((rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId])); + } catch (error) { + throw new DatabaseError({ error, name: "Find - Secret Rotation V2" }); + } + }; + + const findWithMappedSecretsCount = async ( + { + search, + projectId, + ...filter + }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string }, + tx?: Knex + ) => { + const query = (tx || db.replicaNode())(TableName.SecretRotationV2) + .join(TableName.SecretFolder, `${TableName.SecretRotationV2}.folderId`, `${TableName.SecretFolder}.id`) + .join(TableName.Environment, `${TableName.SecretFolder}.envId`, `${TableName.Environment}.id`) + .join( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.rotationId`, + `${TableName.SecretRotationV2}.id` + ) + .join(TableName.SecretV2, `${TableName.SecretRotationV2SecretMapping}.secretId`, `${TableName.SecretV2}.id`) + .where(`${TableName.Environment}.projectId`, projectId) + .where(buildFindFilter(prependTableNameToFindFilter(TableName.SecretRotationV2, filter))) + .countDistinct(`${TableName.SecretRotationV2}.name`); + + if (search) { + void query.where((qb) => { + void qb + .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) + .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + }); + } + + const result = await query; + + // @ts-expect-error knex infers wrong type... + return Number(result[0]?.count ?? 0); + }; + + const findWithMappedSecrets = async ( + { search, ...filter }: Parameters<(typeof secretRotationV2Orm)["find"]>[0] & { projectId: string; search?: string }, + options?: TSecretRotationFindOptions, + tx?: Knex + ) => { + try { + const extendedQuery = baseSecretRotationV2Query({ filter, db, tx, options }) + .join( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.rotationId`, + `${TableName.SecretRotationV2}.id` + ) + .join(TableName.SecretV2, `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId`) + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .select( + db.ref("id").withSchema(TableName.SecretV2).as("secretId"), + db.ref("key").withSchema(TableName.SecretV2).as("secretKey"), + db.ref("version").withSchema(TableName.SecretV2).as("secretVersion"), + db.ref("type").withSchema(TableName.SecretV2).as("secretType"), + db.ref("encryptedValue").withSchema(TableName.SecretV2).as("secretEncryptedValue"), + db.ref("encryptedComment").withSchema(TableName.SecretV2).as("secretEncryptedComment"), + db.ref("reminderNote").withSchema(TableName.SecretV2).as("secretReminderNote"), + db.ref("reminderRepeatDays").withSchema(TableName.SecretV2).as("secretReminderRepeatDays"), + db.ref("skipMultilineEncoding").withSchema(TableName.SecretV2).as("secretSkipMultilineEncoding"), + db.ref("metadata").withSchema(TableName.SecretV2).as("secretMetadata"), + db.ref("userId").withSchema(TableName.SecretV2).as("secretUserId"), + db.ref("folderId").withSchema(TableName.SecretV2).as("secretFolderId"), + db.ref("createdAt").withSchema(TableName.SecretV2).as("secretCreatedAt"), + db.ref("updatedAt").withSchema(TableName.SecretV2).as("secretUpdatedAt"), + db.ref("id").withSchema(TableName.SecretTag).as("tagId"), + db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ); + + if (search) { + void extendedQuery.where((query) => { + void query + .whereILike(`${TableName.SecretV2}.key`, `%${search}%`) + .orWhereILike(`${TableName.SecretRotationV2}.name`, `%${search}%`); + }); + } + + const secretRotations = await extendedQuery; + + if (!secretRotations.length) return []; + + const foldersWithPath = await folderDAL.findSecretPathByFolderIds( + filter.projectId, + secretRotations.map((rotation) => rotation.folderId), + tx + ); + + const folderRecord: Record = {}; + + foldersWithPath.forEach((folder) => { + if (folder) folderRecord[folder.id] = folder; + }); + + return sqlNestRelationships({ + data: secretRotations, + key: "id", + parentMapper: (rotation) => expandSecretRotation(rotation, folderRecord[rotation.folderId]), + childrenMapper: [ + { + key: "secretId", + label: "secrets" as const, + mapper: ({ + secretId, + secretKey, + secretVersion, + secretType, + secretEncryptedValue, + secretEncryptedComment, + secretReminderNote, + secretReminderRepeatDays, + secretSkipMultilineEncoding, + secretMetadata, + secretUserId, + secretFolderId, + secretCreatedAt, + secretUpdatedAt, + id + }) => ({ + id: secretId, + key: secretKey, + version: secretVersion, + type: secretType, + encryptedValue: secretEncryptedValue, + encryptedComment: secretEncryptedComment, + reminderNote: secretReminderNote, + reminderRepeatDays: secretReminderRepeatDays, + skipMultilineEncoding: secretSkipMultilineEncoding, + metadata: secretMetadata, + userId: secretUserId, + folderId: secretFolderId, + createdAt: secretCreatedAt, + updatedAt: secretUpdatedAt, + rotationId: id, + isRotatedSecret: true + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + } + ] + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find with Mapped Secrets - Secret Rotation V2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const secretRotation = await baseSecretRotationV2Query({ + filter: { id }, + db, + tx + }).first(); + + if (secretRotation) { + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + return expandSecretRotation(secretRotation, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find by ID - Secret Rotation V2" }); + } + }; + + const create = async (data: Parameters<(typeof secretRotationV2Orm)["create"]>[0], tx?: Knex) => { + const rotation = await secretRotationV2Orm.create(data, tx); + + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotation.id }, + db, + tx + }).first())!; + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const updateById = async ( + rotationId: string, + data: Parameters<(typeof secretRotationV2Orm)["updateById"]>[1], + tx?: Knex + ) => { + const rotation = await secretRotationV2Orm.updateById(rotationId, data, tx); + + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotation.id }, + db, + tx + }).first())!; + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const deleteById = async (rotationId: string, tx?: Knex) => { + const secretRotation = (await baseSecretRotationV2Query({ + filter: { id: rotationId }, + db, + tx + }).first())!; + + await secretRotationV2Orm.deleteById(rotationId, tx); + + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + }; + + const findOne = async (filter: Parameters<(typeof secretRotationV2Orm)["findOne"]>[0], tx?: Knex) => { + try { + const secretRotation = await baseSecretRotationV2Query({ filter, db, tx }).first(); + + if (secretRotation) { + const [folderWithPath] = await folderDAL.findSecretPathByFolderIds( + secretRotation.projectId, + [secretRotation.folderId], + tx + ); + + return expandSecretRotation(secretRotation, folderWithPath); + } + } catch (error) { + throw new DatabaseError({ error, name: "Find One - Secret Rotation V2" }); + } + }; + + const findSecretRotationsToQueue = async (rotateBy: Date, tx?: Knex) => { + const secretRotations = await (tx || db.replicaNode())(TableName.SecretRotationV2) + .where(`${TableName.SecretRotationV2}.isAutoRotationEnabled`, true) + .whereNotNull(`${TableName.SecretRotationV2}.nextRotationAt`) + .andWhereRaw(`"nextRotationAt" <= ?`, [rotateBy]) + .select(selectAllTableCols(TableName.SecretRotationV2)); + + return secretRotations; + }; + + return { + ...secretRotationV2Orm, + find, + create, + findById, + updateById, + deleteById, + findOne, + insertSecretMappings: secretRotationV2SecretMappingOrm.insertMany, + findWithMappedSecrets, + findWithMappedSecretsCount, + findSecretRotationsToQueue + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts new file mode 100644 index 000000000..178a12516 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -0,0 +1,9 @@ +export enum SecretRotation { + PostgresCredentials = "postgres-credentials", + MsSqlCredentials = "mssql-credentials" +} + +export enum SecretRotationStatus { + Success = "success", + Failed = "failed" +} diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts new file mode 100644 index 000000000..376b497f1 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -0,0 +1,222 @@ +import { AxiosError } from "axios"; + +import { getConfig } from "@app/lib/config/env"; +import { KmsDataKey } from "@app/services/kms/kms-types"; + +import { MSSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mssql-credentials"; +import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; +import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; +import { TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; +import { + TSecretRotationV2, + TSecretRotationV2GeneratedCredentials, + TSecretRotationV2ListItem, + TSecretRotationV2Raw +} from "./secret-rotation-v2-types"; + +const SECRET_ROTATION_LIST_OPTIONS: Record = { + [SecretRotation.PostgresCredentials]: POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION, + [SecretRotation.MsSqlCredentials]: MSSQL_CREDENTIALS_ROTATION_LIST_OPTION +}; + +export const listSecretRotationOptions = () => { + return Object.values(SECRET_ROTATION_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name)); +}; + +const getNextUTCDayInterval = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { + const now = new Date(); + + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + 1, // Add 1 day to get tomorrow + hours, + minutes, + 0, + 0 + ) + ); +}; + +const getNextUTCMinuteInterval = ({ minutes }: TSecretRotationV2["rotateAtUtc"] = { hours: 0, minutes: 0 }) => { + const now = new Date(); + return new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + now.getUTCHours(), + now.getUTCMinutes() + 1, // Add 1 minute to get the next minute + minutes, // use minutes as seconds in dev + 0 + ) + ); +}; + +export const getNextUtcRotationInterval = (rotateAtUtc?: TSecretRotationV2["rotateAtUtc"]) => { + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + return getNextUTCMinuteInterval(rotateAtUtc); + } + + return getNextUTCDayInterval(rotateAtUtc); +}; + +export const encryptSecretRotationCredentials = async ({ + projectId, + generatedCredentials, + kmsService +}: { + projectId: string; + generatedCredentials: TSecretRotationV2GeneratedCredentials; + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]; +}) => { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedCredentialsBlob } = encryptor({ + plainText: Buffer.from(JSON.stringify(generatedCredentials)) + }); + + return encryptedCredentialsBlob; +}; + +export const decryptSecretRotationCredentials = async ({ + projectId, + encryptedGeneratedCredentials, + kmsService +}: { + projectId: string; + encryptedGeneratedCredentials: Buffer; + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"]; +}) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const decryptedPlainTextBlob = decryptor({ + cipherTextBlob: encryptedGeneratedCredentials + }); + + return JSON.parse(decryptedPlainTextBlob.toString()) as TSecretRotationV2GeneratedCredentials; +}; + +export const getSecretRotationRotateSecretJobOptions = ({ + id, + nextRotationAt +}: Pick) => { + const appCfg = getConfig(); + + return { + jobId: `secret-rotation-v2-rotate-${id}`, + retryLimit: appCfg.isRotationDevelopmentMode ? 3 : 5, + retryBackoff: true, + startAfter: nextRotationAt ?? undefined + }; +}; + +export const calculateNextRotationAt = ({ + rotateAtUtc, + isAutoRotationEnabled, + rotationInterval, + rotationStatus, + isManualRotation, + ...params +}: Pick< + TSecretRotationV2, + "isAutoRotationEnabled" | "lastRotatedAt" | "rotateAtUtc" | "rotationInterval" | "rotationStatus" +> & { isManualRotation: boolean }) => { + if (!isAutoRotationEnabled) return null; + + if (rotationStatus === SecretRotationStatus.Failed) { + return getNextUtcRotationInterval(rotateAtUtc); + } + + const lastRotatedAt = new Date(params.lastRotatedAt); + + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + // treat interval as minute + const nextRotation = new Date(lastRotatedAt.getTime() + rotationInterval * 60 * 1000); + + // in development mode we use rotateAtUtc.minutes as seconds + nextRotation.setUTCSeconds(rotateAtUtc.minutes); + nextRotation.setUTCMilliseconds(0); + + // If creation/manual rotation seconds are after the configured seconds we pad an additional minute + // to ensure a full interval has elapsed before rotation + if (isManualRotation && lastRotatedAt.getUTCSeconds() >= rotateAtUtc.minutes) { + nextRotation.setUTCMinutes(nextRotation.getUTCMinutes() + 1); + } + + return nextRotation; + } + + // production mode - rotationInterval = days + + const nextRotation = new Date(lastRotatedAt); + + nextRotation.setUTCHours(rotateAtUtc.hours); + nextRotation.setUTCMinutes(rotateAtUtc.minutes); + nextRotation.setUTCSeconds(0); + nextRotation.setUTCMilliseconds(0); + + // If creation/manual rotation was after the daily rotation time, + // we need pad an additional day to ensure full rotation interval + if ( + isManualRotation && + (lastRotatedAt.getUTCHours() > rotateAtUtc.hours || + (lastRotatedAt.getUTCHours() === rotateAtUtc.hours && lastRotatedAt.getUTCMinutes() >= rotateAtUtc.minutes)) + ) { + nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval + 1); + } else { + nextRotation.setUTCDate(nextRotation.getUTCDate() + rotationInterval); + } + + return nextRotation; +}; + +export const expandSecretRotation = async ( + { encryptedLastRotationMessage, ...secretRotation }: TSecretRotationV2Raw, + kmsService: TSecretRotationV2ServiceFactoryDep["kmsService"] +) => { + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId: secretRotation.projectId + }); + + const lastRotationMessage = encryptedLastRotationMessage + ? decryptor({ + cipherTextBlob: encryptedLastRotationMessage + }).toString() + : null; + + return { + ...secretRotation, + lastRotationMessage + } as TSecretRotationV2; +}; + +const MAX_MESSAGE_LENGTH = 1024; + +export const parseRotationErrorMessage = (err: unknown): string => { + let errorMessage = `Infisical encountered an issue while generating credentials with the configured inputs: `; + + if (err instanceof AxiosError) { + errorMessage += err?.response?.data + ? JSON.stringify(err?.response?.data) + : err?.message ?? "An unknown error occurred."; + } else { + errorMessage += (err as Error)?.message || "An unknown error occurred."; + } + + return errorMessage.length <= MAX_MESSAGE_LENGTH + ? errorMessage + : `${errorMessage.substring(0, MAX_MESSAGE_LENGTH - 3)}...`; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts new file mode 100644 index 000000000..c0d59332b --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -0,0 +1,12 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const SECRET_ROTATION_NAME_MAP: Record = { + [SecretRotation.PostgresCredentials]: "PostgreSQL Credentials", + [SecretRotation.MsSqlCredentials]: "Microsoft SQL Sever Credentials" +}; + +export const SECRET_ROTATION_CONNECTION_MAP: Record = { + [SecretRotation.PostgresCredentials]: AppConnection.Postgres, + [SecretRotation.MsSqlCredentials]: AppConnection.MsSql +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts new file mode 100644 index 000000000..f15cc4974 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-queue.ts @@ -0,0 +1,193 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; +import { TSecretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + getNextUtcRotationInterval, + getSecretRotationRotateSecretJobOptions +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; +import { SECRET_ROTATION_NAME_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { TSecretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; +import { + TSecretRotationRotateSecretsJobPayload, + TSecretRotationSendNotificationJobPayload +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +type TSecretRotationV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + secretRotationV2DAL: Pick; + secretRotationV2Service: Pick; + smtpService: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; +}; + +export const secretRotationV2QueueServiceFactory = async ({ + queueService, + secretRotationV2DAL, + secretRotationV2Service, + projectMembershipDAL, + projectDAL, + smtpService +}: TSecretRotationV2QueueServiceFactoryDep) => { + const appCfg = getConfig(); + + if (appCfg.isRotationDevelopmentMode) { + logger.warn("Secret Rotation V2 is in development mode."); + } + + await queueService.startPg( + QueueJobs.SecretRotationV2QueueRotations, + async () => { + try { + const rotateBy = getNextUtcRotationInterval(); + + const currentTime = new Date(); + + const secretRotations = await secretRotationV2DAL.findSecretRotationsToQueue(rotateBy); + + logger.info( + `secretRotationV2Queue: Queue Rotations [currentTime=${currentTime.toISOString()}] [rotateBy=${rotateBy.toISOString()}] [count=${ + secretRotations.length + }]` + ); + + for await (const rotation of secretRotations) { + logger.info( + `secretRotationV2Queue: Queue Rotation [rotationId=${rotation.id}] [lastRotatedAt=${new Date( + rotation.lastRotatedAt + ).toISOString()}] [rotateAt=${new Date(rotation.nextRotationAt!).toISOString()}]` + ); + await queueService.queuePg( + QueueJobs.SecretRotationV2RotateSecrets, + { + rotationId: rotation.id, + queuedAt: currentTime + }, + getSecretRotationRotateSecretJobOptions(rotation) + ); + } + } catch (error) { + logger.error(error, "secretRotationV2Queue: Queue Rotations Error:"); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: appCfg.isRotationDevelopmentMode ? 0.5 : 30 + } + ); + + await queueService.startPg( + QueueJobs.SecretRotationV2RotateSecrets, + async ([job]) => { + const { rotationId, queuedAt, isManualRotation } = job.data as TSecretRotationRotateSecretsJobPayload; + const { retryCount, retryLimit } = job; + + const logDetails = `[rotationId=${rotationId}] [jobId=${job.id}] retryCount=[${retryCount}/${retryLimit}]`; + + try { + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) throw new Error(`Secret rotation ${rotationId} not found`); + + if (!secretRotation.isAutoRotationEnabled) { + logger.info(`secretRotationV2Queue: Skipping Rotation - Auto-Rotation Disabled Since Queue ${logDetails}`); + } + + if (new Date(secretRotation.lastRotatedAt).getTime() >= new Date(queuedAt).getTime()) { + // rotated since being queued, skip rotation + logger.info(`secretRotationV2Queue: Skipping Rotation - Rotated Since Queue ${logDetails}`); + return; + } + + await secretRotationV2Service.rotateGeneratedCredentials(secretRotation, { + jobId: job.id, + shouldSendNotification: true, + isFinalAttempt: retryCount === retryLimit, + isManualRotation + }); + + logger.info(`secretRotationV2Queue: Secrets Rotated ${logDetails}`); + } catch (error) { + logger.error(error, `secretRotationV2Queue: Failed to Rotate Secrets ${logDetails}`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 2, + pollingIntervalSeconds: 0.5 + } + ); + + await queueService.startPg( + QueueJobs.SecretRotationV2SendNotification, + async ([job]) => { + const { secretRotation } = job.data as TSecretRotationSendNotificationJobPayload; + try { + const { + name: rotationName, + type, + projectId, + lastRotationAttemptedAt, + folder, + environment, + id: rotationId + } = secretRotation; + + logger.info(`secretRotationV2Queue: Sending Status Notification [rotationId=${rotationId}]`); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const rotationType = SECRET_ROTATION_NAME_MAP[type as SecretRotation]; + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.SecretRotationFailed, + subjectLine: `Secret Rotation Failed`, + substitutions: { + rotationName, + rotationType, + content: `Your ${rotationType} Rotation failed to rotate during it's scheduled rotation. The last rotation attempt occurred at ${new Date( + lastRotationAttemptedAt + ).toISOString()}. Please check the rotation status in Infisical for more details.`, + secretPath: folder.path, + environment: environment.name, + projectName: project.name, + rotationUrl: encodeURI(`${appCfg.SITE_URL}/secret-manager/${projectId}/secrets/${environment.slug}`) + } + }); + } catch (error) { + logger.error( + error, + `secretRotationV2Queue: Failed to Send Status Notification [rotationId=${secretRotation.id}]` + ); + throw error; + } + }, + { + batchSize: 1, + workerCount: 2, + pollingIntervalSeconds: 1 + } + ); + + await queueService.schedulePg( + QueueJobs.SecretRotationV2QueueRotations, + appCfg.isRotationDevelopmentMode ? "* * * * *" : "0 0 * * *", + undefined, + { tz: "UTC" } + ); +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts new file mode 100644 index 000000000..b1be4ea22 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { SecretRotationsV2Schema } from "@app/db/schemas/secret-rotations-v2"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { SECRET_ROTATION_CONNECTION_MAP } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { SecretRotations } from "@app/lib/api-docs"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { slugSchema } from "@app/server/lib/schemas"; + +const RotateAtUtcSchema = z.object({ + hours: z.number().min(0).max(23), + minutes: z.number().min(0).max(59) +}); + +export const BaseSecretRotationSchema = (type: SecretRotation) => + SecretRotationsV2Schema.omit({ + encryptedGeneratedCredentials: true, + encryptedLastRotationMessage: true, + rotateAtUtc: true, + // unique to provider + type: true, + parameters: true, + secretMappings: true + }).extend({ + connection: z.object({ + app: z.literal(SECRET_ROTATION_CONNECTION_MAP[type]), + name: z.string(), + id: z.string().uuid() + }), + environment: z.object({ slug: z.string(), name: z.string(), id: z.string().uuid() }), + projectId: z.string(), + folder: z.object({ id: z.string(), path: z.string() }), + rotateAtUtc: RotateAtUtcSchema, + lastRotationMessage: z.string().nullish() + }); + +export const BaseCreateSecretRotationSchema = (type: SecretRotation) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretRotations.CREATE(type).name), + projectId: z.string().trim().min(1, "Project ID required").describe(SecretRotations.CREATE(type).projectId), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretRotations.CREATE(type).description), + connectionId: z.string().uuid().describe(SecretRotations.CREATE(type).connectionId), + environment: slugSchema({ field: "environment", max: 64 }).describe(SecretRotations.CREATE(type).environment), + secretPath: z + .string() + .trim() + .min(1, "Secret path required") + .transform(removeTrailingSlash) + .describe(SecretRotations.CREATE(type).secretPath), + isAutoRotationEnabled: z + .boolean() + .optional() + .default(true) + .describe(SecretRotations.CREATE(type).isAutoRotationEnabled), + rotationInterval: z.coerce.number().min(1).describe(SecretRotations.CREATE(type).rotationInterval), + rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.CREATE(type).rotateAtUtc) + }); + +export const BaseUpdateSecretRotationSchema = (type: SecretRotation) => + z.object({ + name: slugSchema({ field: "name" }).describe(SecretRotations.UPDATE(type).name).optional(), + description: z + .string() + .trim() + .max(256, "Description cannot exceed 256 characters") + .nullish() + .describe(SecretRotations.UPDATE(type).description), + isAutoRotationEnabled: z.boolean().optional().describe(SecretRotations.UPDATE(type).isAutoRotationEnabled), + rotationInterval: z.coerce.number().min(1).optional().describe(SecretRotations.UPDATE(type).rotationInterval), + rotateAtUtc: RotateAtUtcSchema.optional().describe(SecretRotations.UPDATE(type).rotateAtUtc) + }); diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts new file mode 100644 index 000000000..d0b38808e --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-service.ts @@ -0,0 +1,1290 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import { Knex } from "knex"; +import isEqual from "lodash.isequal"; + +import { ActionProjectType, SecretType, TableName } from "@app/db/schemas"; +import { TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-service"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { hasSecretReadValueOrDescribePermission } from "@app/ee/services/permission/permission-fns"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { + ProjectPermissionSecretActions, + ProjectPermissionSecretRotationActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { SecretRotation, SecretRotationStatus } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + calculateNextRotationAt, + decryptSecretRotationCredentials, + encryptSecretRotationCredentials, + expandSecretRotation, + getNextUtcRotationInterval, + getSecretRotationRotateSecretJobOptions, + listSecretRotationOptions, + parseRotationErrorMessage +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-fns"; +import { + SECRET_ROTATION_CONNECTION_MAP, + SECRET_ROTATION_NAME_MAP +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; +import { + TCreateSecretRotationV2DTO, + TDeleteSecretRotationV2DTO, + TFindSecretRotationV2ByIdDTO, + TFindSecretRotationV2ByNameDTO, + TGetDashboardSecretRotationsV2, + TGetDashboardSecretRotationV2Count, + TListSecretRotationsV2ByProjectId, + TQuickSearchSecretRotationsV2, + TRotateSecretRotationV2, + TRotationFactory, + TSecretRotationRotateGeneratedCredentials, + TSecretRotationV2, + TSecretRotationV2Raw, + TSecretRotationV2WithConnection, + TUpdateSecretRotationV2DTO +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { sqlCredentialsRotationFactory } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials"; +import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { QueueJobs, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnection } from "@app/services/app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; +import { TResourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal"; +import { TSecretQueueFactory } from "@app/services/secret/secret-queue"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; +import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal"; +import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-dal"; +import { + fnSecretBulkDelete, + fnSecretBulkInsert, + fnSecretBulkUpdate, + reshapeBridgeSecret +} from "@app/services/secret-v2-bridge/secret-v2-bridge-fns"; +import { TSecretVersionV2DALFactory } from "@app/services/secret-v2-bridge/secret-version-dal"; +import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/secret-version-tag-dal"; + +import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; + +export type TSecretRotationV2ServiceFactoryDep = { + secretRotationV2DAL: TSecretRotationV2DALFactory; + appConnectionService: Pick; + permissionService: Pick; + projectBotService: Pick; + kmsService: Pick; + licenseService: Pick; + auditLogService: Pick; + keyStore: Pick; + folderDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "bulkUpdate" | "insertMany" | "deleteMany" | "upsertSecretReferences" | "find" + >; + secretVersionV2BridgeDAL: Pick; + secretVersionTagV2BridgeDAL: Pick; + resourceMetadataDAL: Pick; + secretTagDAL: Pick; + secretQueueService: Pick; + snapshotService: Pick; + queueService: Pick; +}; + +export type TSecretRotationV2ServiceFactory = ReturnType; + +const MAX_GENERATED_CREDENTIALS_LENGTH = 2; + +const SECRET_ROTATION_FACTORY_MAP: Record = { + [SecretRotation.PostgresCredentials]: sqlCredentialsRotationFactory, + [SecretRotation.MsSqlCredentials]: sqlCredentialsRotationFactory +}; + +export const secretRotationV2ServiceFactory = ({ + secretRotationV2DAL, + folderDAL, + secretV2BridgeDAL, + secretVersionV2BridgeDAL, + secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL, + permissionService, + appConnectionService, + projectBotService, + licenseService, + kmsService, + auditLogService, + secretQueueService, + snapshotService, + keyStore, + queueService +}: TSecretRotationV2ServiceFactoryDep) => { + const $queueSendSecretRotationStatusNotification = async (secretRotation: TSecretRotationV2Raw) => { + const appCfg = getConfig(); + if (!appCfg.isSmtpConfigured) return; // comment out if testing email sending + + await queueService.queuePg( + QueueJobs.SecretRotationV2SendNotification, + { secretRotation }, + { + jobId: `secret-rotation-v2-notification-${secretRotation.id}`, + retryLimit: 5, + retryBackoff: true + } + ); + }; + + const $throwOnConflictingSecrets = async ({ + secretKeys, + folderId, + tx, + secretPath + }: { + secretKeys: string[]; + folderId: string; + tx: Knex; + secretPath: string; + }) => { + if (new Set(secretKeys).size !== secretKeys.length) { + throw new BadRequestError({ + message: `Secrets mapping keys must be unique. "${secretKeys.join(", ")}" contains duplicate keys.` + }); + } + + const conflictingSecrets = await secretV2BridgeDAL.find( + { + $in: { + [`${TableName.SecretV2}.key` as "key"]: secretKeys + }, + [`${TableName.SecretV2}.folderId` as "folderId"]: folderId, + [`${TableName.SecretV2}.type` as "type"]: SecretType.Shared + }, + { tx } + ); + + if (conflictingSecrets.length) { + throw new BadRequestError({ + message: `The following secrets already exist at the path "${secretPath}": ${conflictingSecrets + .map(({ key }) => key) + .join(", ")}` + }); + } + }; + + const listSecretRotationsByProjectId = async ( + { projectId, type }: TListSecretRotationsV2ByProjectId, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotations due to plan restriction. Upgrade plan to access secret rotations." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); + + const secretRotations = await secretRotationV2DAL.find({ + ...(type && { type }), + projectId + }); + + return Promise.all( + secretRotations + .filter((rotation) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment: rotation.environment.slug, + secretPath: rotation.folder.path + }) + ) + ) + .map((rotation) => expandSecretRotation(rotation, kmsService)) + ); + }; + + const findSecretRotationById = async ({ type, rotationId }: TFindSecretRotationV2ByIdDTO, actor: OrgServiceActor) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + return expandSecretRotation(secretRotation, kmsService); + }; + + const findSecretRotationGeneratedCredentialsById = async ( + { type, rotationId }: TFindSecretRotationV2ByIdDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to access secret rotation credentials due to plan restriction. Upgrade plan to access secret rotations credentials." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection, encryptedGeneratedCredentials } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.ReadGeneratedCredentials, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const generatedCredentials = await decryptSecretRotationCredentials({ + projectId, + encryptedGeneratedCredentials, + kmsService + }); + + return { + generatedCredentials, + secretRotation: secretRotation as TSecretRotationV2 + }; + }; + + const findSecretRotationByName = async ( + { type, rotationName, secretPath, environment, projectId }: TFindSecretRotationV2ByNameDTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to access secret rotation due to plan restriction. Upgrade plan to access secret rotations." + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + // we prevent conflicting names within a folder + const secretRotation = await secretRotationV2DAL.findOne({ + name: rotationName, + folderId: folder.id + }); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with name "${rotationName}"` + }); + + const { connection, id: rotationId } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { + environment, + secretPath + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + return expandSecretRotation(secretRotation, kmsService); + }; + + const createSecretRotation = async ( + { + projectId, + secretPath, + environment, + rotateAtUtc = { hours: 0, minutes: 0 }, + secretsMapping, + ...payload + }: TCreateSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to create secret rotation due to plan restriction. Upgrade plan to create secret rotations." + }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); + + if (!shouldUseSecretV2Bridge) + throw new BadRequestError({ + message: + "Project version does not support Secret Rotation V2. Please upgrade your project via the Infiscal Dashboard to gain access." + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Create, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new BadRequestError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + const typeApp = SECRET_ROTATION_CONNECTION_MAP[payload.type]; + + // validates permission to connect and app is valid for rotation type + const connection = await appConnectionService.connectAppConnectionById(typeApp, payload.connectionId, actor); + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[payload.type]({ + parameters: payload.parameters, + secretsMapping, + connection + } as TSecretRotationV2WithConnection); + + try { + const currentTime = new Date(); + + // callback structure to support transactional rollback when possible + const secretRotation = await rotationFactory.issueCredentials(async (newCredentials) => { + const encryptedGeneratedCredentials = await encryptSecretRotationCredentials({ + generatedCredentials: [newCredentials], + projectId, + kmsService + }); + + return secretRotationV2DAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + + await $throwOnConflictingSecrets({ + secretPath, + secretKeys: Object.values(secretsMapping), + tx, + folderId: folder.id + }); + + const createdRotation = await secretRotationV2DAL.create( + { + folderId: folder.id, + secretsMapping, + ...payload, + encryptedGeneratedCredentials, + rotateAtUtc, + rotationStatus: SecretRotationStatus.Success, + lastRotationAttemptedAt: currentTime, + lastRotatedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + lastRotatedAt: currentTime, + isAutoRotationEnabled: Boolean(payload.isAutoRotationEnabled), + rotateAtUtc, + rotationInterval: payload.rotationInterval, + rotationStatus: SecretRotationStatus.Success, + isManualRotation: true + }) + }, + tx + ); + + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const mappedSecrets = await fnSecretBulkInsert({ + folderId: folder.id, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + key, + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + await secretRotationV2DAL.insertSecretMappings( + mappedSecrets.map((secret) => ({ + secretId: secret.id, + rotationId: createdRotation.id + })), + tx + ); + + return createdRotation; + }); + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath, + projectId, + environmentSlug: environment, + excludeReplication: true + }); + + return await expandSecretRotation(secretRotation, kmsService); + } catch (err) { + if (err instanceof DatabaseError) { + const error = err.error as { code: string; message: string; table: string }; + + if (error.code === DatabaseErrorCode.UniqueViolation) { + switch (error.table) { + case TableName.SecretRotationV2: + throw new BadRequestError({ + message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${secretPath}"` + }); + default: + throw err; + } + } + + throw err; + } + + if (err instanceof BadRequestError) throw err; + + throw new BadRequestError({ + message: parseRotationErrorMessage(err) + }); + } + }; + + const updateSecretRotation = async ( + { type, rotationId, ...payload }: TUpdateSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to update secret rotation due to plan restriction. Upgrade plan to update secret rotations." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID ${rotationId}` + }); + + const { folder, environment, projectId, folderId, connection } = secretRotation; + const secretsMapping = secretRotation.secretsMapping as TSecretRotationV2["secretsMapping"]; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Edit, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const nextRotationAt = calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + ...payload, + isManualRotation: secretRotation.isLastRotationManual + }); + + let secretsMappingUpdated = false; + + try { + const updatedSecretRotation = await secretRotationV2DAL.transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.SecretRotationV2Creation(folder.id)]); + + if (payload.secretsMapping && !isEqual(payload.secretsMapping, secretsMapping)) { + const currentMappingKeys = Object.values(secretsMapping); + await $throwOnConflictingSecrets({ + secretPath: folder.path, + secretKeys: Object.values(payload.secretsMapping).filter((key) => !currentMappingKeys.includes(key)), + tx, + folderId: folder.id + }); + + // update mapped secrets names + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: Object.entries(secretsMapping).map(([mappingKey, secretKey]) => ({ + filter: { + key: secretKey, + folderId, + type: SecretType.Shared + }, + data: { + key: payload.secretsMapping![mappingKey as keyof TSecretRotationV2["secretsMapping"]] + } + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + secretsMappingUpdated = true; + } + + return secretRotationV2DAL.updateById( + rotationId, + { + ...payload, + nextRotationAt + }, + tx + ); + }); + + if (secretsMappingUpdated) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + // queue for rotation if adjusted time falls before next cron + if (nextRotationAt && nextRotationAt.getTime() < getNextUtcRotationInterval().getTime()) { + await queueService.queuePg( + QueueJobs.SecretRotationV2RotateSecrets, + { rotationId, queuedAt: new Date(), isManualRotation: true }, + getSecretRotationRotateSecretJobOptions(updatedSecretRotation) + ); + } + + return await expandSecretRotation(updatedSecretRotation, kmsService); + } catch (err) { + if (err instanceof DatabaseError) { + const error = err.error as { code: string; message: string; table: string }; + + if (error.code === DatabaseErrorCode.UniqueViolation) { + switch (error.table) { + case TableName.SecretRotationV2: + if (payload.name) + throw new BadRequestError({ + message: `A Secret Rotation with the name "${payload.name}" already exists at the secret path "${folder.path}"` + }); + break; + default: + throw err; + } + } + } + + if (err instanceof BadRequestError) throw err; + + throw err; + } + }; + + const deleteSecretRotation = async ( + { type, rotationId, deleteSecrets, revokeGeneratedCredentials }: TDeleteSecretRotationV2DTO, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: "Failed to delete secret rotation due to plan restriction. Upgrade plan to delete secret rotation." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { folder, environment, projectId, encryptedGeneratedCredentials, connection, folderId, secretsMapping } = + secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Delete, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const deleteTransaction = secretRotationV2DAL.transaction(async (tx) => { + if (deleteSecrets) { + await fnSecretBulkDelete({ + secretDAL: secretV2BridgeDAL, + secretQueueService, + inputSecrets: Object.values(secretsMapping as TSecretRotationV2["secretsMapping"]).map((secretKey) => ({ + secretKey, + type: SecretType.Shared + })), + projectId, + folderId, + actorId: actor.id, // not actually used since rotated secrets are shared + tx + }); + } + + return secretRotationV2DAL.deleteById(rotationId, tx); + }); + + if (revokeGeneratedCredentials) { + const appConnection = await decryptAppConnection(connection, kmsService); + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type]({ + ...secretRotation, + connection: appConnection + } as TSecretRotationV2WithConnection); + + const generatedCredentials = await decryptSecretRotationCredentials({ + encryptedGeneratedCredentials, + projectId, + kmsService + }); + + await rotationFactory.revokeCredentials(generatedCredentials, async () => deleteTransaction); + } else { + await deleteTransaction; + } + + if (deleteSecrets) { + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + } + + return expandSecretRotation(secretRotation, kmsService); + }; + + const rotateGeneratedCredentials = async ( + secretRotation: TSecretRotationV2Raw, + { + auditLogInfo, + jobId, + shouldSendNotification, + isFinalAttempt = true, + isManualRotation = false + }: TSecretRotationRotateGeneratedCredentials = {} + ) => { + const { + connection, + folder, + environment, + encryptedGeneratedCredentials, + activeIndex, + projectId, + type, + folderId, + id: rotationId, + parameters, + secretsMapping + } = secretRotation; + + let lock: Awaited> | undefined; + + try { + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.SecretRotationLock(rotationId)], 60 * 1000); + } catch (e) { + throw new InternalServerError({ + message: "Failed to acquire rotation lock." + }); + } + + const appConnection = await decryptAppConnection(connection, kmsService); + + const generatedCredentials = await decryptSecretRotationCredentials({ + projectId, + encryptedGeneratedCredentials, + kmsService + }); + + const inactiveIndex = (activeIndex + 1) % MAX_GENERATED_CREDENTIALS_LENGTH; + + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + const rotationFactory = SECRET_ROTATION_FACTORY_MAP[type as SecretRotation]({ + ...secretRotation, + connection: appConnection + } as TSecretRotationV2WithConnection); + + const updatedRotation = await rotationFactory.rotateCredentials(inactiveCredentials, async (newCredentials) => { + const updatedCredentials = [...generatedCredentials]; + updatedCredentials[inactiveIndex] = newCredentials; + + const encryptedUpdatedCredentials = await encryptSecretRotationCredentials({ + projectId, + generatedCredentials: updatedCredentials, + kmsService + }); + + return secretRotationV2DAL.transaction(async (tx) => { + const secretsPayload = rotationFactory.getSecretsPayload(newCredentials); + + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + // update mapped secrets with new credential values + await fnSecretBulkUpdate({ + folderId, + orgId: connection.orgId, + tx, + inputSecrets: secretsPayload.map(({ key, value }) => ({ + filter: { + key, + folderId, + type: SecretType.Shared + }, + data: { + encryptedValue: encryptor({ + plainText: Buffer.from(value) + }).cipherTextBlob, + references: [] + } + })), + secretDAL: secretV2BridgeDAL, + secretVersionDAL: secretVersionV2BridgeDAL, + secretVersionTagDAL: secretVersionTagV2BridgeDAL, + secretTagDAL, + resourceMetadataDAL + }); + + const currentTime = new Date(); + + return secretRotationV2DAL.updateById( + secretRotation.id, + { + encryptedGeneratedCredentials: encryptedUpdatedCredentials, + activeIndex: inactiveIndex, + isLastRotationManual: isManualRotation, + lastRotatedAt: currentTime, + lastRotationAttemptedAt: currentTime, + nextRotationAt: calculateNextRotationAt({ + ...(secretRotation as TSecretRotationV2), + rotationStatus: SecretRotationStatus.Success, + lastRotatedAt: currentTime, + isManualRotation + }), + rotationStatus: SecretRotationStatus.Success, + lastRotationJobId: jobId, + encryptedLastRotationMessage: null + }, + tx + ); + }); + }); + + await auditLogService.createAuditLog({ + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + projectId, + event: { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS, + metadata: { + type, + rotationId, + connectionId: connection.id, + folderId, + parameters, + secretsMapping, + status: SecretRotationStatus.Success, + occurredAt: new Date(), + message: null, + jobId + } + } + }); + + await snapshotService.performSnapshot(folder.id); + await secretQueueService.syncSecrets({ + orgId: connection.orgId, + secretPath: folder.path, + projectId, + environmentSlug: environment.slug, + excludeReplication: true + }); + + return updatedRotation; + } catch (error) { + const errorMessage = parseRotationErrorMessage(error); + + if (isFinalAttempt) { + const { encryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const { cipherTextBlob: encryptedMessage } = encryptor({ + plainText: Buffer.from(errorMessage) + }); + + const updatedRotation = await secretRotationV2DAL.updateById(secretRotation.id, { + rotationStatus: SecretRotationStatus.Failed, + lastRotationJobId: jobId, + lastRotationAttemptedAt: new Date(), + encryptedLastRotationMessage: encryptedMessage, + nextRotationAt: getNextUtcRotationInterval(secretRotation.rotateAtUtc as TSecretRotationV2["rotateAtUtc"]) + }); + + if (shouldSendNotification) { + await $queueSendSecretRotationStatusNotification(updatedRotation); + } + } + + await auditLogService.createAuditLog({ + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + projectId, + event: { + type: EventType.SECRET_ROTATION_ROTATE_SECRETS, + metadata: { + type, + rotationId, + connectionId: connection.id, + folderId, + parameters, + secretsMapping, + occurredAt: new Date(), + status: SecretRotationStatus.Failed, + message: isFinalAttempt ? "See Rotation status for details" : "Rotation will be re-attempted shortly...", + jobId + } + } + }); + + throw new BadRequestError({ message: errorMessage }); + } finally { + await lock?.release(); + } + }; + + const rotateSecretRotation = async ( + { rotationId, type, auditLogInfo }: TRotateSecretRotationV2, + actor: OrgServiceActor + ) => { + const plan = await licenseService.getPlan(actor.orgId); + + if (!plan.secretRotation) + throw new BadRequestError({ + message: + "Failed to rotate secret rotation secrets due to plan restriction. Upgrade plan to rotate secret rotation secrets." + }); + + const secretRotation = await secretRotationV2DAL.findById(rotationId); + + if (!secretRotation) + throw new NotFoundError({ + message: `Could not find ${SECRET_ROTATION_NAME_MAP[type]} Rotation with ID "${rotationId}"` + }); + + const { projectId, environment, folder, connection } = secretRotation; + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.RotateSecrets, + subject(ProjectPermissionSub.SecretRotation, { + environment: environment.slug, + secretPath: folder.path + }) + ); + + if (connection.app !== SECRET_ROTATION_CONNECTION_MAP[type]) + throw new BadRequestError({ + message: `Secret Rotation with ID "${rotationId}" is not configured for ${SECRET_ROTATION_NAME_MAP[type]}` + }); + + const isRotationOccurring = Boolean(await keyStore.getItem(KeyStorePrefixes.SecretRotationLock(secretRotation.id))); + + if (isRotationOccurring) + throw new BadRequestError({ message: `A rotation is already in progress. Please try again shortly.` }); + + try { + const updatedRotation = await rotateGeneratedCredentials(secretRotation, { + auditLogInfo, + isManualRotation: true + }); + + return await expandSecretRotation(updatedRotation, kmsService); + } catch (err) { + throw new InternalServerError({ + message: (err as Error).message ?? "Failed to rotate secrets: check Rotation status for details." + }); + } + }; + + const getDashboardSecretRotationCount = async ( + { projectId, environments, secretPath, search }: TGetDashboardSecretRotationV2Count, + actor: OrgServiceActor + ) => { + // we don't check plan for dashboard like dynamic secret, actions will be prevented + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const permissiveEnvironments = environments.filter((environment) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ) + ); + + if (!permissiveEnvironments.length) return 0; + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); + + if (!folders.length) { + throw new NotFoundError({ + message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( + ", " + )}' not found` + }); + } + + const count = await secretRotationV2DAL.findWithMappedSecretsCount({ + $in: { folderId: folders.map((folder) => folder.id) }, + search, + projectId + }); + + return count; + }; + + const getDashboardSecretRotations = async ( + { + projectId, + environments, + secretPath, + search, + limit, + offset = 0, + orderBy = SecretsOrderBy.Name, + orderDirection = OrderByDirection.ASC + }: TGetDashboardSecretRotationsV2, + actor: OrgServiceActor + ) => { + // we don't check plan for dashboard like dynamic secret, actions will be prevented + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + const permissiveEnvironments = environments.filter((environment) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath }) + ) + ); + + if (!permissiveEnvironments.length) return []; + + const folders = await folderDAL.findBySecretPathMultiEnv(projectId, permissiveEnvironments, secretPath); + + if (!folders.length) { + throw new NotFoundError({ + message: `Folders with path '${secretPath}' in environments with slugs '${permissiveEnvironments.join( + ", " + )}' not found` + }); + } + + const folderIds = folders.map((folder) => folder.id); + + const secretRotations = await secretRotationV2DAL.findWithMappedSecrets( + { + $in: { folderId: folderIds }, + search, + projectId + }, + { + limit, + offset, + sort: orderBy ? [[orderBy, orderDirection]] : undefined + } + ); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + + const secretRotationsWithSecrets = await Promise.all( + secretRotations.map(async ({ secrets, ...rotation }) => { + const decryptedSecrets = secrets.map((secret) => { + const canDescribeSecret = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.DescribeSecret, + { + environment: rotation.environment.slug, + secretPath: rotation.folder.path, + secretName: secret.key, + // TODO: scott/akhil our mapper seems to not propagate children's children types + // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) + } + ); + + if (!canDescribeSecret) { + return null; // return null so we know to display empty row in dashboard + } + + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: rotation.environment.slug, + secretPath: rotation.folder.path, + secretName: secret.key, + // TODO: scott/akhil our mapper seems to not propagate children's children types + // @ts-expect-error eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-assignment + secretTags: (secret.tags as { slug: string; name: string; color: string }[]).map((i) => i.slug) + } + ); + + return reshapeBridgeSecret( + projectId, + rotation.environment.slug, + rotation.folder.path, + { + ...secret, + value: secret.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedValue }).toString() + : "", + comment: secret.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: secret.encryptedComment }).toString() + : "" + }, + secretValueHidden && secret.type === SecretType.Shared + ); + }); + + const expandedRotation = await expandSecretRotation(rotation, kmsService); + + return { + ...expandedRotation, + secrets: decryptedSecrets + }; + }) + ); + + return secretRotationsWithSecrets as (TSecretRotationV2 & { + secrets: Awaited>[]; + })[]; + }; + + const getQuickSearchSecretRotations = async ( + { folderMappings, filters: { search, ...options }, projectId }: TQuickSearchSecretRotationsV2, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const permissiveFolderMappings = folderMappings.filter(({ path, environment }) => + permission.can( + ProjectPermissionSecretRotationActions.Read, + subject(ProjectPermissionSub.SecretRotation, { environment, secretPath: path }) + ) + ); + + if (!permissiveFolderMappings.length) return []; + + const secretRotations = await secretRotationV2DAL.find( + { + projectId, + $search: { + name: `%${search}%` + }, + $in: { + folderId: permissiveFolderMappings.map(({ folderId }) => folderId) + } + }, + options + ); + + return secretRotations as TSecretRotationV2[]; + }; + + return { + listSecretRotationOptions, + listSecretRotationsByProjectId, + createSecretRotation, + updateSecretRotation, + findSecretRotationById, + findSecretRotationByName, + deleteSecretRotation, + findSecretRotationGeneratedCredentialsById, + rotateSecretRotation, + rotateGeneratedCredentials, + getDashboardSecretRotationCount, + getDashboardSecretRotations, + getQuickSearchSecretRotations + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts new file mode 100644 index 000000000..7102f7de3 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-types.ts @@ -0,0 +1,155 @@ +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { TSqlCredentialsRotationGeneratedCredentials } from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; +import { OrderByDirection } from "@app/lib/types"; +import { SecretsOrderBy } from "@app/services/secret/secret-types"; + +import { + TMsSqlCredentialsRotation, + TMsSqlCredentialsRotationInput, + TMsSqlCredentialsRotationListItem, + TMsSqlCredentialsRotationWithConnection +} from "./mssql-credentials"; +import { + TPostgresCredentialsRotation, + TPostgresCredentialsRotationInput, + TPostgresCredentialsRotationListItem, + TPostgresCredentialsRotationWithConnection +} from "./postgres-credentials"; +import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; +import { SecretRotation } from "./secret-rotation-v2-enums"; + +export type TSecretRotationV2 = TPostgresCredentialsRotation | TMsSqlCredentialsRotation; + +export type TSecretRotationV2WithConnection = + | TPostgresCredentialsRotationWithConnection + | TMsSqlCredentialsRotationWithConnection; + +export type TSecretRotationV2GeneratedCredentials = TSqlCredentialsRotationGeneratedCredentials; + +export type TSecretRotationV2Input = TPostgresCredentialsRotationInput | TMsSqlCredentialsRotationInput; + +export type TSecretRotationV2ListItem = TPostgresCredentialsRotationListItem | TMsSqlCredentialsRotationListItem; + +export type TSecretRotationV2Raw = NonNullable>>; + +export type TListSecretRotationsV2ByProjectId = { + projectId: string; + type?: SecretRotation; +}; + +export type TFindSecretRotationV2ByIdDTO = { + rotationId: string; + type: SecretRotation; +}; + +export type TRotateSecretRotationV2 = TFindSecretRotationV2ByIdDTO & { auditLogInfo: AuditLogInfo }; + +export type TRotateAtUtc = { hours: number; minutes: number }; + +export type TFindSecretRotationV2ByNameDTO = { + rotationName: string; + secretPath: string; + environment: string; + projectId: string; + type: SecretRotation; +}; + +export type TCreateSecretRotationV2DTO = Pick< + TSecretRotationV2, + "parameters" | "secretsMapping" | "description" | "rotationInterval" | "name" | "connectionId" | "projectId" +> & { + type: SecretRotation; + secretPath: string; + environment: string; + isAutoRotationEnabled?: boolean; + rotateAtUtc?: TRotateAtUtc; +}; + +export type TUpdateSecretRotationV2DTO = Partial< + Omit +> & { + rotationId: string; + type: SecretRotation; +}; + +export type TDeleteSecretRotationV2DTO = { + type: SecretRotation; + rotationId: string; + deleteSecrets: boolean; + revokeGeneratedCredentials: boolean; +}; + +export type TGetDashboardSecretRotationV2Count = { + search?: string; + projectId: string; + secretPath: string; + environments: string[]; +}; + +export type TGetDashboardSecretRotationsV2 = { + search?: string; + projectId: string; + secretPath: string; + environments: string[]; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + limit: number; + offset: number; +}; + +export type TQuickSearchSecretRotationsV2Filters = { + offset?: number; + limit?: number; + orderBy?: SecretsOrderBy; + orderDirection?: OrderByDirection; + search?: string; +}; + +export type TQuickSearchSecretRotationsV2 = { + projectId: string; + folderMappings: { folderId: string; path: string; environment: string }[]; + filters: TQuickSearchSecretRotationsV2Filters; +}; + +export type TSecretRotationRotateGeneratedCredentials = { + auditLogInfo?: AuditLogInfo; + jobId?: string; + shouldSendNotification?: boolean; + isFinalAttempt?: boolean; + isManualRotation?: boolean; +}; + +export type TSecretRotationRotateSecretsJobPayload = { rotationId: string; queuedAt: Date; isManualRotation: boolean }; + +export type TSecretRotationSendNotificationJobPayload = { + secretRotation: TSecretRotationV2Raw; +}; + +// scott: the reason for the callback structure of the rotation factory is to facilitate, when possible, +// transactional behavior. By passing in the rotation mutation, if this mutation fails we can roll back the +// third party credential changes (when supported), preventing credentials getting out of sync + +export type TRotationFactoryIssueCredentials = ( + callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise +) => Promise; + +export type TRotationFactoryRevokeCredentials = ( + generatedCredentials: TSecretRotationV2GeneratedCredentials, + callback: () => Promise +) => Promise; + +export type TRotationFactoryRotateCredentials = ( + credentialsToRevoke: TSecretRotationV2GeneratedCredentials[number] | undefined, + callback: (newCredentials: TSecretRotationV2GeneratedCredentials[number]) => Promise +) => Promise; + +export type TRotationFactoryGetSecretsPayload = ( + generatedCredentials: TSecretRotationV2GeneratedCredentials[number] +) => { key: string; value: string }[]; + +export type TRotationFactory = (secretRotation: TSecretRotationV2WithConnection) => { + issueCredentials: TRotationFactoryIssueCredentials; + revokeCredentials: TRotationFactoryRevokeCredentials; + rotateCredentials: TRotationFactoryRotateCredentials; + getSecretsPayload: TRotationFactoryGetSecretsPayload; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts new file mode 100644 index 000000000..0c4bbd014 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { MsSqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; + +export const SecretRotationV2Schema = z.discriminatedUnion("type", [ + PostgresCredentialsRotationSchema, + MsSqlCredentialsRotationSchema +]); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts new file mode 100644 index 000000000..1ab210d66 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/index.ts @@ -0,0 +1,2 @@ +export * from "./sql-credentials-rotation-fns"; +export * from "./sql-credentials-rotation-schemas"; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts new file mode 100644 index 000000000..17983eb43 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -0,0 +1,232 @@ +import { randomInt } from "crypto"; + +import { + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { getSqlConnectionClient, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql"; + +import { + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationWithConnection +} from "./sql-credentials-rotation-types"; + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = () => { + try { + const { length, required, allowedSymbols } = DEFAULT_PASSWORD_REQUIREMENTS; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGeneratedCredentials) => { + const error = e as Error; + + if (!error?.message) return "Unknown error"; + + let redactedMessage = error.message; + + credentials.forEach(({ password }) => { + redactedMessage = redactedMessage.replaceAll(password, "*******************"); + }); + + return redactedMessage; +}; + +export const sqlCredentialsRotationFactory = (secretRotation: TSqlCredentialsRotationWithConnection) => { + const { + connection, + parameters: { username1, username2 }, + activeIndex, + secretsMapping + } = secretRotation; + + const validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { + const client = await getSqlConnectionClient({ + ...connection, + credentials: { + ...connection.credentials, + ...credentials + } + }); + + try { + await client.raw("SELECT 1"); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + await client.destroy(); + } + }; + + const issueCredentials: TRotationFactoryIssueCredentials = async (callback) => { + const client = await getSqlConnectionClient(connection); + + // For SQL, since we get existing users, we change both their passwords + // on issue to invalidate their existing passwords + const credentialsSet = [ + { username: username1, password: generatePassword() }, + { username: username2, password: generatePassword() } + ]; + + try { + await client.transaction(async (tx) => { + for await (const credentials of credentialsSet) { + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); + } catch (error) { + throw new Error(redactPasswords(error, credentialsSet)); + } finally { + await client.destroy(); + } + + for await (const credentials of credentialsSet) { + await validateCredentials(credentials); + } + + return callback(credentialsSet[0]); + }; + + const revokeCredentials: TRotationFactoryRevokeCredentials = async (credentialsToRevoke, callback) => { + const client = await getSqlConnectionClient(connection); + + const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() })); + + try { + await client.transaction(async (tx) => { + for await (const credentials of revokedCredentials) { + // invalidate previous passwords + await tx.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } + }); + } catch (error) { + throw new Error(redactPasswords(error, revokedCredentials)); + } finally { + await client.destroy(); + } + + return callback(); + }; + + const rotateCredentials: TRotationFactoryRotateCredentials = async (_, callback) => { + const client = await getSqlConnectionClient(connection); + + // generate new password for the next active user + const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() }; + + try { + await client.raw(...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[connection.app](credentials)); + } catch (error) { + throw new Error(redactPasswords(error, [credentials])); + } finally { + await client.destroy(); + } + + await validateCredentials(credentials); + + return callback(credentials); + }; + + const getSecretsPayload: TRotationFactoryGetSecretsPayload = (generatedCredentials) => { + const { username, password } = secretsMapping; + + const secrets = [ + { + key: username, + value: generatedCredentials.username + }, + { + key: password, + value: generatedCredentials.password + } + ]; + + return secrets; + }; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload, + validateCredentials + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts new file mode 100644 index 000000000..7ec47741f --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-schemas.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; + +export const SqlCredentialsRotationGeneratedCredentialsSchema = z + .object({ + username: z.string(), + password: z.string() + }) + .array() + .min(1) + .max(2); + +export const SqlCredentialsRotationParametersSchema = z.object({ + username1: z + .string() + .trim() + .min(1, "Username1 Required") + .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username1), + username2: z + .string() + .trim() + .min(1, "Username2 Required") + .describe(SecretRotations.PARAMETERS.SQL_CREDENTIALS.username2) +}); + +export const SqlCredentialsRotationSecretsMappingSchema = z.object({ + username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.username), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.SQL_CREDENTIALS.password) +}); + +export const SqlCredentialsRotationTemplateSchema = z.object({ + createUserStatement: z.string(), + secretsMapping: z.object({ + username: z.string(), + password: z.string() + }) +}); diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts new file mode 100644 index 000000000..6eada6019 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { TMsSqlCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/mssql-credentials"; +import { TPostgresCredentialsRotationWithConnection } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; + +import { SqlCredentialsRotationGeneratedCredentialsSchema } from "./sql-credentials-rotation-schemas"; + +export type TSqlCredentialsRotationWithConnection = + | TPostgresCredentialsRotationWithConnection + | TMsSqlCredentialsRotationWithConnection; + +export type TSqlCredentialsRotationGeneratedCredentials = z.infer< + typeof SqlCredentialsRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts index 8d458111a..df7b86a0b 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-service.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-service.ts @@ -16,8 +16,8 @@ import { TSecretV2BridgeDALFactory } from "@app/services/secret-v2-bridge/secret import { TLicenseServiceFactory } from "../license/license-service"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { - ProjectPermissionActions, ProjectPermissionSecretActions, + ProjectPermissionSecretRotationActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretRotationDALFactory } from "./secret-rotation-dal"; @@ -69,7 +69,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); return { custom: [], @@ -99,7 +102,7 @@ export const secretRotationServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Create, + ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation ); @@ -208,7 +211,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Read, + ProjectPermissionSub.SecretRotation + ); const { botKey, shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId); if (shouldUseSecretV2Bridge) { const docs = await secretRotationDAL.findSecretV2({ projectId }); @@ -254,7 +260,10 @@ export const secretRotationServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretRotation); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretRotationActions.Edit, + ProjectPermissionSub.SecretRotation + ); await secretRotationQueue.removeFromQueue(doc.id, doc.interval); await secretRotationQueue.addToQueue(doc.id, doc.interval); return doc; @@ -273,7 +282,7 @@ export const secretRotationServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionActions.Delete, + ProjectPermissionSecretRotationActions.Delete, ProjectPermissionSub.SecretRotation ); const deletedDoc = await secretRotationDAL.transaction(async (tx) => { 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 5fbb3f598..015a8d420 100644 --- a/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts +++ b/backend/src/ee/services/secret-snapshot/secret-snapshot-service.ts @@ -398,8 +398,32 @@ export const secretSnapshotServiceFactory = ({ if (shouldUseBridge) { const rollback = await snapshotDAL.transaction(async (tx) => { const rollbackSnaps = await snapshotDAL.findRecursivelySnapshotsV2Bridge(snapshot.id, tx); - // this will remove all secrets in current folder - const deletedTopLevelSecs = await secretV2BridgeDAL.delete({ folderId: snapshot.folderId }, tx); + const secretRotationIds = rollbackSnaps + .flatMap((snap) => snap.secretVersions) + .filter((el) => el.isRotatedSecret) + .map((el) => el.secretId); + + // this will remove all secrets in current folder except rotated secrets which we ignore + const deletedTopLevelSecs = await secretV2BridgeDAL.delete( + { + $complex: { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: snapshot.folderId + }, + { + operator: "notIn", + field: "id", + value: secretRotationIds + } + ] + } + }, + tx + ); 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 @@ -424,28 +448,31 @@ export const secretSnapshotServiceFactory = ({ ); const secrets = await secretV2BridgeDAL.insertMany( rollbackSnaps.flatMap(({ secretVersions, folderId }) => - secretVersions.map( - ({ - latestSecretVersion, - version, - updatedAt, - createdAt, - secretId, - envId, - id, - tags, - // exclude the bottom fields from the secret - they are for versioning only. - userActorId, - identityActorId, - actorType, - ...el - }) => ({ - ...el, - id: secretId, - version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, - folderId - }) - ) + secretVersions + .filter((v) => !v.isRotatedSecret) + .map( + ({ + latestSecretVersion, + version, + updatedAt, + createdAt, + secretId, + envId, + id, + tags, + // exclude the bottom fields from the secret - they are for versioning only. + userActorId, + identityActorId, + actorType, + isRotatedSecret, + ...el + }) => ({ + ...el, + id: secretId, + version: deletedTopLevelSecsGroupById[secretId] ? latestSecretVersion + 1 : latestSecretVersion, + folderId + }) + ) ), tx ); diff --git a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts index d8240f27e..c547d85c2 100644 --- a/backend/src/ee/services/secret-snapshot/snapshot-dal.ts +++ b/backend/src/ee/services/secret-snapshot/snapshot-dal.ts @@ -181,6 +181,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SnapshotFolder}.folderVersionId`, `${TableName.SecretFolderVersion}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretRotationV2SecretMapping}.secretId`, + `${TableName.SecretVersionV2}.secretId` + ) .select(selectAllTableCols(TableName.SecretVersionV2)) .select( db.ref("id").withSchema(TableName.Snapshot).as("snapshotId"), @@ -195,7 +200,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); return sqlNestRelationships({ data, @@ -221,7 +227,11 @@ export const snapshotDALFactory = (db: TDbClient) => { { key: "id", label: "secretVersions" as const, - mapper: (el) => SecretVersionsV2Schema.parse(el), + mapper: (el) => ({ + ...SecretVersionsV2Schema.parse(el), + isRotatedSecret: Boolean(el.rotationId), + rotationId: el.rotationId + }), childrenMapper: [ { key: "tagVersionId", @@ -476,6 +486,11 @@ export const snapshotDALFactory = (db: TDbClient) => { `${TableName.SecretVersionV2Tag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretVersionV2}.secretId`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .leftJoin<{ latestSecretVersion: number }>( (tx || db)(TableName.SecretVersionV2) .groupBy("secretId") @@ -506,7 +521,8 @@ export const snapshotDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.SecretTag).as("tagId"), db.ref("id").withSchema(TableName.SecretVersionV2Tag).as("tagVersionId"), db.ref("color").withSchema(TableName.SecretTag).as("tagColor"), - db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug") + db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"), + db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping) ); const formated = sqlNestRelationships({ @@ -523,7 +539,8 @@ export const snapshotDALFactory = (db: TDbClient) => { label: "secretVersions" as const, mapper: (el) => ({ ...SecretVersionsV2Schema.parse(el), - latestSecretVersion: el.latestSecretVersion as number + latestSecretVersion: el.latestSecretVersion as number, + isRotatedSecret: Boolean(el.rotationId) }), childrenMapper: [ { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8ff07db26..8fef532f5 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -8,7 +8,8 @@ export const PgSqlLock = { SuperAdminInit: 2024, KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), - OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`) + OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) } as const; export type TKeyStoreFactory = ReturnType; @@ -33,6 +34,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, IdentityAccessTokenStatusUpdate: (identityAccessTokenId: string) => `identity-access-token-status:${identityAccessTokenId}`, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index d3026e223..b739f69e6 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1,3 +1,8 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + SECRET_ROTATION_CONNECTION_MAP, + SECRET_ROTATION_NAME_MAP +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-maps"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -61,6 +66,17 @@ export const IDENTITIES = { }, LIST: { orgId: "The ID of the organization to list identities." + }, + SEARCH: { + search: { + desc: "The filters to apply to the search.", + name: "The name of the identity to filter by.", + role: "The organizational role of the identity to filter by." + }, + offset: "The offset to start from. If you enter 10, it will start from the 10th identity.", + limit: "The number of identities to return.", + orderBy: "The column to order identities by.", + orderDirection: "The direction to order identities in." } } as const; @@ -819,7 +835,8 @@ export const DASHBOARD = { includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", - includeImports: "Whether to include project secret imports in the response." + includeImports: "Whether to include project secret imports in the response.", + includeSecretRotations: "Whether to include project secret rotations in the response." }, SECRET_DETAILS_LIST: { projectId: "The ID of the project to list secrets/folders from.", @@ -834,7 +851,8 @@ export const DASHBOARD = { includeSecrets: "Whether to include project secrets in the response.", includeFolders: "Whether to include project folders in the response.", includeImports: "Whether to include project secret imports in the response.", - includeDynamicSecrets: "Whether to include dynamic project secrets in the response." + includeDynamicSecrets: "Whether to include dynamic project secrets in the response.", + includeSecretRotations: "Whether to include secret rotations in the response." } } as const; @@ -1682,7 +1700,8 @@ export const AppConnections = { name: `The name of the ${appName} Connection to create. Must be slug-friendly.`, description: `An optional description for the ${appName} Connection.`, credentials: `The credentials used to connect with ${appName}.`, - method: `The method used to authenticate with ${appName}.` + method: `The method used to authenticate with ${appName}.`, + isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.` }; }, UPDATE: (app: AppConnection) => { @@ -1692,12 +1711,25 @@ export const AppConnections = { name: `The updated name of the ${appName} Connection. Must be slug-friendly.`, description: `The updated description of the ${appName} Connection.`, credentials: `The credentials used to connect with ${appName}.`, - method: `The method used to authenticate with ${appName}.` + method: `The method used to authenticate with ${appName}.`, + isPlatformManagedCredentials: `Whether or not the ${appName} Connection credentials should be managed by Infisical. Once enabled this cannot be reversed.` }; }, DELETE: (app: AppConnection) => ({ connectionId: `The ID of the ${APP_CONNECTION_NAME_MAP[app]} Connection to be deleted.` - }) + }), + CREDENTIALS: { + SQL_CONNECTION: { + host: "The hostname of the database server.", + port: "The port number of the database.", + database: "The name of the database to connect to.", + username: "The username to connect to the database with.", + password: "The password to connect to the database with.", + sslEnabled: "Whether or not to use SSL when connecting to the database.", + sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates.", + sslCertificate: "The SSL certificate to use for connection." + } + } }; export const SecretSyncs = { @@ -1814,3 +1846,70 @@ export const SecretSyncs = { } } }; + +export const SecretRotations = { + LIST: (type?: SecretRotation) => ({ + projectId: `The ID of the project to list ${type ? SECRET_ROTATION_NAME_MAP[type] : "Secret"} Rotations from.` + }), + GET_BY_ID: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.` + }), + GET_GENERATED_CREDENTIALS_BY_ID: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve the generated credentials for.` + }), + GET_BY_NAME: (type: SecretRotation) => ({ + rotationName: `The name of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to retrieve.`, + projectId: `The ID of the project the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.`, + secretPath: `The secret path the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located at.`, + environment: `The environment the ${SECRET_ROTATION_NAME_MAP[type]} Rotation is located in.` + }), + CREATE: (type: SecretRotation) => { + const destinationName = SECRET_ROTATION_NAME_MAP[type]; + return { + name: `The name of the ${destinationName} Rotation to create. Must be slug-friendly.`, + description: `An optional description for the ${destinationName} Rotation.`, + projectId: "The ID of the project to create the rotation in.", + environment: `The slug of the project environment to create the rotation in.`, + secretPath: `The secret path of the project to create the rotation in.`, + connectionId: `The ID of the ${ + APP_CONNECTION_NAME_MAP[SECRET_ROTATION_CONNECTION_MAP[type]] + } Connection to use for rotation.`, + isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`, + rotationInterval: `The interval, in days, to automatically rotate secrets.`, + rotateAtUtc: `The hours and minutes rotation should occur at in UTC. Defaults to Midnight (00:00) UTC.` + }; + }, + UPDATE: (type: SecretRotation) => { + const typeName = SECRET_ROTATION_NAME_MAP[type]; + return { + rotationId: `The ID of the ${typeName} Rotation to be updated.`, + name: `The updated name of the ${typeName} Rotation. Must be slug-friendly.`, + description: `The updated description of the ${typeName} Rotation.`, + isAutoRotationEnabled: `Whether secrets should be automatically rotated when the specified rotation interval has elapsed.`, + rotationInterval: `The updated interval, in days, to automatically rotate secrets.`, + rotateAtUtc: `The updated hours and minutes rotation should occur at in UTC.` + }; + }, + DELETE: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to be deleted.`, + deleteSecrets: `Whether the mapped secrets belonging to this rotation should be deleted.`, + revokeGeneratedCredentials: `Whether the generated credentials associated with this rotation should be revoked.` + }), + ROTATE: (type: SecretRotation) => ({ + rotationId: `The ID of the ${SECRET_ROTATION_NAME_MAP[type]} Rotation to rotate generated credentials for.` + }), + PARAMETERS: { + SQL_CREDENTIALS: { + username1: + "The username of the first login to rotate passwords for. This user must already exists in your database.", + username2: + "The username of the second login to rotate passwords for. This user must already exists in your database." + } + }, + SECRETS_MAPPING: { + SQL_CREDENTIALS: { + username: "The name of the secret that the active username will be mapped to.", + password: "The name of the secret that the generated password will be mapped to." + } + } +}; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 4ca9c1b12..10ab16b97 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -58,6 +58,7 @@ const envSchema = z ROOT_ENCRYPTION_KEY: zpStr(z.string().optional()), QUEUE_WORKERS_ENABLED: zodStrBool.default("true"), HTTPS_ENABLED: zodStrBool, + ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -192,6 +193,7 @@ const envSchema = z GATEWAY_RELAY_REALM: zpStr(z.string().optional()), GATEWAY_RELAY_AUTH_SECRET: zpStr(z.string().optional()), + DYNAMIC_SECRET_ALLOW_INTERNAL_IP: zodStrBool.default("false"), /* ----------------------------------------------------------------------------- */ /* App Connections ----------------------------------------------------------------------------- */ @@ -262,6 +264,7 @@ const envSchema = z isSmtpConfigured: Boolean(data.SMTP_HOST), isRedisConfigured: Boolean(data.REDIS_URL), isDevelopmentMode: data.NODE_ENV === "development", + isRotationDevelopmentMode: data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isSecretScanningConfigured: diff --git a/backend/src/lib/knex/connection.ts b/backend/src/lib/knex/connection.ts index 993615a0b..68b40e18f 100644 --- a/backend/src/lib/knex/connection.ts +++ b/backend/src/lib/knex/connection.ts @@ -1,6 +1,8 @@ import { URL } from "url"; // Import the URL class -export const getDbConnectionHost = (urlString: string) => { +export const getDbConnectionHost = (urlString?: string) => { + if (!urlString) return null; + try { const url = new URL(urlString); // Split hostname and port (if provided) diff --git a/backend/src/lib/knex/dynamic.ts b/backend/src/lib/knex/dynamic.ts index b8bc8ab57..a57464fac 100644 --- a/backend/src/lib/knex/dynamic.ts +++ b/backend/src/lib/knex/dynamic.ts @@ -2,11 +2,17 @@ import { Knex } from "knex"; import { UnauthorizedError } from "../errors"; -type TKnexDynamicPrimitiveOperator = { - operator: "eq" | "ne" | "startsWith" | "endsWith"; - value: string; - field: Extract; -}; +type TKnexDynamicPrimitiveOperator = + | { + operator: "eq" | "ne" | "startsWith" | "endsWith"; + value: string; + field: Extract; + } + | { + operator: "notIn"; + value: string[]; + field: Extract; + }; type TKnexDynamicInOperator = { operator: "in"; @@ -48,6 +54,10 @@ export const buildDynamicKnexQuery = ( void queryBuilder.whereILike(filterAst.field, `%${filterAst.value}`); break; } + case "notIn": { + void queryBuilder.whereNotIn(filterAst.field, filterAst.value); + break; + } case "and": { filterAst.value.forEach((el) => { void queryBuilder.andWhere((subQueryBuilder) => { diff --git a/backend/src/lib/knex/prependTableNameToFindFilter.ts b/backend/src/lib/knex/prependTableNameToFindFilter.ts index ee48dce5a..3fb1dabb3 100644 --- a/backend/src/lib/knex/prependTableNameToFindFilter.ts +++ b/backend/src/lib/knex/prependTableNameToFindFilter.ts @@ -7,7 +7,7 @@ export const prependTableNameToFindFilter = (tableName: TableName, filterObj: ob Object.fromEntries( Object.entries(filterObj).map(([key, value]) => key.startsWith("$") - ? [key, prependTableNameToFindFilter(tableName, value as object)] + ? [key, value ? prependTableNameToFindFilter(tableName, value as object) : value] : [`${tableName}.${key}`, value] ) ); diff --git a/backend/src/lib/search-resource/db.ts b/backend/src/lib/search-resource/db.ts new file mode 100644 index 000000000..fc450d9f9 --- /dev/null +++ b/backend/src/lib/search-resource/db.ts @@ -0,0 +1,141 @@ +import { Knex } from "knex"; + +import { SearchResourceOperators, TSearchResourceOperator } from "./search"; + +const buildKnexQuery = ( + query: Knex.QueryBuilder, + // when it's multiple table field means it's field1 or field2 + fields: string | string[], + operator: SearchResourceOperators, + value: unknown +) => { + switch (operator) { + case SearchResourceOperators.$eq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $eq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "=", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "=", value); + } + return void qb.orWhere(el, "=", value); + }); + }); + } + + case SearchResourceOperators.$neq: { + if (typeof value !== "string" && typeof value !== "number") + throw new Error("Invalid value type for $neq operator"); + + if (typeof fields === "string") { + return void query.where(fields, "<>", value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.where(el, "<>", value); + } + return void qb.orWhere(el, "<>", value); + }); + }); + } + case SearchResourceOperators.$in: { + if (!Array.isArray(value)) throw new Error("Invalid value type for $in operator"); + + if (typeof fields === "string") { + return void query.whereIn(fields, value); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereIn(el, value); + } + return void qb.orWhereIn(el, value); + }); + }); + } + case SearchResourceOperators.$contains: { + if (typeof value !== "string") throw new Error("Invalid value type for $contains operator"); + + if (typeof fields === "string") { + return void query.whereILike(fields, `%${value}%`); + } + + return void query.where((qb) => { + return fields.forEach((el, index) => { + if (index === 0) { + return void qb.whereILike(el, `%${value}%`); + } + return void qb.orWhereILike(el, `%${value}%`); + }); + }); + } + default: + throw new Error(`Unsupported operator: ${String(operator)}`); + } +}; + +export const buildKnexFilterForSearchResource = ( + rootQuery: Knex.QueryBuilder, + searchFilter: T & { $or?: T[] }, + getAttributeField: (attr: K) => string | string[] | null +) => { + const { $or: orFilters = [] } = searchFilter; + (Object.keys(searchFilter) as K[]).forEach((key) => { + // akhilmhdh: yes, we could have split in top. This is done to satisfy ts type error + if (key === "$or") return; + + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = searchFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(rootQuery, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + rootQuery, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + + if (orFilters.length) { + void rootQuery.andWhere((andQb) => { + return orFilters.forEach((orFilter) => { + return void andQb.orWhere((qb) => { + (Object.keys(orFilter) as K[]).forEach((key) => { + const dbField = getAttributeField(key); + if (!dbField) throw new Error(`DB field not found for ${String(key)}`); + + const dbValue = orFilter[key]; + if (typeof dbValue === "string" || typeof dbValue === "number") { + buildKnexQuery(qb, dbField, SearchResourceOperators.$eq, dbValue); + return; + } + + Object.keys(dbValue as Record).forEach((el) => { + buildKnexQuery( + qb, + dbField, + el as SearchResourceOperators, + (dbValue as Record)[el as SearchResourceOperators] + ); + }); + }); + }); + }); + }); + } +}; diff --git a/backend/src/lib/search-resource/search.ts b/backend/src/lib/search-resource/search.ts new file mode 100644 index 000000000..162283fd5 --- /dev/null +++ b/backend/src/lib/search-resource/search.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +export enum SearchResourceOperators { + $eq = "$eq", + $neq = "$neq", + $in = "$in", + $contains = "$contains" +} + +export const SearchResourceOperatorSchema = z.union([ + z.string(), + z.number(), + z + .object({ + [SearchResourceOperators.$eq]: z.string().optional(), + [SearchResourceOperators.$neq]: z.string().optional(), + [SearchResourceOperators.$in]: z.string().array().optional(), + [SearchResourceOperators.$contains]: z.string().array().optional() + }) + .partial() +]); + +export type TSearchResourceOperator = z.infer; + +export type TSearchResource = { + [k: string]: z.ZodOptional< + z.ZodUnion< + [ + z.ZodEffects, + z.ZodObject<{ + [SearchResourceOperators.$eq]?: z.ZodOptional>; + [SearchResourceOperators.$neq]?: z.ZodOptional>; + [SearchResourceOperators.$in]?: z.ZodOptional>>; + [SearchResourceOperators.$contains]?: z.ZodOptional>; + }> + ] + > + >; +}; + +export const buildSearchZodSchema = (schema: z.ZodObject) => { + return schema.extend({ $or: schema.array().optional() }).optional(); +}; diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts index 57bc052f8..d2d033693 100644 --- a/backend/src/lib/validator/validate-string.ts +++ b/backend/src/lib/validator/validate-string.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export enum CharacterType { Alphabets = "alphabets", Numbers = "numbers", @@ -36,7 +38,8 @@ export enum CharacterType { DoubleQuote = "doubleQuote", // " Comma = "comma", // , Semicolon = "semicolon", // ; - Exclamation = "exclamation" // ! + Exclamation = "exclamation", // ! + Fullstop = "fullStop" // . } /** @@ -81,7 +84,8 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => { [CharacterType.DoubleQuote]: '\\"', [CharacterType.Comma]: ",", [CharacterType.Semicolon]: ";", - [CharacterType.Exclamation]: "!" + [CharacterType.Exclamation]: "!", + [CharacterType.Fullstop]: "." }; // Combine patterns from allowed characters @@ -99,3 +103,10 @@ export const characterValidator = (allowedCharacters: CharacterType[]) => { return regex.test(input); }; }; + +export const zodValidateCharacters = (allowedCharacters: CharacterType[]) => { + const validator = characterValidator(allowedCharacters); + return (schema: z.ZodString, fieldName: string) => { + return schema.refine(validator, { message: `${fieldName} can only contain ${allowedCharacters.join(",")}` }); + }; +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 94006b5a5..ae1a3e821 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -4,6 +4,10 @@ import PgBoss, { WorkOptions } from "pg-boss"; import { SecretEncryptionAlgo, SecretKeyEncoding } from "@app/db/schemas"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { + TSecretRotationRotateSecretsJobPayload, + TSecretRotationSendNotificationJobPayload +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; import { TScanFullRepoEventPayload, TScanPushEventPayload @@ -44,7 +48,8 @@ export enum QueueName { ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", - AppConnectionSecretSync = "app-connection-secret-sync" + AppConnectionSecretSync = "app-connection-secret-sync", + SecretRotationV2 = "secret-rotation-v2" } export enum QueueJobs { @@ -73,7 +78,10 @@ export enum QueueJobs { SecretSyncSyncSecrets = "secret-sync-sync-secrets", SecretSyncImportSecrets = "secret-sync-import-secrets", SecretSyncRemoveSecrets = "secret-sync-remove-secrets", - SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications" + SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", + SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", + SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", + SecretRotationV2SendNotification = "secret-rotation-v2-send-notification" } export type TQueueJobTypes = { @@ -213,6 +221,19 @@ export type TQueueJobTypes = { name: QueueJobs.SecretSyncSendActionFailedNotifications; payload: TQueueSendSecretSyncActionFailedNotificationsDTO; }; + [QueueName.SecretRotationV2]: + | { + name: QueueJobs.SecretRotationV2QueueRotations; + payload: undefined; + } + | { + name: QueueJobs.SecretRotationV2RotateSecrets; + payload: TSecretRotationRotateSecretsJobPayload; + } + | { + name: QueueJobs.SecretRotationV2SendNotification; + payload: TSecretRotationSendNotificationJobPayload; + }; }; export type TQueueServiceFactory = ReturnType; @@ -229,6 +250,7 @@ export const queueServiceFactory = ( const pgBoss = new PgBoss({ connectionString: dbConnectionUrl, archiveCompletedAfterSeconds: 60, + cronMonitorIntervalSeconds: 5, archiveFailedAfterSeconds: 1000, // we want to keep failed jobs for a longer time so that it can be retried deleteAfterSeconds: 30, ssl: dbRootCert @@ -247,15 +269,12 @@ export const queueServiceFactory = ( >; const initialize = async () => { - const appCfg = getConfig(); - if (appCfg.SHOULD_INIT_PG_QUEUE) { - logger.info("Initializing pg-queue..."); - await pgBoss.start(); + logger.info("Initializing pg-queue..."); + await pgBoss.start(); - pgBoss.on("error", (error) => { - logger.error(error, "pg-queue error"); - }); - } + pgBoss.on("error", (error) => { + logger.error(error, "pg-queue error"); + }); }; const start = ( @@ -283,7 +302,7 @@ export const queueServiceFactory = ( const startPg = async ( jobName: QueueJobs, - jobsFn: (jobs: PgBoss.Job[]) => Promise, + jobsFn: (jobs: PgBoss.JobWithMetadata[]) => Promise, options: WorkOptions & { workerCount: number; } @@ -297,7 +316,7 @@ export const queueServiceFactory = ( await Promise.all( Array.from({ length: options.workerCount }).map(() => - pgBoss.work(jobName, options, jobsFn) + pgBoss.work(jobName, { ...options, includeMetadata: true }, jobsFn) ) ); }; @@ -342,6 +361,15 @@ export const queueServiceFactory = ( }); }; + const schedulePg = async ( + job: TQueueJobTypes[T]["name"], + cron: string, + data: TQueueJobTypes[T]["payload"], + opts?: PgBoss.ScheduleOptions & { jobId?: string } + ) => { + await pgBoss.schedule(job, cron, data, opts); + }; + const stopRepeatableJob = async ( name: T, job: TQueueJobTypes[T]["name"], @@ -403,6 +431,7 @@ export const queueServiceFactory = ( stopJobById, getRepeatableJobs, startPg, - queuePg + queuePg, + schedulePg }; }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 43a30760e..d09a2c40b 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -39,3 +39,10 @@ export const GenericResourceNameSchema = z ])(val), "Name can only contain alphanumeric characters, dashes, underscores, and spaces" ); + +export const BaseSecretNameSchema = z.string().trim().min(1); + +export const SecretNameSchema = BaseSecretNameSchema.refine( + (el) => !el.includes(" "), + "Secret name cannot contain spaces." +).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index dac88791f..21988e12d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -76,6 +76,9 @@ import { secretReplicationServiceFactory } from "@app/ee/services/secret-replica 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"; +import { secretRotationV2DALFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-dal"; +import { secretRotationV2QueueServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-queue"; +import { secretRotationV2ServiceFactory } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-service"; import { gitAppDALFactory } from "@app/ee/services/secret-scanning/git-app-dal"; import { gitAppInstallSessionDALFactory } from "@app/ee/services/secret-scanning/git-app-install-session-dal"; import { secretScanningDALFactory } from "@app/ee/services/secret-scanning/secret-scanning-dal"; @@ -406,6 +409,8 @@ export const registerRoutes = async ( const gatewayDAL = gatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db); + const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); + const permissionService = permissionServiceFactory({ permissionDAL, orgRoleDAL, @@ -662,6 +667,7 @@ export const registerRoutes = async ( }); const orgAdminService = orgAdminServiceFactory({ + smtpService, projectDAL, permissionService, projectUserMembershipRoleDAL, @@ -964,7 +970,8 @@ export const registerRoutes = async ( projectSlackConfigDAL, slackIntegrationDAL, projectTemplateService, - groupProjectDAL + groupProjectDAL, + smtpService }); const projectEnvService = projectEnvServiceFactory({ @@ -1497,6 +1504,35 @@ export const registerRoutes = async ( permissionService }); + const secretRotationV2Service = secretRotationV2ServiceFactory({ + secretRotationV2DAL, + permissionService, + appConnectionService, + folderDAL, + projectBotService, + licenseService, + kmsService, + auditLogService, + secretV2BridgeDAL, + secretTagDAL, + secretVersionTagV2BridgeDAL, + secretVersionV2BridgeDAL, + keyStore, + resourceMetadataDAL, + snapshotService, + secretQueueService, + queueService + }); + + await secretRotationV2QueueServiceFactory({ + secretRotationV2Service, + secretRotationV2DAL, + queueService, + projectDAL, + projectMembershipDAL, + smtpService + }); + await superAdminService.initServerCfg(); // setup the communication with license key server @@ -1598,7 +1634,8 @@ export const registerRoutes = async ( secretSync: secretSyncService, kmip: kmipService, kmipOperation: kmipOperationService, - gateway: gatewayService + gateway: gatewayService, + secretRotationV2: secretRotationV2Service }); const cronJobs: CronJob[] = []; @@ -1607,6 +1644,10 @@ export const registerRoutes = async ( if (rateLimitSyncJob) { cronJobs.push(rateLimitSyncJob); } + const licenseSyncJob = await licenseService.initializeBackgroundSync(); + if (licenseSyncJob) { + cronJobs.push(licenseSyncJob); + } } server.decorate("store", { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 67bd26552..8bf6f7390 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -134,7 +134,9 @@ export const secretRawSchema = z.object({ membershipId: z.string().nullable().optional() }) .optional() - .nullable() + .nullable(), + isRotatedSecret: z.boolean().optional(), + rotationId: z.string().uuid().nullish() }); export const ProjectPermissionSchema = z.object({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts index e23d52004..dfb451a4c 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-endpoints.ts @@ -24,8 +24,14 @@ export const registerAppConnectionEndpoints = ; + updateSchema: z.ZodType<{ + name?: string; + credentials?: I["credentials"]; + description?: string | null; + isPlatformManagedCredentials?: boolean; }>; - updateSchema: z.ZodType<{ name?: string; credentials?: I["credentials"]; description?: string | null }>; sanitizedResponseSchema: z.ZodTypeAny; }) => { const appName = APP_CONNECTION_NAME_MAP[app]; @@ -208,10 +214,10 @@ export const registerAppConnectionEndpoints = { - const { name, method, credentials, description } = req.body; + const { name, method, credentials, description, isPlatformManagedCredentials } = req.body; const appConnection = (await server.services.appConnection.createAppConnection( - { name, method, app, credentials, description }, + { name, method, app, credentials, description, isPlatformManagedCredentials }, req.permission )) as T; @@ -224,7 +230,8 @@ export const registerAppConnectionEndpoints = { - const { name, credentials, description } = req.body; + const { name, credentials, description, isPlatformManagedCredentials } = req.body; const { connectionId } = req.params; const appConnection = (await server.services.appConnection.updateAppConnection( - { name, credentials, connectionId, description }, + { name, credentials, connectionId, description, isPlatformManagedCredentials }, req.permission )) as T; @@ -268,7 +275,8 @@ export const registerAppConnectionEndpoints = { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index c2b688a43..906ffaee9 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -7,6 +7,8 @@ import { registerDatabricksConnectionRouter } from "./databricks-connection-rout import { registerGcpConnectionRouter } from "./gcp-connection-router"; import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; +import { registerPostgresConnectionRouter } from "./postgres-connection-router"; export * from "./app-connection-router"; @@ -18,5 +20,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.MsSql, + server, + sanitizedResponseSchema: SanitizedMsSqlConnectionSchema, + createSchema: CreateMsSqlConnectionSchema, + updateSchema: UpdateMsSqlConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts new file mode 100644 index 000000000..8662f2e52 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/postgres-connection-router.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreatePostgresConnectionSchema, + SanitizedPostgresConnectionSchema, + UpdatePostgresConnectionSchema +} from "@app/services/app-connection/postgres"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerPostgresConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Postgres, + server, + sanitizedResponseSchema: SanitizedPostgresConnectionSchema, + createSchema: CreatePostgresConnectionSchema, + updateSchema: UpdatePostgresConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index fc33bcafe..bcb0a949a 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -8,6 +8,7 @@ import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { DASHBOARD } from "@app/lib/api-docs"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; @@ -101,12 +102,30 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeFolders), includeImports: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeImports), + includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeSecretRotations), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_OVERVIEW_LIST.includeDynamicSecrets) }), response: { 200: z.object({ folders: SecretFoldersSchema.extend({ environment: z.string() }).array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.extend({ environment: z.string() }).array().optional(), + secretRotations: z + .intersection( + SecretRotationV2Schema, + z.object({ + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean(), + secretPath: z.string().optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tags: SanitizedTagSchema.array().optional() + }) + .nullable() + .array() + }) + ) + .array() + .optional(), secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -127,6 +146,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), totalImportCount: z.number().optional(), + totalSecretRotationCount: z.number().optional(), totalCount: z.number() }) } @@ -144,7 +164,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeFolders, includeSecrets, includeImports, - includeDynamicSecrets + includeDynamicSecrets, + includeSecretRotations } = req.query; const environments = req.query.environments.split(","); @@ -166,11 +187,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let dynamicSecrets: | Awaited> | undefined; + let secretRotations: + | Awaited> + | undefined; let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; let totalImportCount: number | undefined; + let totalSecretRotationCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportMultiEnvCount({ @@ -322,6 +347,56 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeSecretRotations) { + totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount( + { + projectId, + search, + environments, + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { + secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments, + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id), + secretPath, + environment: environments.join(",") + } + } + }); + + // get the count of unique secret rotation names to properly adjust remaining limit + const uniqueSecretRotationCount = new Set(secretRotations.map((rotation) => rotation.name)).size; + + remainingLimit -= uniqueSecretRotationCount; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount); + } + } + if (includeSecrets) { // this is the unique count, ie duplicate secrets across envs only count as 1 totalSecretCount = await server.services.secret.getSecretsCountMultiEnv({ @@ -353,38 +428,44 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { offset: adjustedOffset, isInternal: true }); + } + } - for await (const environment of environments) { - const secretCountFromEnv = secrets.filter((secret) => secret.environment === environment).length; + if (secrets?.length || secretRotations?.length) { + for await (const environment of environments) { + const secretCountFromEnv = + (secrets?.filter((secret) => secret.environment === environment).length ?? 0) + + (secretRotations + ?.filter((rotation) => rotation.environment.slug === environment) + .flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); - if (secretCountFromEnv) { - await server.services.auditLog.createAuditLog({ - projectId, - ...req.auditLogInfo, - event: { - type: EventType.GET_SECRETS, - metadata: { - environment, - secretPath, - numberOfSecrets: secretCountFromEnv - } + if (secretCountFromEnv) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCountFromEnv + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCountFromEnv, + workspaceId: projectId, + environment, + 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: secretCountFromEnv, - workspaceId: projectId, - environment, - secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); - } } } } @@ -395,12 +476,18 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { dynamicSecrets, secrets, imports, + secretRotations, totalFolderCount, totalDynamicSecretCount, totalImportCount, totalSecretCount, + totalSecretRotationCount, totalCount: - (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) + (totalFolderCount ?? 0) + + (totalDynamicSecretCount ?? 0) + + (totalSecretCount ?? 0) + + (totalImportCount ?? 0) + + (totalSecretRotationCount ?? 0) }; } }); @@ -445,7 +532,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecrets), includeFolders: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeFolders), includeDynamicSecrets: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeDynamicSecrets), - includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports) + includeImports: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeImports), + includeSecretRotations: booleanSchema.describe(DASHBOARD.SECRET_DETAILS_LIST.includeSecretRotations) }), response: { 200: z.object({ @@ -457,6 +545,23 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { .optional(), folders: SecretFoldersSchema.array().optional(), dynamicSecrets: SanitizedDynamicSecretSchema.array().optional(), + secretRotations: z + .intersection( + SecretRotationV2Schema, + z.object({ + secrets: secretRawSchema + .extend({ + secretValueHidden: z.boolean(), + secretPath: z.string().optional(), + secretMetadata: ResourceMetadataSchema.optional(), + tags: SanitizedTagSchema.array().optional() + }) + .nullable() + .array() + }) + ) + .array() + .optional(), secrets: secretRawSchema .extend({ secretValueHidden: z.boolean(), @@ -470,6 +575,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { totalFolderCount: z.number().optional(), totalDynamicSecretCount: z.number().optional(), totalSecretCount: z.number().optional(), + totalSecretRotationCount: z.number().optional(), totalCount: z.number() }) } @@ -488,7 +594,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeFolders, includeSecrets, includeDynamicSecrets, - includeImports + includeImports, + includeSecretRotations } = req.query; if (!projectId || !environment) throw new BadRequestError({ message: "Missing workspace id or environment" }); @@ -507,11 +614,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let folders: Awaited> | undefined; let secrets: Awaited>["secrets"] | undefined; let dynamicSecrets: Awaited> | undefined; + let secretRotations: + | Awaited> + | undefined; let totalImportCount: number | undefined; let totalFolderCount: number | undefined; let totalDynamicSecretCount: number | undefined; let totalSecretCount: number | undefined; + let totalSecretRotationCount: number | undefined; if (includeImports) { totalImportCount = await server.services.secretImport.getProjectImportCount({ @@ -594,6 +705,53 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (includeSecretRotations) { + totalSecretRotationCount = await server.services.secretRotationV2.getDashboardSecretRotationCount( + { + projectId, + search, + environments: [environment], + secretPath + }, + req.permission + ); + + if (remainingLimit > 0 && totalSecretRotationCount > adjustedOffset) { + secretRotations = await server.services.secretRotationV2.getDashboardSecretRotations( + { + projectId, + search, + orderBy, + orderDirection, + environments: [environment], + secretPath, + limit: remainingLimit, + offset: adjustedOffset + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotations.length, + rotationIds: secretRotations.map((rotation) => rotation.id), + secretPath, + environment + } + } + }); + + remainingLimit -= secretRotations.length; + adjustedOffset = 0; + } else { + adjustedOffset = Math.max(0, adjustedOffset - totalSecretRotationCount); + } + } + try { if (includeDynamicSecrets) { totalDynamicSecretCount = await server.services.dynamicSecret.getDynamicSecretCount({ @@ -629,7 +787,13 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { adjustedOffset = Math.max(0, adjustedOffset - totalDynamicSecretCount); } } + } catch (error) { + if (!(error instanceof ForbiddenError)) { + throw error; + } + } + try { if (includeSecrets) { totalSecretCount = await server.services.secret.getSecretsCount({ actorId: req.permission.id, @@ -663,34 +827,6 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { tagSlugs: tags }) ).secrets; - - await server.services.auditLog.createAuditLog({ - projectId, - ...req.auditLogInfo, - event: { - type: EventType.GET_SECRETS, - metadata: { - environment, - secretPath, - numberOfSecrets: secrets.length - } - } - }); - - 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: projectId, - environment, - secretPath, - channel: getUserAgentType(req.headers["user-agent"]), - ...req.auditLogInfo - } - }); - } } } } catch (error) { @@ -699,17 +835,57 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { } } + if (secrets?.length || secretRotations?.length) { + const secretCount = + (secrets?.length ?? 0) + + (secretRotations?.flatMap((rotation) => rotation.secrets.filter((secret) => Boolean(secret))).length ?? 0); + + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRETS, + metadata: { + environment, + secretPath, + numberOfSecrets: secretCount + } + } + }); + + if (getUserAgentType(req.headers["user-agent"]) !== UserAgentType.K8_OPERATOR) { + await server.services.telemetry.sendPostHogEvents({ + event: PostHogEventTypes.SecretPulled, + distinctId: getTelemetryDistinctId(req), + properties: { + numberOfSecrets: secretCount, + workspaceId: projectId, + environment, + secretPath, + channel: getUserAgentType(req.headers["user-agent"]), + ...req.auditLogInfo + } + }); + } + } + return { imports, folders, dynamicSecrets, secrets, + secretRotations, totalImportCount, totalFolderCount, totalDynamicSecretCount, totalSecretCount, + totalSecretRotationCount, totalCount: - (totalImportCount ?? 0) + (totalFolderCount ?? 0) + (totalDynamicSecretCount ?? 0) + (totalSecretCount ?? 0) + (totalImportCount ?? 0) + + (totalFolderCount ?? 0) + + (totalDynamicSecretCount ?? 0) + + (totalSecretCount ?? 0) + + (totalSecretRotationCount ?? 0) }; } }); @@ -747,7 +923,8 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { tags: SanitizedTagSchema.array().optional() }) .array() - .optional() + .optional(), + secretRotations: SecretRotationV2Schema.array().optional() }) } }, @@ -811,6 +988,17 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { req.permission ); + const secretRotations = searchHasTags + ? [] + : await server.services.secretRotationV2.getQuickSearchSecretRotations( + { + projectId, + folderMappings, + filters: sharedFilters + }, + req.permission + ); + for await (const environment of environments) { const secretCountForEnv = secrets.filter((secret) => secret.environment === environment).length; @@ -843,6 +1031,24 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); } } + + const secretRotationsFromEnv = secretRotations.filter((rotation) => rotation.environment.slug === environment); + + if (secretRotationsFromEnv.length) { + await server.services.auditLog.createAuditLog({ + projectId, + ...req.auditLogInfo, + event: { + type: EventType.GET_SECRET_ROTATIONS, + metadata: { + count: secretRotationsFromEnv.length, + rotationIds: secretRotationsFromEnv.map((rotation) => rotation.id), + secretPath, + environment + } + } + }); + } } const sliceQuickSearch = (array: T[]) => array.slice(0, 25); @@ -856,6 +1062,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { ? dynamicSecrets.filter((dynamicSecret) => dynamicSecret.path.endsWith(searchPath)) : dynamicSecrets ), + secretRotations: sliceQuickSearch( + searchPath ? secretRotations.filter((rotation) => rotation.folder.path.endsWith(searchPath)) : secretRotations + ), folders: searchHasTags ? [] : sliceQuickSearch( diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 344da3383..107a4b9ef 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -3,15 +3,26 @@ import { z } from "zod"; import { IdentitiesSchema, IdentityOrgMembershipsSchema, OrgMembershipRole, OrgRolesSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { IDENTITIES } from "@app/lib/api-docs"; +import { buildSearchZodSchema, SearchResourceOperators } from "@app/lib/search-resource/search"; +import { OrderByDirection } from "@app/lib/types"; +import { CharacterType, zodValidateCharacters } from "@app/lib/validator/validate-string"; import { 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 { OrgIdentityOrderBy } from "@app/services/identity/identity-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { SanitizedProjectSchema } from "../sanitizedSchemas"; +const searchResourceZodValidate = zodValidateCharacters([ + CharacterType.AlphaNumeric, + CharacterType.Spaces, + CharacterType.Underscore, + CharacterType.Hyphen +]); + export const registerIdentityRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -245,7 +256,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { method: "GET", url: "/", config: { - rateLimit: writeLimit + rateLimit: readLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -289,6 +300,103 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Search identities", + security: [ + { + bearerAuth: [] + } + ], + body: z.object({ + orderBy: z + .nativeEnum(OrgIdentityOrderBy) + .default(OrgIdentityOrderBy.Name) + .describe(IDENTITIES.SEARCH.orderBy) + .optional(), + orderDirection: z + .nativeEnum(OrderByDirection) + .default(OrderByDirection.ASC) + .describe(IDENTITIES.SEARCH.orderDirection) + .optional(), + limit: z.number().max(100).default(50).describe(IDENTITIES.SEARCH.limit), + offset: z.number().default(0).describe(IDENTITIES.SEARCH.offset), + search: buildSearchZodSchema( + z + .object({ + name: z + .union([ + searchResourceZodValidate(z.string().max(255), "Name"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Name $eq"), + [SearchResourceOperators.$contains]: searchResourceZodValidate( + z.string().max(255), + "Name $contains" + ), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Name $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.name), + role: z + .union([ + searchResourceZodValidate(z.string().max(255), "Role"), + z + .object({ + [SearchResourceOperators.$eq]: searchResourceZodValidate(z.string().max(255), "Role $eq"), + [SearchResourceOperators.$in]: searchResourceZodValidate(z.string().max(255), "Role $in").array() + }) + .partial() + ]) + .describe(IDENTITIES.SEARCH.search.role) + }) + .describe(IDENTITIES.SEARCH.search.desc) + .partial() + ) + }), + response: { + 200: z.object({ + identities: IdentityOrgMembershipsSchema.extend({ + customRole: OrgRolesSchema.pick({ + id: true, + name: true, + slug: true, + permissions: true, + description: true + }).optional(), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }) + }).array(), + totalCount: z.number() + }) + } + }, + handler: async (req) => { + const { identityMemberships, totalCount } = await server.services.identity.searchOrgIdentities({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + searchFilter: req.body.search, + orgId: req.permission.orgId, + limit: req.body.limit, + offset: req.body.offset, + orderBy: req.body.orderBy, + orderDirection: req.body.orderDirection + }); + + return { identities: identityMemberships, totalCount }; + } + }); + server.route({ method: "GET", url: "/:identityId/identity-memberships", diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index b44e93a66..2496d8f62 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -8,15 +8,17 @@ import { ProjectSlackConfigsSchema, ProjectType, SecretFoldersSchema, + SortDirection, UserEncryptionKeysSchema, UsersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; +import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; 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 { ProjectFilterType } from "@app/services/project/project-types"; +import { ActorType, AuthMode } from "@app/services/auth/auth-type"; +import { ProjectFilterType, SearchProjectSortBy } from "@app/services/project/project-types"; import { validateSlackChannelsField } from "@app/services/slack/slack-auth-validators"; import { integrationAuthPubSchema, SanitizedProjectSchema } from "../sanitizedSchemas"; @@ -704,4 +706,107 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return environmentsFolders; } }); + + server.route({ + method: "POST", + url: "/search", + config: { + rateLimit: readLimit + }, + schema: { + body: z.object({ + limit: z.number().default(100), + offset: z.number().default(0), + type: z.nativeEnum(ProjectType).optional(), + orderBy: z.nativeEnum(SearchProjectSortBy).optional().default(SearchProjectSortBy.NAME), + orderDirection: z.nativeEnum(SortDirection).optional().default(SortDirection.ASC), + name: z + .string() + .trim() + .refine((val) => characterValidator([CharacterType.AlphaNumeric, CharacterType.Hyphen])(val), { + message: "Invalid pattern: only alphanumeric characters, - are allowed." + }) + .optional() + }), + response: { + 200: z.object({ + projects: SanitizedProjectSchema.extend({ isMember: z.boolean() }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { docs: projects, totalCount } = await server.services.project.searchProjects({ + permission: req.permission, + ...req.body + }); + + return { projects, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:workspaceId/project-access", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + workspaceId: z.string().trim() + }), + body: z.object({ + comment: z + .string() + .trim() + .max(2500) + .refine( + (val) => + characterValidator([ + CharacterType.AlphaNumeric, + CharacterType.Hyphen, + CharacterType.Comma, + CharacterType.Fullstop, + CharacterType.Spaces, + CharacterType.Exclamation + ])(val), + { + message: "Invalid pattern: only alphanumeric characters, spaces, -.!, are allowed." + } + ) + .optional() + }), + response: { + 200: z.object({ + message: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + await server.services.project.requestProjectAccess({ + permission: req.permission, + comment: req.body.comment, + projectId: req.params.workspaceId + }); + + if (req.auth.actor === ActorType.USER) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.workspaceId, + event: { + type: EventType.PROJECT_ACCESS_REQUEST, + metadata: { + projectId: req.params.workspaceId, + requesterEmail: req.auth.user.email || req.auth.user.username, + requesterId: req.auth.userId + } + } + }); + } + + return { message: "Project access request has been send to project admins" }; + } + }); }; diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index f854baade..0f53b777a 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -7,6 +7,7 @@ import { RAW_SECRETS, SECRETS } from "@app/lib/api-docs"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { secretsLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { BaseSecretNameSchema, SecretNameSchema } from "@app/server/lib/schemas"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { getUserAgentType } from "@app/server/plugins/audit-log"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -39,13 +40,6 @@ const SecretReferenceNodeTree: z.ZodType = SecretReference children: z.lazy(() => SecretReferenceNodeTree.array()) }); -const BaseSecretNameSchema = z.string().trim().min(1); - -const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(" "), - "Secret name cannot contain spaces." -).refine((el) => !el.includes(":"), "Secret name cannot contain colon."); - export const registerSecretRouter = async (server: FastifyZodProvider) => { server.route({ method: "POST", @@ -630,6 +624,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() .describe(RAW_SECRETS.UPDATE.secretValue), secretPath: z .string() @@ -2049,6 +2044,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) + .optional() .describe(RAW_SECRETS.UPDATE.secretValue), secretPath: z .string() diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 9da622541..f5f921c4e 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -5,7 +5,9 @@ export enum AppConnection { GCP = "gcp", AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", - Humanitec = "humanitec" + Humanitec = "humanitec", + Postgres = "postgres", + MsSql = "mssql" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index fe5130ff1..b2d45e71f 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -1,30 +1,22 @@ import { TAppConnections } from "@app/db/schemas/app-connections"; import { generateHash } from "@app/lib/crypto/encryption"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; -import { TAppConnectionServiceFactoryDep } from "@app/services/app-connection/app-connection-service"; -import { TAppConnection, TAppConnectionConfig } from "@app/services/app-connection/app-connection-types"; +import { BadRequestError } from "@app/lib/errors"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; import { - AwsConnectionMethod, - getAwsConnectionListItem, - validateAwsConnectionCredentials -} from "@app/services/app-connection/aws"; -import { - DatabricksConnectionMethod, - getDatabricksConnectionListItem, - validateDatabricksConnectionCredentials -} from "@app/services/app-connection/databricks"; -import { - GcpConnectionMethod, - getGcpConnectionListItem, - validateGcpConnectionCredentials -} from "@app/services/app-connection/gcp"; -import { - getGitHubConnectionListItem, - GitHubConnectionMethod, - validateGitHubConnectionCredentials -} from "@app/services/app-connection/github"; + transferSqlConnectionCredentialsToPlatform, + validateSqlConnectionCredentials +} from "@app/services/app-connection/shared/sql"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { AppConnection } from "./app-connection-enums"; +import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; +import { + TAppConnection, + TAppConnectionConfig, + TAppConnectionCredentialsValidator, + TAppConnectionTransitionCredentialsToPlatform +} from "./app-connection-types"; +import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws"; import { AzureAppConfigurationConnectionMethod, getAzureAppConfigurationConnectionListItem, @@ -35,11 +27,20 @@ import { getAzureKeyVaultConnectionListItem, validateAzureKeyVaultConnectionCredentials } from "./azure-key-vault"; +import { + DatabricksConnectionMethod, + getDatabricksConnectionListItem, + validateDatabricksConnectionCredentials +} from "./databricks"; +import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; +import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; import { getHumanitecConnectionListItem, HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; +import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; export const listAppConnectionOptions = () => { return [ @@ -49,7 +50,9 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), - getHumanitecConnectionListItem() + getHumanitecConnectionListItem(), + getPostgresConnectionListItem(), + getMsSqlConnectionListItem() ].sort((a, b) => a.name.localeCompare(b.name)); }; @@ -95,30 +98,22 @@ export const decryptAppConnectionCredentials = async ({ return JSON.parse(decryptedPlainTextBlob.toString()) as TAppConnection["credentials"]; }; +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { + [AppConnection.AWS]: validateAwsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Databricks]: validateDatabricksConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GitHub]: validateGitHubConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.GCP]: validateGcpConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureKeyVault]: validateAzureKeyVaultConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureAppConfiguration]: + validateAzureAppConfigurationConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator +}; + export const validateAppConnectionCredentials = async ( appConnection: TAppConnectionConfig -): Promise => { - const { app } = appConnection; - switch (app) { - case AppConnection.AWS: - return validateAwsConnectionCredentials(appConnection); - case AppConnection.Databricks: - return validateDatabricksConnectionCredentials(appConnection); - case AppConnection.GitHub: - return validateGitHubConnectionCredentials(appConnection); - case AppConnection.GCP: - return validateGcpConnectionCredentials(appConnection); - case AppConnection.AzureKeyVault: - return validateAzureKeyVaultConnectionCredentials(appConnection); - case AppConnection.AzureAppConfiguration: - return validateAzureAppConfigurationConnectionCredentials(appConnection); - case AppConnection.Humanitec: - return validateHumanitecConnectionCredentials(appConnection); - default: - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - throw new Error(`Unhandled App Connection ${app}`); - } -}; +): Promise => VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection); export const getAppConnectionMethodName = (method: TAppConnection["method"]) => { switch (method) { @@ -136,8 +131,11 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => return "Service Account Impersonation"; case DatabricksConnectionMethod.ServicePrincipal: return "Service Principal"; - case HumanitecConnectionMethod.API_TOKEN: + case HumanitecConnectionMethod.ApiToken: return "API Token"; + case PostgresConnectionMethod.UsernameAndPassword: + case MsSqlConnectionMethod.UsernameAndPassword: + return "Username & Password"; default: // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new Error(`Unhandled App Connection Method: ${method}`); @@ -158,3 +156,24 @@ export const decryptAppConnection = async ( credentialsHash: generateHash(appConnection.encryptedCredentials) } as TAppConnection; }; + +const platformManagedCredentialsNotSupported: TAppConnectionTransitionCredentialsToPlatform = ({ app }) => { + throw new BadRequestError({ + message: `${APP_CONNECTION_NAME_MAP[app]} Connections do not support platform managed credentials.` + }); +}; + +export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< + AppConnection, + TAppConnectionTransitionCredentialsToPlatform +> = { + [AppConnection.AWS]: platformManagedCredentialsNotSupported, + [AppConnection.Databricks]: platformManagedCredentialsNotSupported, + [AppConnection.GitHub]: platformManagedCredentialsNotSupported, + [AppConnection.GCP]: platformManagedCredentialsNotSupported, + [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, + [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, + [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, + [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, + [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform +}; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 8a6c65426..eb28070d5 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -7,5 +7,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureKeyVault]: "Azure Key Vault", [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", - [AppConnection.Humanitec]: "Humanitec" + [AppConnection.Humanitec]: "Humanitec", + [AppConnection.Postgres]: "PostgreSQL", + [AppConnection.MsSql]: "Microsoft SQL Server" }; diff --git a/backend/src/services/app-connection/app-connection-schemas.ts b/backend/src/services/app-connection/app-connection-schemas.ts index ef3c16cf8..0d3968637 100644 --- a/backend/src/services/app-connection/app-connection-schemas.ts +++ b/backend/src/services/app-connection/app-connection-schemas.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { AppConnectionsSchema } from "@app/db/schemas/app-connections"; import { AppConnections } from "@app/lib/api-docs"; import { slugSchema } from "@app/server/lib/schemas"; +import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; +import { TAppConnectionBaseConfig } from "@app/services/app-connection/app-connection-types"; import { AppConnection } from "./app-connection-enums"; @@ -14,7 +16,10 @@ export const BaseAppConnectionSchema = AppConnectionsSchema.omit({ credentialsHash: z.string().optional() }); -export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => +export const GenericCreateAppConnectionFieldsSchema = ( + app: AppConnection, + { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} +) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.CREATE(app).name), description: z @@ -22,10 +27,16 @@ export const GenericCreateAppConnectionFieldsSchema = (app: AppConnection) => .trim() .max(256, "Description cannot exceed 256 characters") .nullish() - .describe(AppConnections.CREATE(app).description) + .describe(AppConnections.CREATE(app).description), + isPlatformManagedCredentials: supportsPlatformManagedCredentials + ? z.boolean().optional().default(false).describe(AppConnections.CREATE(app).isPlatformManagedCredentials) + : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); -export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => +export const GenericUpdateAppConnectionFieldsSchema = ( + app: AppConnection, + { supportsPlatformManagedCredentials = false }: TAppConnectionBaseConfig = {} +) => z.object({ name: slugSchema({ field: "name" }).describe(AppConnections.UPDATE(app).name).optional(), description: z @@ -33,5 +44,8 @@ export const GenericUpdateAppConnectionFieldsSchema = (app: AppConnection) => .trim() .max(256, "Description cannot exceed 256 characters") .nullish() - .describe(AppConnections.UPDATE(app).description) + .describe(AppConnections.UPDATE(app).description), + isPlatformManagedCredentials: supportsPlatformManagedCredentials + ? z.boolean().optional().describe(AppConnections.UPDATE(app).isPlatformManagedCredentials) + : z.literal(false).optional().describe(`Not supported for ${APP_CONNECTION_NAME_MAP[app]} Connections.`) }); diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index e2e55bba0..978f3bfd7 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -6,25 +6,27 @@ import { generateHash } from "@app/lib/crypto/encryption"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; import { DiscriminativePick, OrgServiceActor } from "@app/lib/types"; -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnection, encryptAppConnectionCredentials, getAppConnectionMethodName, listAppConnectionOptions, + TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM, validateAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; -import { APP_CONNECTION_NAME_MAP } from "@app/services/app-connection/app-connection-maps"; -import { - TAppConnection, - TAppConnectionConfig, - TCreateAppConnectionDTO, - TUpdateAppConnectionDTO, - TValidateAppConnectionCredentials -} from "@app/services/app-connection/app-connection-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; +import { AppConnection } from "./app-connection-enums"; +import { APP_CONNECTION_NAME_MAP } from "./app-connection-maps"; +import { + TAppConnection, + TAppConnectionConfig, + TAppConnectionRaw, + TCreateAppConnectionDTO, + TUpdateAppConnectionDTO, + TValidateAppConnectionCredentialsSchema +} from "./app-connection-types"; import { ValidateAwsConnectionCredentialsSchema } from "./aws"; import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; @@ -37,6 +39,8 @@ import { ValidateGitHubConnectionCredentialsSchema } from "./github"; import { githubConnectionService } from "./github/github-connection-service"; import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; +import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -46,14 +50,16 @@ export type TAppConnectionServiceFactoryDep = { export type TAppConnectionServiceFactory = ReturnType; -const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { +const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record = { [AppConnection.AWS]: ValidateAwsConnectionCredentialsSchema, [AppConnection.GitHub]: ValidateGitHubConnectionCredentialsSchema, [AppConnection.GCP]: ValidateGcpConnectionCredentialsSchema, [AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema, [AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema, [AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema, - [AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema + [AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema, + [AppConnection.Postgres]: ValidatePostgresConnectionCredentialsSchema, + [AppConnection.MsSql]: ValidateMsSqlConnectionCredentialsSchema }; export const appConnectionServiceFactory = ({ @@ -163,20 +169,38 @@ export const appConnectionServiceFactory = ({ orgId: actor.orgId } as TAppConnectionConfig); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService - }); - try { - const connection = await appConnectionDAL.create({ - orgId: actor.orgId, - encryptedCredentials, - method, - app, - ...params - }); + const createConnection = async (connectionCredentials: TAppConnection["credentials"]) => { + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }); + + return appConnectionDAL.create({ + orgId: actor.orgId, + encryptedCredentials, + method, + app, + ...params + }); + }; + + let connection: TAppConnectionRaw; + + if (params.isPlatformManagedCredentials) { + connection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app]( + { + app, + orgId: actor.orgId, + credentials: validatedCredentials, + method + } as TAppConnectionConfig, + (platformCredentials) => createConnection(platformCredentials) + ); + } else { + connection = await createConnection(validatedCredentials); + } return { ...connection, @@ -213,11 +237,18 @@ export const appConnectionServiceFactory = ({ OrgPermissionSubjects.AppConnections ); - let encryptedCredentials: undefined | Buffer; + // prevent updating credentials or management status if platform managed + if (appConnection.isPlatformManagedCredentials && (params.isPlatformManagedCredentials === false || credentials)) { + throw new BadRequestError({ + message: "Cannot update credentials or management status for platform managed connections" + }); + } + + let updatedCredentials: undefined | TAppConnection["credentials"]; + + const { app, method } = appConnection as DiscriminativePick; if (credentials) { - const { app, method } = appConnection as DiscriminativePick; - if ( !VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[app].safeParse({ method, @@ -230,29 +261,53 @@ export const appConnectionServiceFactory = ({ } Connection with method ${getAppConnectionMethodName(method)}` }); - const validatedCredentials = await validateAppConnectionCredentials({ + updatedCredentials = await validateAppConnectionCredentials({ app, orgId: actor.orgId, credentials, method } as TAppConnectionConfig); - if (!validatedCredentials) + if (!updatedCredentials) throw new BadRequestError({ message: "Unable to validate connection - check credentials" }); - - encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: validatedCredentials, - orgId: actor.orgId, - kmsService - }); } try { - const updatedConnection = await appConnectionDAL.updateById(connectionId, { - orgId: actor.orgId, - encryptedCredentials, - ...params - }); + const updateConnection = async (connectionCredentials: TAppConnection["credentials"] | undefined) => { + const encryptedCredentials = connectionCredentials + ? await encryptAppConnectionCredentials({ + credentials: connectionCredentials, + orgId: actor.orgId, + kmsService + }) + : undefined; + + return appConnectionDAL.updateById(connectionId, { + orgId: actor.orgId, + encryptedCredentials, + ...params + }); + }; + + let updatedConnection: TAppConnectionRaw; + + if (params.isPlatformManagedCredentials) { + if (!updatedCredentials) + // prevent enabling platform managed credentials without re-confirming credentials + throw new BadRequestError({ message: "Credentials required to transition to platform managed credentials" }); + + updatedConnection = await TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM[app]( + { + app, + orgId: actor.orgId, + credentials: updatedCredentials, + method + } as TAppConnectionConfig, + (platformCredentials) => updateConnection(platformCredentials) + ); + } else { + updatedConnection = await updateConnection(updatedCredentials); + } return await decryptAppConnection(updatedConnection, kmsService); } catch (err) { diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index 7051ecb11..be276d606 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,43 +1,56 @@ -import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { AWSRegion } from "./app-connection-enums"; import { TAwsConnection, TAwsConnectionConfig, TAwsConnectionInput, - TValidateAwsConnectionCredentials -} from "@app/services/app-connection/aws"; -import { - TDatabricksConnection, - TDatabricksConnectionConfig, - TDatabricksConnectionInput, - TValidateDatabricksConnectionCredentials -} from "@app/services/app-connection/databricks"; -import { - TGitHubConnection, - TGitHubConnectionConfig, - TGitHubConnectionInput, - TValidateGitHubConnectionCredentials -} from "@app/services/app-connection/github"; -import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; - + TValidateAwsConnectionCredentialsSchema +} from "./aws"; import { TAzureAppConfigurationConnection, TAzureAppConfigurationConnectionConfig, TAzureAppConfigurationConnectionInput, - TValidateAzureAppConfigurationConnectionCredentials + TValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; import { TAzureKeyVaultConnection, TAzureKeyVaultConnectionConfig, TAzureKeyVaultConnectionInput, - TValidateAzureKeyVaultConnectionCredentials + TValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; -import { TGcpConnection, TGcpConnectionConfig, TGcpConnectionInput, TValidateGcpConnectionCredentials } from "./gcp"; +import { + TDatabricksConnection, + TDatabricksConnectionConfig, + TDatabricksConnectionInput, + TValidateDatabricksConnectionCredentialsSchema +} from "./databricks"; +import { + TGcpConnection, + TGcpConnectionConfig, + TGcpConnectionInput, + TValidateGcpConnectionCredentialsSchema +} from "./gcp"; +import { + TGitHubConnection, + TGitHubConnectionConfig, + TGitHubConnectionInput, + TValidateGitHubConnectionCredentialsSchema +} from "./github"; import { THumanitecConnection, THumanitecConnectionConfig, THumanitecConnectionInput, - TValidateHumanitecConnectionCredentials + TValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; +import { TMsSqlConnection, TMsSqlConnectionInput, TValidateMsSqlConnectionCredentialsSchema } from "./mssql"; +import { + TPostgresConnection, + TPostgresConnectionInput, + TValidatePostgresConnectionCredentialsSchema +} from "./postgres"; export type TAppConnection = { id: string } & ( | TAwsConnection @@ -47,8 +60,14 @@ export type TAppConnection = { id: string } & ( | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TPostgresConnection + | TMsSqlConnection ); +export type TAppConnectionRaw = NonNullable>>; + +export type TSqlConnection = TPostgresConnection | TMsSqlConnection; + export type TAppConnectionInput = { id: string } & ( | TAwsConnectionInput | TGitHubConnectionInput @@ -57,11 +76,15 @@ export type TAppConnectionInput = { id: string } & ( | TAzureAppConfigurationConnectionInput | TDatabricksConnectionInput | THumanitecConnectionInput + | TPostgresConnectionInput + | TMsSqlConnectionInput ); +export type TSqlConnectionInput = TPostgresConnectionInput | TMsSqlConnectionInput; + export type TCreateAppConnectionDTO = Pick< TAppConnectionInput, - "credentials" | "method" | "name" | "app" | "description" + "credentials" | "method" | "name" | "app" | "description" | "isPlatformManagedCredentials" >; export type TUpdateAppConnectionDTO = Partial> & { @@ -75,19 +98,35 @@ export type TAppConnectionConfig = | TAzureKeyVaultConnectionConfig | TAzureAppConfigurationConnectionConfig | TDatabricksConnectionConfig - | THumanitecConnectionConfig; + | THumanitecConnectionConfig + | TSqlConnectionConfig; -export type TValidateAppConnectionCredentials = - | TValidateAwsConnectionCredentials - | TValidateGitHubConnectionCredentials - | TValidateGcpConnectionCredentials - | TValidateAzureKeyVaultConnectionCredentials - | TValidateAzureAppConfigurationConnectionCredentials - | TValidateDatabricksConnectionCredentials - | TValidateHumanitecConnectionCredentials; +export type TValidateAppConnectionCredentialsSchema = + | TValidateAwsConnectionCredentialsSchema + | TValidateGitHubConnectionCredentialsSchema + | TValidateGcpConnectionCredentialsSchema + | TValidateAzureKeyVaultConnectionCredentialsSchema + | TValidateAzureAppConfigurationConnectionCredentialsSchema + | TValidateDatabricksConnectionCredentialsSchema + | TValidateHumanitecConnectionCredentialsSchema + | TValidatePostgresConnectionCredentialsSchema + | TValidateMsSqlConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; region: AWSRegion; destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; }; + +export type TAppConnectionCredentialsValidator = ( + appConnection: TAppConnectionConfig +) => Promise; + +export type TAppConnectionTransitionCredentialsToPlatform = ( + appConnection: TAppConnectionConfig, + callback: (credentials: TAppConnection["credentials"]) => Promise +) => Promise; + +export type TAppConnectionBaseConfig = { + supportsPlatformManagedCredentials?: boolean; +}; diff --git a/backend/src/services/app-connection/aws/aws-connection-fns.ts b/backend/src/services/app-connection/aws/aws-connection-fns.ts index 00a44745a..767cb82fb 100644 --- a/backend/src/services/app-connection/aws/aws-connection-fns.ts +++ b/backend/src/services/app-connection/aws/aws-connection-fns.ts @@ -92,7 +92,7 @@ export const validateAwsConnectionCredentials = async (appConnection: TAwsConnec resp = await sts.getCallerIdentity().promise(); } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } diff --git a/backend/src/services/app-connection/aws/aws-connection-schemas.ts b/backend/src/services/app-connection/aws/aws-connection-schemas.ts index c06c6f0ed..8cb19ba26 100644 --- a/backend/src/services/app-connection/aws/aws-connection-schemas.ts +++ b/backend/src/services/app-connection/aws/aws-connection-schemas.ts @@ -48,11 +48,11 @@ export const SanitizedAwsConnectionSchema = z.discriminatedUnion("method", [ export const ValidateAwsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ - method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections?.CREATE(AppConnection.AWS).method), + method: z.literal(AwsConnectionMethod.AssumeRole).describe(AppConnections.CREATE(AppConnection.AWS).method), credentials: AwsConnectionAssumeRoleCredentialsSchema.describe(AppConnections.CREATE(AppConnection.AWS).credentials) }), z.object({ - method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections?.CREATE(AppConnection.AWS).method), + method: z.literal(AwsConnectionMethod.AccessKey).describe(AppConnections.CREATE(AppConnection.AWS).method), credentials: AwsConnectionAccessTokenCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AWS).credentials ) diff --git a/backend/src/services/app-connection/aws/aws-connection-types.ts b/backend/src/services/app-connection/aws/aws-connection-types.ts index a0b74c3d0..a311d604b 100644 --- a/backend/src/services/app-connection/aws/aws-connection-types.ts +++ b/backend/src/services/app-connection/aws/aws-connection-types.ts @@ -15,7 +15,7 @@ export type TAwsConnectionInput = z.infer & { app: AppConnection.AWS; }; -export type TValidateAwsConnectionCredentials = typeof ValidateAwsConnectionCredentialsSchema; +export type TValidateAwsConnectionCredentialsSchema = typeof ValidateAwsConnectionCredentialsSchema; export type TAwsConnectionConfig = DiscriminativePick & { orgId: string; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 9ccfc72b6..937a8a84f 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -57,7 +57,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } } diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts index db59a1558..8111b4c50 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-types.ts @@ -16,7 +16,7 @@ export type TAzureAppConfigurationConnectionInput = z.infer & { app: AppConnection.GCP; }; -export type TValidateGcpConnectionCredentials = typeof ValidateGcpConnectionCredentialsSchema; +export type TValidateGcpConnectionCredentialsSchema = typeof ValidateGcpConnectionCredentialsSchema; export type TGcpConnectionConfig = DiscriminativePick & { orgId: string; diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index 391ba5f96..6ec675c0f 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -200,7 +200,7 @@ export const validateGitHubConnectionCredentials = async (config: TGitHubConnect }); } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection - verify credentials` + message: `Unable to validate connection: verify credentials` }); } diff --git a/backend/src/services/app-connection/github/github-connection-types.ts b/backend/src/services/app-connection/github/github-connection-types.ts index 714c87174..600506277 100644 --- a/backend/src/services/app-connection/github/github-connection-types.ts +++ b/backend/src/services/app-connection/github/github-connection-types.ts @@ -15,6 +15,6 @@ export type TGitHubConnectionInput = z.infer; diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts index a3f31ed66..8011999b2 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-enums.ts @@ -1,3 +1,3 @@ export enum HumanitecConnectionMethod { - API_TOKEN = "api-token" + ApiToken = "api-token" } diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts index 0eeb9bfbf..b8d257026 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-fns.ts @@ -18,7 +18,7 @@ export const getHumanitecConnectionListItem = () => { return { name: "Humanitec" as const, app: AppConnection.Humanitec as const, - methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.API_TOKEN] + methods: Object.values(HumanitecConnectionMethod) as [HumanitecConnectionMethod.ApiToken] }; }; @@ -40,7 +40,7 @@ export const validateHumanitecConnectionCredentials = async (config: THumanitecC }); } throw new BadRequestError({ - message: "Unable to validate connection - verify credentials" + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts index 145f78b85..4e6cb0078 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-schemas.ts @@ -17,13 +17,13 @@ export const HumanitecConnectionAccessTokenCredentialsSchema = z.object({ const BaseHumanitecConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Humanitec) }); export const HumanitecConnectionSchema = BaseHumanitecConnectionSchema.extend({ - method: z.literal(HumanitecConnectionMethod.API_TOKEN), + method: z.literal(HumanitecConnectionMethod.ApiToken), credentials: HumanitecConnectionAccessTokenCredentialsSchema }); export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", [ BaseHumanitecConnectionSchema.extend({ - method: z.literal(HumanitecConnectionMethod.API_TOKEN), + method: z.literal(HumanitecConnectionMethod.ApiToken), credentials: HumanitecConnectionAccessTokenCredentialsSchema.pick({}) }) ]); @@ -31,8 +31,8 @@ export const SanitizedHumanitecConnectionSchema = z.discriminatedUnion("method", export const ValidateHumanitecConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z - .literal(HumanitecConnectionMethod.API_TOKEN) - .describe(AppConnections?.CREATE(AppConnection.Humanitec).method), + .literal(HumanitecConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.Humanitec).method), credentials: HumanitecConnectionAccessTokenCredentialsSchema.describe( AppConnections.CREATE(AppConnection.Humanitec).credentials ) diff --git a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts index 94613bfba..b9d084a86 100644 --- a/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts +++ b/backend/src/services/app-connection/humanitec/humanitec-connection-types.ts @@ -15,7 +15,7 @@ export type THumanitecConnectionInput = z.infer { + return { + name: "Microsoft SQL Server" as const, + app: AppConnection.MsSql as const, + methods: Object.values(MsSqlConnectionMethod) as [MsSqlConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts new file mode 100644 index 000000000..38ef0eef6 --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-schemas.ts @@ -0,0 +1,67 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { MsSqlConnectionMethod } from "./mssql-connection-enums"; + +export const MsSqlConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BaseMsSqlConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.MsSql) +}); + +export const MsSqlConnectionSchema = BaseMsSqlConnectionSchema.extend({ + method: z.literal(MsSqlConnectionMethod.UsernameAndPassword), + credentials: MsSqlConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedMsSqlConnectionSchema = z.discriminatedUnion("method", [ + BaseMsSqlConnectionSchema.extend({ + method: z.literal(MsSqlConnectionMethod.UsernameAndPassword), + credentials: MsSqlConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true + }) + }) +]); + +export const ValidateMsSqlConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(MsSqlConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.MsSql).method), + credentials: MsSqlConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.MsSql).credentials + ) + }) +]); + +export const CreateMsSqlConnectionSchema = ValidateMsSqlConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true }) +); + +export const UpdateMsSqlConnectionSchema = z + .object({ + credentials: MsSqlConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.MsSql).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.MsSql, { supportsPlatformManagedCredentials: true })); + +export const MsSqlConnectionListItemSchema = z.object({ + name: z.literal("Microsoft SQL Server"), + app: z.literal(AppConnection.MsSql), + methods: z.nativeEnum(MsSqlConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/mssql/mssql-connection-types.ts b/backend/src/services/app-connection/mssql/mssql-connection-types.ts new file mode 100644 index 000000000..dda4dfe9d --- /dev/null +++ b/backend/src/services/app-connection/mssql/mssql-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateMsSqlConnectionSchema, + MsSqlConnectionSchema, + ValidateMsSqlConnectionCredentialsSchema +} from "./mssql-connection-schemas"; + +export type TMsSqlConnection = z.infer; + +export type TMsSqlConnectionInput = z.infer & { + app: AppConnection.MsSql; +}; + +export type TValidateMsSqlConnectionCredentialsSchema = typeof ValidateMsSqlConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/postgres/index.ts b/backend/src/services/app-connection/postgres/index.ts new file mode 100644 index 000000000..23ddbba98 --- /dev/null +++ b/backend/src/services/app-connection/postgres/index.ts @@ -0,0 +1,4 @@ +export * from "./postgres-connection-enums"; +export * from "./postgres-connection-fns"; +export * from "./postgres-connection-schemas"; +export * from "./postgres-connection-types"; diff --git a/backend/src/services/app-connection/postgres/postgres-connection-enums.ts b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts new file mode 100644 index 000000000..a29807987 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-enums.ts @@ -0,0 +1,3 @@ +export enum PostgresConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/postgres/postgres-connection-fns.ts b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts new file mode 100644 index 000000000..39053dbf6 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-fns.ts @@ -0,0 +1,12 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PostgresConnectionMethod } from "./postgres-connection-enums"; + +export const getPostgresConnectionListItem = () => { + return { + name: "PostgreSQL" as const, + app: AppConnection.Postgres as const, + methods: Object.values(PostgresConnectionMethod) as [PostgresConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: true as const + }; +}; diff --git a/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts new file mode 100644 index 000000000..510f7b7d0 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-schemas.ts @@ -0,0 +1,65 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { BaseSqlUsernameAndPasswordConnectionSchema } from "../shared/sql"; +import { PostgresConnectionMethod } from "./postgres-connection-enums"; + +export const PostgresConnectionAccessTokenCredentialsSchema = BaseSqlUsernameAndPasswordConnectionSchema; + +const BasePostgresConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Postgres) }); + +export const PostgresConnectionSchema = BasePostgresConnectionSchema.extend({ + method: z.literal(PostgresConnectionMethod.UsernameAndPassword), + credentials: PostgresConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedPostgresConnectionSchema = z.discriminatedUnion("method", [ + BasePostgresConnectionSchema.extend({ + method: z.literal(PostgresConnectionMethod.UsernameAndPassword), + credentials: PostgresConnectionAccessTokenCredentialsSchema.pick({ + host: true, + database: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true + }) + }) +]); + +export const ValidatePostgresConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(PostgresConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.Postgres).method), + credentials: PostgresConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Postgres).credentials + ) + }) +]); + +export const CreatePostgresConnectionSchema = ValidatePostgresConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true }) +); + +export const UpdatePostgresConnectionSchema = z + .object({ + credentials: PostgresConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Postgres).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Postgres, { supportsPlatformManagedCredentials: true })); + +export const PostgresConnectionListItemSchema = z.object({ + name: z.literal("PostgreSQL"), + app: z.literal(AppConnection.Postgres), + methods: z.nativeEnum(PostgresConnectionMethod).array(), + supportsPlatformManagement: z.literal(true) +}); diff --git a/backend/src/services/app-connection/postgres/postgres-connection-types.ts b/backend/src/services/app-connection/postgres/postgres-connection-types.ts new file mode 100644 index 000000000..845b2b825 --- /dev/null +++ b/backend/src/services/app-connection/postgres/postgres-connection-types.ts @@ -0,0 +1,16 @@ +import z from "zod"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreatePostgresConnectionSchema, + PostgresConnectionSchema, + ValidatePostgresConnectionCredentialsSchema +} from "./postgres-connection-schemas"; + +export type TPostgresConnection = z.infer; + +export type TPostgresConnectionInput = z.infer & { + app: AppConnection.Postgres; +}; + +export type TValidatePostgresConnectionCredentialsSchema = typeof ValidatePostgresConnectionCredentialsSchema; diff --git a/backend/src/services/app-connection/shared/sql/index.ts b/backend/src/services/app-connection/shared/sql/index.ts new file mode 100644 index 000000000..107929154 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/index.ts @@ -0,0 +1,2 @@ +export * from "./sql-connection-fns"; +export * from "./sql-connection-schemas"; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts new file mode 100644 index 000000000..ed1d99941 --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-fns.ts @@ -0,0 +1,139 @@ +import knex, { Knex } from "knex"; + +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { + TSqlCredentialsRotationGeneratedCredentials, + TSqlCredentialsRotationWithConnection +} from "@app/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-types"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionRaw, TSqlConnection } from "@app/services/app-connection/app-connection-types"; +import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const SQL_CONNECTION_CLIENT_MAP = { + [AppConnection.Postgres]: "pg", + [AppConnection.MsSql]: "mssql" +}; + +const getConnectionConfig = ({ + app, + credentials: { host, sslCertificate, sslEnabled, sslRejectUnauthorized } +}: Pick) => { + switch (app) { + case AppConnection.Postgres: { + return { + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host + } + : false + }; + } + case AppConnection.MsSql: { + return { + options: sslEnabled + ? { + trustServerCertificate: !sslRejectUnauthorized, + encrypt: true, + cryptoCredentialsDetails: sslCertificate ? { ca: sslCertificate } : {} + } + : { encrypt: false } + }; + } + default: + throw new Error(`Unhandled SQL Connection Config: ${app as AppConnection}`); + } +}; + +export const getSqlConnectionClient = async (appConnection: Pick) => { + const { + app, + credentials: { host: baseHost, database, port, password, username } + } = appConnection; + + const [host] = await verifyHostInputValidity(baseHost); + + const client = knex({ + client: SQL_CONNECTION_CLIENT_MAP[app], + connection: { + database, + port, + host, + user: username, + password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ...getConnectionConfig(appConnection) + } + }); + + return client; +}; + +export const validateSqlConnectionCredentials = async (config: TSqlConnectionConfig) => { + const { credentials, app } = config; + + const client = await getSqlConnectionClient({ app, credentials }); + + try { + await client.raw(`Select 1`); + + return credentials; + } catch (error) { + throw new BadRequestError({ + message: + (error as Error)?.message?.replaceAll(credentials.password, "********************") ?? + "Unable to validate connection: verify credentials" + }); + } finally { + await client.destroy(); + } +}; + +export const SQL_CONNECTION_ALTER_LOGIN_STATEMENT: Record< + TSqlCredentialsRotationWithConnection["connection"]["app"], + (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => [string, Knex.RawBinding] +> = { + [AppConnection.Postgres]: ({ username, password }) => [`ALTER USER ?? WITH PASSWORD '${password}';`, [username]], + [AppConnection.MsSql]: ({ username, password }) => [`ALTER LOGIN ?? WITH PASSWORD = '${password}';`, [username]] +}; + +export const transferSqlConnectionCredentialsToPlatform = async ( + config: TSqlConnectionConfig, + callback: (credentials: TSqlConnectionConfig["credentials"]) => Promise +) => { + const { credentials, app } = config; + + const client = await getSqlConnectionClient({ app, credentials }); + + const newPassword = alphaNumericNanoId(32); + + try { + return await client.transaction(async (tx) => { + await tx.raw( + ...SQL_CONNECTION_ALTER_LOGIN_STATEMENT[app]({ username: credentials.username, password: newPassword }) + ); + return callback({ + ...credentials, + password: newPassword + }); + }); + } catch (error) { + // update/create service function will handle + if (error instanceof DatabaseError) { + throw error; + } + + throw new BadRequestError({ + message: + (error as Error)?.message?.replaceAll(newPassword, "********************") ?? + "Encountered an error transferring credentials to platform" + }); + } finally { + await client.destroy(); + } +}; diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts new file mode 100644 index 000000000..500ed596a --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-schemas.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; + +export const BaseSqlUsernameAndPasswordConnectionSchema = z.object({ + host: z.string().trim().min(1, "Host required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.host), + port: z.coerce.number().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.port), + database: z.string().trim().min(1, "Database required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.database), + username: z.string().trim().min(1, "Username required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.username), + password: z.string().trim().min(1, "Password required").describe(AppConnections.CREDENTIALS.SQL_CONNECTION.password), + sslEnabled: z.boolean().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslEnabled), + sslRejectUnauthorized: z.boolean().describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + .describe(AppConnections.CREDENTIALS.SQL_CONNECTION.sslCertificate) +}); diff --git a/backend/src/services/app-connection/shared/sql/sql-connection-types.ts b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts new file mode 100644 index 000000000..bbfe4086c --- /dev/null +++ b/backend/src/services/app-connection/shared/sql/sql-connection-types.ts @@ -0,0 +1,6 @@ +import { DiscriminativePick } from "@app/lib/types"; +import { TSqlConnectionInput } from "@app/services/app-connection/app-connection-types"; + +export type TSqlConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/backend/src/services/identity/identity-org-dal.ts b/backend/src/services/identity/identity-org-dal.ts index 92a6795d0..dbae59bbe 100644 --- a/backend/src/services/identity/identity-org-dal.ts +++ b/backend/src/services/identity/identity-org-dal.ts @@ -14,10 +14,15 @@ import { TIdentityUniversalAuths, TOrgRoles } from "@app/db/schemas"; -import { DatabaseError } from "@app/lib/errors"; +import { BadRequestError, DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; +import { buildKnexFilterForSearchResource } from "@app/lib/search-resource/db"; import { OrderByDirection } from "@app/lib/types"; -import { OrgIdentityOrderBy, TListOrgIdentitiesByOrgIdDTO } from "@app/services/identity/identity-types"; +import { + OrgIdentityOrderBy, + TListOrgIdentitiesByOrgIdDTO, + TSearchOrgIdentitiesByOrgIdDAL +} from "@app/services/identity/identity-types"; import { buildAuthMethods } from "./identity-fns"; @@ -195,7 +200,6 @@ export const identityOrgDALFactory = (db: TDbClient) => { "paginatedIdentity.identityId", `${TableName.IdentityJwtAuth}.identityId` ) - .select( db.ref("id").withSchema("paginatedIdentity"), db.ref("role").withSchema("paginatedIdentity"), @@ -309,6 +313,214 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; + const searchIdentities = async ( + { + limit, + offset = 0, + orderBy = OrgIdentityOrderBy.Name, + orderDirection = OrderByDirection.ASC, + searchFilter, + orgId + }: TSearchOrgIdentitiesByOrgIdDAL, + tx?: Knex + ) => { + try { + const searchQuery = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .join(TableName.Identity, `${TableName.Identity}.id`, `${TableName.IdentityOrgMembership}.identityId`) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .orderBy(`${TableName.Identity}.${orderBy}`, orderDirection) + .select(`${TableName.IdentityOrgMembership}.id`) + .select<{ id: string; total_count: string }>( + db.raw( + `count(${TableName.IdentityOrgMembership}."identityId") OVER(PARTITION BY ${TableName.IdentityOrgMembership}."orgId") as total_count` + ) + ) + .as("searchedIdentities"); + + if (searchFilter) { + buildKnexFilterForSearchResource(searchQuery, searchFilter, (attr) => { + switch (attr) { + case "role": + return [`${TableName.OrgRoles}.slug`, `${TableName.IdentityOrgMembership}.role`]; + case "name": + return `${TableName.Identity}.name`; + default: + throw new BadRequestError({ message: `Invalid ${String(attr)} provided` }); + } + }); + } + + if (limit) { + void searchQuery.offset(offset).limit(limit); + } + + type TSubquery = Awaited; + const query = (tx || db.replicaNode())(TableName.IdentityOrgMembership) + .where(`${TableName.IdentityOrgMembership}.orgId`, orgId) + .join(searchQuery, `${TableName.IdentityOrgMembership}.id`, "searchedIdentities.id") + .join(TableName.Identity, `${TableName.IdentityOrgMembership}.identityId`, `${TableName.Identity}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.IdentityOrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.IdentityOrgMembership}.identityId`, `${TableName.IdentityMetadata}.identityId`) + .andOn(`${TableName.IdentityOrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .leftJoin( + TableName.IdentityUniversalAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityUniversalAuth}.identityId` + ) + .leftJoin( + TableName.IdentityGcpAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityGcpAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAwsAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAwsAuth}.identityId` + ) + .leftJoin( + TableName.IdentityKubernetesAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityKubernetesAuth}.identityId` + ) + .leftJoin( + TableName.IdentityOidcAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityOidcAuth}.identityId` + ) + .leftJoin( + TableName.IdentityAzureAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityAzureAuth}.identityId` + ) + .leftJoin( + TableName.IdentityTokenAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityTokenAuth}.identityId` + ) + .leftJoin( + TableName.IdentityJwtAuth, + `${TableName.IdentityOrgMembership}.identityId`, + `${TableName.IdentityJwtAuth}.identityId` + ) + .select( + db.ref("id").withSchema(TableName.IdentityOrgMembership), + db.ref("total_count").withSchema("searchedIdentities"), + db.ref("role").withSchema(TableName.IdentityOrgMembership), + db.ref("roleId").withSchema(TableName.IdentityOrgMembership), + db.ref("orgId").withSchema(TableName.IdentityOrgMembership), + db.ref("createdAt").withSchema(TableName.IdentityOrgMembership), + db.ref("updatedAt").withSchema(TableName.IdentityOrgMembership), + db.ref("identityId").withSchema(TableName.IdentityOrgMembership).as("identityId"), + db.ref("name").withSchema(TableName.Identity).as("identityName"), + + db.ref("id").as("uaId").withSchema(TableName.IdentityUniversalAuth), + db.ref("id").as("gcpId").withSchema(TableName.IdentityGcpAuth), + db.ref("id").as("awsId").withSchema(TableName.IdentityAwsAuth), + db.ref("id").as("kubernetesId").withSchema(TableName.IdentityKubernetesAuth), + db.ref("id").as("oidcId").withSchema(TableName.IdentityOidcAuth), + db.ref("id").as("azureId").withSchema(TableName.IdentityAzureAuth), + db.ref("id").as("tokenId").withSchema(TableName.IdentityTokenAuth), + db.ref("id").as("jwtId").withSchema(TableName.IdentityJwtAuth) + ) + // cr stands for custom role + .select(db.ref("id").as("crId").withSchema(TableName.OrgRoles)) + .select(db.ref("name").as("crName").withSchema(TableName.OrgRoles)) + .select(db.ref("slug").as("crSlug").withSchema(TableName.OrgRoles)) + .select(db.ref("description").as("crDescription").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select(db.ref("permissions").as("crPermission").withSchema(TableName.OrgRoles)) + .select( + db.ref("id").withSchema(TableName.IdentityMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue") + ); + + if (orderBy === OrgIdentityOrderBy.Name) { + void query.orderBy("identityName", orderDirection); + } + + const docs = await query; + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: ({ + crId, + crDescription, + crSlug, + crPermission, + crName, + identityId, + identityName, + role, + roleId, + total_count, + id, + uaId, + awsId, + gcpId, + jwtId, + kubernetesId, + oidcId, + azureId, + tokenId, + createdAt, + updatedAt + }) => ({ + role, + roleId, + identityId, + id, + total_count: total_count as string, + orgId, + createdAt, + updatedAt, + customRole: roleId + ? { + id: crId, + name: crName, + slug: crSlug, + permissions: crPermission, + description: crDescription + } + : undefined, + identity: { + id: identityId, + name: identityName, + authMethods: buildAuthMethods({ + uaId, + awsId, + gcpId, + kubernetesId, + oidcId, + azureId, + tokenId, + jwtId + }) + } + }), + childrenMapper: [ + { + key: "metadataId", + label: "metadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return { docs: formattedDocs, totalCount: Number(formattedDocs?.[0]?.total_count ?? 0) }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByOrgId" }); + } + }; + const countAllOrgIdentities = async ( { search, ...filter }: Partial & Pick, tx?: Knex @@ -331,5 +543,5 @@ export const identityOrgDALFactory = (db: TDbClient) => { } }; - return { ...identityOrgOrm, find, findOne, countAllOrgIdentities }; + return { ...identityOrgOrm, find, findOne, countAllOrgIdentities, searchIdentities }; }; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index f9185ba9d..6f72b3c6e 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -21,6 +21,7 @@ import { TGetIdentityByIdDTO, TListOrgIdentitiesByOrgIdDTO, TListProjectIdentitiesByIdentityIdDTO, + TSearchOrgIdentitiesByOrgIdDTO, TUpdateIdentityDTO } from "./identity-types"; @@ -288,6 +289,33 @@ export const identityServiceFactory = ({ return { identityMemberships, totalCount }; }; + const searchOrgIdentities = async ({ + orgId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter = {} + }: TSearchOrgIdentitiesByOrgIdDTO) => { + const { permission } = await permissionService.getOrgPermission(actor, actorId, orgId, actorAuthMethod, actorOrgId); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); + + const { totalCount, docs } = await identityOrgMembershipDAL.searchIdentities({ + orgId, + limit, + offset, + orderBy, + orderDirection, + searchFilter + }); + + return { identityMemberships: docs, totalCount }; + }; + const listProjectIdentitiesByIdentityId = async ({ identityId, actor, @@ -317,6 +345,7 @@ export const identityServiceFactory = ({ deleteIdentity, listOrgIdentities, getIdentityById, + searchOrgIdentities, listProjectIdentitiesByIdentityId }; }; diff --git a/backend/src/services/identity/identity-types.ts b/backend/src/services/identity/identity-types.ts index 0eca6b7ee..363d42a88 100644 --- a/backend/src/services/identity/identity-types.ts +++ b/backend/src/services/identity/identity-types.ts @@ -1,4 +1,5 @@ import { IPType } from "@app/lib/ip"; +import { TSearchResourceOperator } from "@app/lib/search-resource/search"; import { OrderByDirection, TOrgPermission } from "@app/lib/types"; export type TCreateIdentityDTO = { @@ -46,3 +47,17 @@ export enum OrgIdentityOrderBy { Name = "name" // Role = "role" } + +export type TSearchOrgIdentitiesByOrgIdDAL = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + orgId: string; + searchFilter?: Partial<{ + name: Omit; + role: Omit; + }>; +}; + +export type TSearchOrgIdentitiesByOrgIdDTO = TSearchOrgIdentitiesByOrgIdDAL & TOrgPermission; diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts index c9f792978..62767200c 100644 --- a/backend/src/services/org-admin/org-admin-service.ts +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -12,17 +12,22 @@ import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; type TOrgAdminServiceFactoryDep = { permissionService: Pick; - projectDAL: Pick; - projectMembershipDAL: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "findOne" | "create" | "transaction" | "delete" | "findAllProjectMembers" + >; projectKeyDAL: Pick; projectBotDAL: Pick; userDAL: Pick; projectUserMembershipRoleDAL: Pick; + smtpService: Pick; }; export type TOrgAdminServiceFactory = ReturnType; @@ -34,7 +39,8 @@ export const orgAdminServiceFactory = ({ projectKeyDAL, projectBotDAL, userDAL, - projectUserMembershipRoleDAL + projectUserMembershipRoleDAL, + smtpService }: TOrgAdminServiceFactoryDep) => { const listOrgProjects = async ({ actor, @@ -89,7 +95,7 @@ export const orgAdminServiceFactory = ({ OrgPermissionSubjects.AdminConsole ); - const project = await projectDAL.findById(projectId); + const project = await projectDAL.findOne({ id: projectId, orgId: actorOrgId }); if (!project) throw new NotFoundError({ message: `Project with ID '${projectId}' not found` }); if (project.version === ProjectVersion.V1) { @@ -184,6 +190,23 @@ export const orgAdminServiceFactory = ({ ); return newProjectMembership; }); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const filteredProjectMembers = projectMembers + .filter( + (member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin) && member.userId !== actorId + ) + .map((el) => el.user.email!); + + await smtpService.sendMail({ + template: SmtpTemplates.OrgAdminProjectDirectAccess, + recipients: filteredProjectMembers, + subjectLine: "Organization Admin Project Direct Access Issued", + substitutions: { + projectName: project.name, + email: projectMembers.find((el) => el.userId === actorId)?.user?.username + } + }); return { isExistingMember: false, membership: updatedMembership }; }; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 1868871f3..98b35f68f 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -231,7 +231,7 @@ export const orgServiceFactory = ({ const findAllWorkspaces = async ({ actor, actorId, orgId, type }: TFindAllWorkspacesDTO) => { if (actor === ActorType.USER) { - const workspaces = await projectDAL.findAllProjects(actorId, orgId, type || "all"); + const workspaces = await projectDAL.findUserProjects(actorId, orgId, type || "all"); return workspaces; } diff --git a/backend/src/services/project/project-dal.ts b/backend/src/services/project/project-dal.ts index 896e1b858..43f1d57e4 100644 --- a/backend/src/services/project/project-dal.ts +++ b/backend/src/services/project/project-dal.ts @@ -6,20 +6,23 @@ import { ProjectType, ProjectUpgradeStatus, ProjectVersion, + SortDirection, TableName, + TProjects, TProjectsUpdate } from "@app/db/schemas"; import { BadRequestError, DatabaseError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; -import { Filter, ProjectFilterType } from "./project-types"; +import { ActorType } from "../auth/auth-type"; +import { Filter, ProjectFilterType, SearchProjectSortBy } from "./project-types"; export type TProjectDALFactory = ReturnType; export const projectDALFactory = (db: TDbClient) => { const projectOrm = ormify(db, TableName.Project); - const findAllProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { + const findUserProjects = async (userId: string, orgId: string, projectType: ProjectType | "all") => { try { const workspaces = await db .replicaNode()(TableName.ProjectMembership) @@ -352,9 +355,79 @@ export const projectDALFactory = (db: TDbClient) => { } }; + const searchProjects = async (dto: { + orgId: string; + actor: ActorType; + actorId: string; + type?: ProjectType; + limit?: number; + offset?: number; + name?: string; + sortBy?: SearchProjectSortBy; + sortDir?: SortDirection; + }) => { + const { limit = 20, offset = 0, sortBy = SearchProjectSortBy.NAME, sortDir = SortDirection.ASC } = dto; + + const userMembershipSubquery = db(TableName.ProjectMembership).where({ userId: dto.actorId }).select("projectId"); + const groups = db(TableName.UserGroupMembership).where({ userId: dto.actorId }).select("groupId"); + const groupMembershipSubquery = db(TableName.GroupProjectMembership).whereIn("groupId", groups).select("projectId"); + + const identityMembershipSubQuery = db(TableName.IdentityProjectMembership) + .where({ identityId: dto.actorId }) + .select("projectId"); + + // Get the SQL strings for the subqueries + const userMembershipSql = userMembershipSubquery.toQuery(); + const groupMembershipSql = groupMembershipSubquery.toQuery(); + const identityMembershipSql = identityMembershipSubQuery.toQuery(); + + const query = db + .replicaNode()(TableName.Project) + .where(`${TableName.Project}.orgId`, dto.orgId) + .select(selectAllTableCols(TableName.Project)) + .select(db.raw("COUNT(*) OVER() AS count")) + .select<(TProjects & { isMember: boolean; count: number })[]>( + dto.actor === ActorType.USER + ? db.raw( + ` + CASE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + ELSE FALSE + END as "isMember" + `, + [db.raw(userMembershipSql), db.raw(groupMembershipSql)] + ) + : db.raw( + ` + CASE + WHEN ${TableName.Project}.id IN (?) THEN TRUE + ELSE FALSE + END as "isMember" + `, + [db.raw(identityMembershipSql)] + ) + ) + .limit(limit) + .offset(offset); + if (sortBy === SearchProjectSortBy.NAME) { + void query.orderBy([{ column: `${TableName.Project}.name`, order: sortDir }]); + } + + if (dto.type) { + void query.where(`${TableName.Project}.type`, dto.type); + } + if (dto.name) { + void query.whereILike(`${TableName.Project}.name`, `%${dto.name}%`); + } + const docs = await query; + + return { docs, totalCount: Number(docs?.[0]?.count ?? 0) }; + }; + return { ...projectOrm, - findAllProjects, + findUserProjects, setProjectUpgradeStatus, findAllProjectsByIdentity, findProjectGhostUser, @@ -363,6 +436,7 @@ export const projectDALFactory = (db: TDbClient) => { findProjectBySlug, findProjectWithOrg, checkProjectUpgradeStatus, - getProjectFromSplitId + getProjectFromSplitId, + searchProjects }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index dbef33c10..58e3f9b54 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -23,6 +23,7 @@ import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-cer import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh-certificate-dal"; import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; @@ -57,6 +58,7 @@ import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secr import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; import { TSlackIntegrationDALFactory } from "../slack/slack-integration-dal"; +import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TUserDALFactory } from "../user/user-dal"; import { TProjectDALFactory } from "./project-dal"; import { assignWorkspaceKeysToMembers, createProjectKey } from "./project-fns"; @@ -76,6 +78,8 @@ import { TListProjectSshCertificatesDTO, TListProjectSshCertificateTemplatesDTO, TLoadProjectKmsBackupDTO, + TProjectAccessRequestDTO, + TSearchProjectsDTO, TToggleProjectAutoCapitalizationDTO, TUpdateAuditLogsRetentionDTO, TUpdateProjectDTO, @@ -106,7 +110,10 @@ type TProjectServiceFactoryDep = { identityProjectDAL: TIdentityProjectDALFactory; identityProjectMembershipRoleDAL: Pick; projectKeyDAL: Pick; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick< + TProjectMembershipDALFactory, + "create" | "findProjectGhostUser" | "findOne" | "delete" | "findAllProjectMembers" + >; groupProjectDAL: Pick; projectSlackConfigDAL: Pick; slackIntegrationDAL: Pick; @@ -123,6 +130,7 @@ type TProjectServiceFactoryDep = { orgService: Pick; licenseService: Pick; queueService: Pick; + smtpService: Pick; orgDAL: Pick; keyStore: Pick; @@ -177,7 +185,8 @@ export const projectServiceFactory = ({ projectSlackConfigDAL, slackIntegrationDAL, projectTemplateService, - groupProjectDAL + groupProjectDAL, + smtpService }: TProjectServiceFactoryDep) => { /* * Create workspace. Make user the admin @@ -506,7 +515,7 @@ export const projectServiceFactory = ({ actorOrgId, type = ProjectType.SecretManager }: TListProjectsDTO) => { - const workspaces = await projectDAL.findAllProjects(actorId, actorOrgId, type); + const workspaces = await projectDAL.findUserProjects(actorId, actorOrgId, type); if (includeRoles) { const { permission } = await permissionService.getUserOrgPermission( @@ -1339,6 +1348,85 @@ export const projectServiceFactory = ({ }); }; + const searchProjects = async ({ + name, + offset, + permission, + limit, + type, + orderBy, + orderDirection + }: TSearchProjectsDTO) => { + // check user belong to org + await permissionService.getOrgPermission( + permission.type, + permission.id, + permission.orgId, + permission.authMethod, + permission.orgId + ); + + return projectDAL.searchProjects({ + limit, + offset, + name, + type, + orgId: permission.orgId, + actor: permission.type, + actorId: permission.id, + sortBy: orderBy, + sortDir: orderDirection + }); + }; + + const requestProjectAccess = async ({ permission, comment, projectId }: TProjectAccessRequestDTO) => { + // check user belong to org + await permissionService.getOrgPermission( + permission.type, + permission.id, + permission.orgId, + permission.authMethod, + permission.orgId + ); + + const projectMember = await permissionService + .getProjectPermission({ + actor: permission.type, + actorId: permission.id, + projectId, + actionProjectType: ActionProjectType.Any, + actorAuthMethod: permission.authMethod, + actorOrgId: permission.orgId + }) + .catch(() => { + return null; + }); + if (projectMember) throw new BadRequestError({ message: "User already has access to the project" }); + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const filteredProjectMembers = projectMembers + .filter((member) => member.roles.some((role) => role.role === ProjectMembershipRole.Admin)) + .map((el) => el.user.email!); + const org = await orgDAL.findOne({ id: permission.orgId }); + const project = await projectDAL.findById(projectId); + const userDetails = await userDAL.findById(permission.id); + const appCfg = getConfig(); + + await smtpService.sendMail({ + template: SmtpTemplates.ProjectAccessRequest, + recipients: filteredProjectMembers, + subjectLine: "Project Access Request", + substitutions: { + requesterName: `${userDetails.firstName} ${userDetails.lastName}`, + requesterEmail: userDetails.email, + projectName: project?.name, + orgName: org?.name, + note: comment, + callback_url: `${appCfg.SITE_URL}/${project.type}/${project.id}/access-management?selectedTab=members&requesterEmail=${userDetails.email}` + } + }); + }; + return { createProject, deleteProject, @@ -1364,6 +1452,8 @@ export const projectServiceFactory = ({ loadProjectKmsBackup, getProjectKmsKeys, getProjectSlackConfig, - updateProjectSlackConfig + updateProjectSlackConfig, + requestProjectAccess, + searchProjects }; }; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 5ccf33d23..30519005d 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; -import { ProjectType, TProjectKeys } from "@app/db/schemas"; -import { TProjectPermission } from "@app/lib/types"; +import { ProjectType, SortDirection, TProjectKeys } from "@app/db/schemas"; +import { OrgServiceActor, TProjectPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -158,3 +158,23 @@ export type TUpdateProjectSlackConfig = { isSecretRequestNotificationEnabled: boolean; secretRequestChannels: string; } & TProjectPermission; + +export enum SearchProjectSortBy { + NAME = "name" +} + +export type TSearchProjectsDTO = { + permission: OrgServiceActor; + name?: string; + type?: ProjectType; + limit?: number; + offset?: number; + orderBy?: SearchProjectSortBy; + orderDirection?: SortDirection; +}; + +export type TProjectAccessRequestDTO = { + permission: OrgServiceActor; + projectId: string; + comment?: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index cc2cd1fcf..617393668 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -31,7 +31,11 @@ const baseSecretSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: Secre db.ref("description").withSchema(TableName.AppConnection).as("connectionDescription"), db.ref("version").withSchema(TableName.AppConnection).as("connectionVersion"), db.ref("createdAt").withSchema(TableName.AppConnection).as("connectionCreatedAt"), - db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt") + db.ref("updatedAt").withSchema(TableName.AppConnection).as("connectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("connectionIsPlatformManagedCredentials") ); if (filter) { @@ -60,6 +64,7 @@ const expandSecretSync = ( connectionCreatedAt, connectionUpdatedAt, connectionVersion, + connectionIsPlatformManagedCredentials, ...el } = secretSync; @@ -77,7 +82,8 @@ const expandSecretSync = ( description: connectionDescription, createdAt: connectionCreatedAt, updatedAt: connectionUpdatedAt, - version: connectionVersion + version: connectionVersion, + isPlatformManagedCredentials: connectionIsPlatformManagedCredentials }, folder: folder ? { diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 5c4a7e850..14a1a1cf0 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -119,14 +119,10 @@ export const secretSyncServiceFactory = ({ { destination, syncName, projectId }: TFindSecretSyncByNameDTO, actor: OrgServiceActor ) => { - const folders = await folderDAL.findByProjectId(projectId); - - // we prevent conflicting names within a project so this will only return one at most - const [secretSync] = await secretSyncDAL.find({ + // we prevent conflicting names within a project + const secretSync = await secretSyncDAL.findOne({ name: syncName, - $in: { - folderId: folders.map((folder) => folder.id) - } + projectId }); if (!secretSync) diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 2044c7c17..bd28e1ee7 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -1,6 +1,6 @@ import { Job } from "bullmq"; -import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { QueueJobs } from "@app/queue"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { @@ -129,8 +129,6 @@ export type TDeleteSecretSyncDTO = { removeSecrets: boolean; }; -type AuditLogInfo = Pick; - export enum SecretSyncStatus { Pending = "pending", Running = "running", diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index b4619abd3..9bed01637 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -35,15 +35,25 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretTag}.id` ) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select(selectAllTableCols(TableName.SecretV2)) .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("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); const data = sqlNestRelationships({ data: docs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + isRotatedSecret: Boolean(el.rotationId), + rotationId: el.rotationId + }), childrenMapper: [ { key: "tagId", @@ -79,6 +89,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretTag}.id` ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .select( db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), @@ -87,7 +102,8 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretV2)) .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("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { @@ -98,7 +114,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { const data = sqlNestRelationships({ data: docs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + rotationId: el.rotationId, + isRotatedSecret: Boolean(el.rotationId) + }), childrenMapper: [ { key: "tagId", @@ -332,6 +353,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { } const query = (tx || db.replicaNode())(TableName.SecretV2) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .whereIn("folderId", folderIds) .where((bd) => { if (filters?.search) { @@ -414,6 +440,11 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { `${TableName.SecretTag}.id` ) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) .where((qb) => { if (filters?.metadataFilter && filters.metadataFilter.length > 0) { filters.metadataFilter.forEach((meta) => { @@ -444,6 +475,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") ) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)) .where((bd) => { const slugs = filters?.tagSlugs?.filter(Boolean); if (slugs && slugs.length > 0) { @@ -472,7 +504,12 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { const data = sqlNestRelationships({ data: secs, key: "id", - parentMapper: (el) => ({ _id: el.id, ...SecretsV2Schema.parse(el) }), + parentMapper: (el) => ({ + _id: el.id, + ...SecretsV2Schema.parse(el), + rotationId: el.rotationId, + isRotatedSecret: Boolean(el.rotationId) + }), childrenMapper: [ { key: "tagId", @@ -511,6 +548,7 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { try { const secrets = await (tx || db.replicaNode())(TableName.SecretV2) .where({ folderId }) + .where((bd) => { query.forEach((el) => { if (el.type === SecretType.Personal && !el.userId) { @@ -522,10 +560,20 @@ export const secretV2BridgeDALFactory = (db: TDbClient) => { userId: el.type === SecretType.Personal ? el.userId : null }); }); - }); - return secrets; + }) + .leftJoin( + TableName.SecretRotationV2SecretMapping, + `${TableName.SecretV2}.id`, + `${TableName.SecretRotationV2SecretMapping}.secretId` + ) + .select(selectAllTableCols(TableName.SecretV2)) + .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); + return secrets.map((secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + })); } catch (error) { - throw new DatabaseError({ error, name: "find by blind indexes" }); + throw new DatabaseError({ error, name: "find by secret keys" }); } }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 1544e963c..4ab021510 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -666,6 +666,8 @@ export const reshapeBridgeSecret = ( name: string; }[]; secretMetadata?: ResourceMetadataDTO; + isRotatedSecret?: boolean; + rotationId?: string; }, secretValueHidden: boolean ) => ({ @@ -695,7 +697,8 @@ export const reshapeBridgeSecret = ( secretMetadata: secret.secretMetadata, createdAt: secret.createdAt, updatedAt: secret.updatedAt, - + isRotatedSecret: secret.isRotatedSecret, + rotationId: secret.rotationId, ...(secretValueHidden ? { secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 6fdfb10f1..509144e21 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -25,6 +25,7 @@ import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-app 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 { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { diff, groupBy } from "@app/lib/fn"; import { setKnexStringValue } from "@app/lib/knex"; @@ -416,6 +417,8 @@ export const secretV2BridgeServiceFactory = ({ }); if (!sharedSecretToModify) throw new NotFoundError({ message: `Secret with name ${inputSecret.secretName} not found` }); + if (sharedSecretToModify.isRotatedSecret && (inputSecret.newSecretName || inputSecret.secretValue)) + throw new BadRequestError({ message: "Cannot update rotated secret name or value" }); secretId = sharedSecretToModify.id; secret = sharedSecretToModify; } @@ -626,66 +629,79 @@ export const secretV2BridgeServiceFactory = ({ }) ); - const deletedSecret = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ - projectId, - folderId, - actorId, - secretDAL, - secretQueueService, - inputSecrets: [ - { - type: inputSecret.type as SecretType, - secretKey: inputSecret.secretName - } - ], - tx - }) - ); + try { + const deletedSecret = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + projectId, + folderId, + actorId, + secretDAL, + secretQueueService, + inputSecrets: [ + { + type: inputSecret.type as SecretType, + secretKey: inputSecret.secretName + } + ], + tx + }) + ); - if (inputSecret.type === SecretType.Shared) { - await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ - secretPath, - actorId, - actor, - projectId, - orgId: actorOrgId, - environmentSlug: folder.environment.slug + if (inputSecret.type === SecretType.Shared) { + await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + secretPath, + actorId, + actor, + projectId, + orgId: actorOrgId, + environmentSlug: folder.environment.slug + }); + } + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId }); - } - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); + const secretValueHidden = !hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment, + secretPath, + secretName: secretToDelete.key, + secretTags: secretToDelete.tags?.map((el) => el.slug) + } + ); - const secretValueHidden = !hasSecretReadValueOrDescribePermission( - permission, - ProjectPermissionSecretActions.ReadValue, - { + return reshapeBridgeSecret( + projectId, environment, secretPath, - secretName: secretToDelete.key, - secretTags: secretToDelete.tags?.map((el) => el.slug) + { + ...deletedSecret[0], + value: deletedSecret[0].encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() + : "", + comment: deletedSecret[0].encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() + : "" + }, + secretValueHidden + ); + } catch (err) { + // deferred errors aren't return as DatabaseError + const error = err as { code: string; table: string }; + if ( + error?.code === DatabaseErrorCode.ForeignKeyViolation && + error?.table === TableName.SecretRotationV2SecretMapping + ) { + throw new BadRequestError({ message: "Cannot delete rotated secrets" }); } - ); - return reshapeBridgeSecret( - projectId, - environment, - secretPath, - { - ...deletedSecret[0], - value: deletedSecret[0].encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedValue }).toString() - : "", - comment: deletedSecret[0].encryptedComment - ? secretManagerDecryptor({ cipherTextBlob: deletedSecret[0].encryptedComment }).toString() - : "" - }, - secretValueHidden - ); + throw err; + } }; // get unique secrets count for multiple envs @@ -948,6 +964,7 @@ export const secretV2BridgeServiceFactory = ({ projectId }); + // scott: if any of this changes it also needs to be mirrored in secret rotation for getting dashboard secrets const decryptedSecrets = secrets .filter((el) => { const canDescribeSecret = hasSecretReadValueOrDescribePermission( @@ -1235,21 +1252,24 @@ export const secretV2BridgeServiceFactory = ({ : secretVersionDAL .findOne({ folderId, + version, type: secretType, userId: secretType === SecretType.Personal ? actorId : null, key: secretName }) .then((el) => - SecretsV2Schema.extend({ - tags: z - .object({ slug: z.string(), name: z.string(), id: z.string(), color: z.string() }) - .array() - .default([]) - .optional() - }).parse({ - ...el, - id: el.secretId - }) + el + ? SecretsV2Schema.extend({ + tags: z + .object({ slug: z.string(), name: z.string(), id: z.string(), color: z.string() }) + .array() + .default([]) + .optional() + }).parse({ + ...el, + id: el.secretId + }) + : undefined )); throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { @@ -1669,6 +1689,13 @@ export const secretV2BridgeServiceFactory = ({ secretTags: el.tags.map((i) => i.slug) }) ); + + if (el.isRotatedSecret) { + const input = secretsToUpdateGroupByPath[secretPath].find((i) => i.secretKey === el.key); + + if (input && (input.newSecretName || input.secretValue)) + throw new BadRequestError({ message: `Cannot update rotated secret name or value: ${el.key}` }); + } }); // get all tags @@ -1971,61 +1998,76 @@ export const secretV2BridgeServiceFactory = ({ ); }); - const secretsDeleted = await secretDAL.transaction(async (tx) => - fnSecretBulkDelete({ - secretDAL, - secretQueueService, - inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ - secretKey, - type: type || SecretType.Shared - })), - projectId, - folderId, - actorId, - tx - }) - ); - - // await snapshotService.performSnapshot(folderId); - await secretQueueService.syncSecrets({ - actor, - actorId, - secretPath, - projectId, - orgId: actorOrgId, - environmentSlug: folder.environment.slug - }); - - const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ - type: KmsDataKey.SecretManager, - projectId - }); - return secretsDeleted.map((el) => { - const secretToDeleteMatch = secretsToDelete.find( - (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type + try { + const secretsDeleted = await secretDAL.transaction(async (tx) => + fnSecretBulkDelete({ + secretDAL, + secretQueueService, + inputSecrets: inputSecrets.map(({ type, secretKey }) => ({ + secretKey, + type: type || SecretType.Shared + })), + projectId, + folderId, + actorId, + tx + }) ); - const secretValueHidden = - !secretToDeleteMatch || - !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + await snapshotService.performSnapshot(folderId); + await secretQueueService.syncSecrets({ + actor, + actorId, + secretPath, + projectId, + orgId: actorOrgId, + environmentSlug: folder.environment.slug + }); + + const { decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.SecretManager, + projectId + }); + return secretsDeleted.map((el) => { + const secretToDeleteMatch = secretsToDelete.find( + (i) => i.key === el.key && (i.type || SecretType.Shared) === el.type + ); + + const secretValueHidden = + !secretToDeleteMatch || + !hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath, + secretName: el.key, + secretTags: secretToDeleteMatch.tags?.map((i) => i.slug) + }); + + return reshapeBridgeSecret( + projectId, environment, secretPath, - secretName: el.key, - secretTags: secretToDeleteMatch.tags?.map((i) => i.slug) - }); + { + ...el, + value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", + comment: el.encryptedComment + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() + : "" + }, + secretValueHidden + ); + }); + } catch (err) { + // deferred errors aren't return as DatabaseError + const error = err as { code: string; table: string }; + if ( + error?.code === DatabaseErrorCode.ForeignKeyViolation && + error?.table === TableName.SecretRotationV2SecretMapping + ) { + throw new BadRequestError({ message: "Cannot delete rotated secrets" }); + } - return reshapeBridgeSecret( - projectId, - environment, - secretPath, - { - ...el, - value: el.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() : "", - comment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "" - }, - secretValueHidden - ); - }); + throw err; + } }; const getSecretVersions = async ({ @@ -2197,6 +2239,10 @@ export const secretV2BridgeServiceFactory = ({ const destinationActions = [ProjectPermissionSecretActions.Create, ProjectPermissionSecretActions.Edit] as const; sourceSecrets.forEach((secret) => { + if (secret.isRotatedSecret) { + throw new BadRequestError({ message: `Cannot move rotated secret: ${secret.key}` }); + } + for (const sourceAction of sourceActions) { if ( sourceAction === ProjectPermissionSecretActions.DescribeSecret || diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 5c5b26ae4..7b4ea1ee1 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -132,7 +132,7 @@ export type TUpdateManySecretDTO = Omit & { secrets: { secretKey: string; newSecretName?: string; - secretValue: string; + secretValue?: string; secretComment?: string; skipMultilineEncoding?: boolean; tagIds?: string[]; diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index 7aeb59f01..a82b04833 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1558,6 +1558,7 @@ export const secretServiceFactory = ({ actorOrgId, actor, actorId, + version, expandSecretReferences, type, secretName diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index bea59a439..be036cab8 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -298,7 +298,7 @@ export type TUpdateManySecretRawDTO = Omit & { secrets: { secretKey: string; newSecretName?: string; - secretValue: string; + secretValue?: string; secretComment?: string; skipMultilineEncoding?: boolean; tagIds?: string[]; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 68e0ecd22..452283235 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -40,7 +40,10 @@ export enum SmtpTemplates { ExternalImportSuccessful = "externalImportSuccessful.handlebars", ExternalImportFailed = "externalImportFailed.handlebars", ExternalImportStarted = "externalImportStarted.handlebars", - SecretRequestCompleted = "secretRequestCompleted.handlebars" + SecretRequestCompleted = "secretRequestCompleted.handlebars", + SecretRotationFailed = "secretRotationFailed.handlebars", + ProjectAccessRequest = "projectAccess.handlebars", + OrgAdminProjectDirectAccess = "orgAdminProjectGrantAccess.handlebars" } export enum SmtpHost { diff --git a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars index 3c0811a1c..ef11957a7 100644 --- a/backend/src/services/smtp/templates/accessApprovalRequest.handlebars +++ b/backend/src/services/smtp/templates/accessApprovalRequest.handlebars @@ -49,4 +49,4 @@ {{emailFooter}} - \ No newline at end of file + diff --git a/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars b/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars new file mode 100644 index 000000000..ef8c6e6b4 --- /dev/null +++ b/backend/src/services/smtp/templates/orgAdminProjectGrantAccess.handlebars @@ -0,0 +1,16 @@ + + + + + + Organization admin issued direct access to project + + + +

Infisical

+

The organization admin {{email}} has granted direct access to the project "{{projectName}}".

+ + {{emailFooter}} + + + diff --git a/backend/src/services/smtp/templates/projectAccess.handlebars b/backend/src/services/smtp/templates/projectAccess.handlebars new file mode 100644 index 000000000..5ff1ca7ec --- /dev/null +++ b/backend/src/services/smtp/templates/projectAccess.handlebars @@ -0,0 +1,26 @@ + + + + + + Project Access Request + + + +

Infisical

+

You have a new project access request!

+
    +
  • Requester Name: "{{requesterName}}"
  • +
  • Requester Email: "{{requesterEmail}}"
  • +
  • Project Name: "{{projectName}}"
  • +
  • Organization Name: "{{orgName}}"
  • +
  • User Note: "{{note}}"
  • +
+

+ Please click on the link below to grant access +

+ Grant Access + {{emailFooter}} + + + diff --git a/backend/src/services/smtp/templates/secretRotationFailed.handlebars b/backend/src/services/smtp/templates/secretRotationFailed.handlebars new file mode 100644 index 000000000..728798ce8 --- /dev/null +++ b/backend/src/services/smtp/templates/secretRotationFailed.handlebars @@ -0,0 +1,31 @@ + + + + + + Your {{rotationType}} Rotation "{{rotationName}}" Failed to Rotate + + + +

Infisical

+ + + +
+
+

Name: {{rotationName}}

+

Type: {{rotationType}}

+

Project: {{projectName}}

+

Environment: {{environment}}

+

Secret Path: {{secretPath}}

+
+ + {{emailFooter}} + + + \ No newline at end of file diff --git a/cli/packages/cmd/root.go b/cli/packages/cmd/root.go index 04af9cce8..b9370ad89 100644 --- a/cli/packages/cmd/root.go +++ b/cli/packages/cmd/root.go @@ -50,6 +50,7 @@ func init() { config.INFISICAL_URL = util.AppendAPIEndpoint(config.INFISICAL_URL) + // util.DisplayAptInstallationChangeBanner(silent) if !util.IsRunningInDocker() && !silent { util.CheckForUpdate() } diff --git a/cli/packages/util/check-for-update.go b/cli/packages/util/check-for-update.go index f3aca2776..4aae75f65 100644 --- a/cli/packages/util/check-for-update.go +++ b/cli/packages/util/check-for-update.go @@ -53,6 +53,25 @@ func CheckForUpdate() { } } +func DisplayAptInstallationChangeBanner(isSilent bool) { + if isSilent { + return + } + + if runtime.GOOS == "linux" { + _, err := exec.LookPath("apt-get") + isApt := err == nil + if isApt { + yellow := color.New(color.FgYellow).SprintFunc() + msg := fmt.Sprintf("%s", + yellow("Update Required: Your current package installation script is outdated and will no longer receive updates.\nPlease update to the new installation script which can be found here https://infisical.com/docs/cli/overview#installation debian section\n"), + ) + + fmt.Fprintln(os.Stderr, msg) + } + } +} + func getLatestTag(repoOwner string, repoName string) (string, string, error) { url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", repoOwner, repoName) resp, err := http.Get(url) diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 53f97f4c9..0693db509 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -694,7 +694,7 @@ func SetRawSecrets(secretArgs []string, secretType string, environmentName strin if err != nil { return nil, fmt.Errorf("unable to get client with custom headers [err=%v]", err) } - + httpClient.SetAuthToken(tokenDetails.Token) httpClient.SetHeader("Accept", "application/json") // pull current secrets diff --git a/cli/scripts/setup.deb.sh b/cli/scripts/setup.deb.sh new file mode 100644 index 000000000..ef24bcadc --- /dev/null +++ b/cli/scripts/setup.deb.sh @@ -0,0 +1,551 @@ +#!/usr/bin/env bash +# +# The core commands execute start from the "MAIN" section below. +# + +test -z "$BASH_SOURCE" && { + self="sudo -E bash" + prefix=" |" +} || { + self=$(readlink -f ${BASH_SOURCE:-$0}) + prefix="" +} + +tmp_log=$(mktemp .s3_setup_XXXXXXXXX) + +# Environment variables that can be set +PKG_URL=${PKG_URL:-"https://artifacts-cli.infisical.com"} +PKG_PATH=${PKG_PATH:-"deb"} +PACKAGE_NAME=${PACKAGE_NAME:-"infisical"} +GPG_KEY_URL=${GPG_KEY_URL:-"${PKG_URL}/infisical.gpg"} + +colours=$(tput colors 2>/dev/null || echo "256") +no_colour="\e[39;49m" +green_colour="\e[32m" +red_colour="\e[41;97m" +bold="\e[1m" +reset="\e[0m" +use_colours=$(test -n "$colours" && test $colours -ge 8 && echo "yes") +test "$use_colours" == "yes" || { + no_colour="" + green_colour="" + red_colour="" + bold="" + reset="" +} + +example_name="Ubuntu/Focal (20.04)" +example_distro="ubuntu" +example_codename="focal" +example_version="20.04" + +function echo_helptext { + local help_text="$*" + echo " ^^^^: ... $help_text" +} + +function die { + local text="$@" + test ! -z "$text" && { + echo_helptext "$text" 1>&2 + } + + local prefix="${red_colour} !!!!${no_colour}" + + echo -e "$prefix: Oh no, your setup failed! :-( ... But we might be able to help. :-)" + echo -e "$prefix: " + echo -e "$prefix: ${bold}Please check your S3 bucket configuration and try again.${reset}" + echo -e "$prefix: " + + test -f "$tmp_log" && { + local n=20 + echo -e "$prefix: Last $n log lines from $tmp_log (might not be errors, nor even relevant):" + echo -e "$prefix:" + check_tool_silent "xargs" && { + check_tool_silent "fmt" && { + tail -n $n $tmp_log | fmt -t | xargs -Ilog echo -e "$prefix: > log" + } || { + tail -n $n $tmp_log | xargs -Ilog echo -e "$prefix: > log" + } + } || { + echo + tail -n $n $tmp_log + } + } + exit 1 +} + +function echo_colour { + local colour="${1:-"no"}_colour"; shift + echo -e "${!colour}$@${no_colour}" +} + +function echo_green_or_red { + local rc="$1" + local good="${2:-YES}" + local bad="${3:-NO}" + + test "$rc" -eq 0 && { + echo_colour "green" "$good" + } || { + echo_colour "red" "$bad" + } + return $rc +} + +function echo_clearline { + local rc="$?" + echo -e -n "\033[1K\r" + return $rc +} + +function echo_status { + local rc="$1" + local good="$2" + local bad="$3" + local text="$4" + local help_text="$5" + local newline=$(test "$6" != "no" && echo "\n" || echo "") + local status_text=$(echo_green_or_red "$rc" "$good" "$bad") + + echo_clearline + local width=$(test "$use_colours" == "yes" && echo "16" || echo "5") + printf "%${width}s %s${newline}" "${status_text}:" "$text" + test $rc -ne 0 && test ! -z "$help_text" && { + echo_helptext "$help_text" + echo + } + + return $rc +} + +function echo_running { + local rc=$? + local text="$1" + echo_status 0 " RUN" " RUN" "$text" "" "no" + return $rc +} + +function echo_okfail_rc { + local rc=$1 + local text="$2" + local help_text="$3" + echo_clearline + echo_status $rc " OK" " NOPE" "$text" "$help_text" + return $rc +} + +function echo_okfail { + echo_okfail_rc $? "$@" + return $? +} + +function check_tool_silent { + local tool=${1} + command -v $tool &>/dev/null || which $tool &>/dev/null + return $? +} + +function check_tool { + local tool=${1} + local optional=${2:-false} + local required_text="optional" + if ! $optional; then required_text="required"; fi + local text="Checking for $required_text executable '$tool' ..." + echo_running "$text" + check_tool_silent "$tool" + echo_okfail "$text" || { + if ! $optional; then + die "$tool is not installed, but is required by this script." + fi + return 1 + } + return 0 +} + +function cleanup { + echo + rm -rf $tmp_log +} + +function shutdown { + echo_colour "red" " !!!!: Operation cancelled by user!" + exit 2 +} + +function check_os { + test ! -z "$distro" && test ! -z "${version}${codename}" + return $? +} + +function detect_os_system { + check_os && return 0 + echo_running "$text" + local text="Detecting your OS distribution and release using system methods ..." + + local tool_rc=1 + test -f '/etc/os-release' && { + . /etc/os-release + distro=${distro:-$ID} + codename=${codename:-$VERSION_CODENAME} + codename=${codename:-$(echo $VERSION | cut -d '(' -f 2 | cut -d ')' -f 1)} + version=${version:-$VERSION_ID} + + test -z "${version}${codename}" && test -f '/etc/debian_version' && { + # Workaround for Debian unstable releases; get the codename from debian_version + codename=$(cat /etc/debian_version | cut -d '/' -f1) + } + + tool_rc=0 + } + + check_os + local rc=$? + echo_okfail_rc $rc "$text" + + test $tool_rc -eq 0 && { + report_os_expanded + } + + return $rc +} + +function report_os_attribute { + local name=$1 + local value=$2 + local coloured="" + echo -n "$name=" + test -z "$value" && { + echo -e -n "${red_colour}${no_colour} " + } || { + echo -e -n "${green_colour}${value}${no_colour} " + } +} + +function report_os_expanded { + echo_helptext "Detected/provided for your OS/distribution, version and architecture:" + echo " >>>>:" + report_os_values +} + +function report_os_values { + echo -n " >>>>: ... " + report_os_attribute "distro" $distro + report_os_attribute "codename" "stable (fixed)" + report_os_attribute "arch" $arch + echo + echo " >>>>:" +} + +function detect_os_legacy_python { + check_os && return 0 + + local text="Detecting your OS distribution and release using legacy python ..." + echo_running "$text" + + IFS='' read -r -d '' script <<-'EOF' +from __future__ import unicode_literals, print_function +import platform; +info = platform.linux_distribution() or ('', '', ''); +for key, value in zip(('distro', 'version', 'codename'), info): + print("local guess_%s=\"%s\"\n" % (key, value.lower().replace(' ', ''))); +EOF + + local tool_rc=1 + check_tool_silent "python" && { + eval $(python -c "$script") + distro=${distro:-$guess_distro} + codename=${codename:-$guess_codename} + version=${version:-$guess_version} + tool_rc=$? + } + + check_os + local rc=$? + echo_okfail_rc $rc "$text" + + check_tool_silent "python" || { + echo_helptext "Python isn't available, so skipping detection method (hint: install python)" + } + + test $tool_rc -eq 0 && { + report_os + } + + return $rc +} + +function detect_os_modern_python { + check_os && return 0 + + check_tool_silent "python" && { + local text="Ensuring python-pip is installed ..." + echo_running "$text" + check_tool_silent "pip" + echo_okfail "$text" || { + local text="Checking if pip can be bootstrapped without get-pip ..." + echo_running "$text" + python -m ensurepip --default-pip &>$tmp_log + echo_okfail "$text" || { + local text="Installing pip via get-pip bootstrap ..." + echo_running "$text" + curl -1sLf https://bootstrap.pypa.io/get-pip.py 2>$tmp/log | python &>$tmp_log + echo_okfail "$text" || die "Failed to install pip!" + } + } + + local text="Installing 'distro' python library ..." + echo_running "$text" + python -c 'import distro' &>$tmp_log || python -m pip install distro &>$tmp_log + echo_okfail "$text" || die "Failed to install required 'distro' python library!" + } + + IFS='' read -r -d '' script <<-'EOF' +from __future__ import unicode_literals, print_function +import distro; +info = distro.linux_distribution(full_distribution_name=False) or ('', '', ''); +for key, value in zip(('distro', 'version', 'codename'), info): + print("local guess_%s=\"%s\"\n" % (key, value.lower().replace(' ', ''))); +EOF + + local text="Detecting your OS distribution and release using modern python ..." + echo_running "$text" + + local tool_rc=1 + check_tool_silent "python" && { + eval $(python -c "$script") + distro=${distro:-$guess_distro} + codename=${codename:-$guess_codename} + version=${version:-$guess_version} + tool_rc=$? + } + + check_os + local rc=$? + echo_okfail_rc $rc "$text" + + check_tool_silent "python" || { + echo_helptext "Python isn't available, so skipping detection method (hint: install python)" + } + + test $tool_rc -eq 0 && { + report_os_expanded + } + + return $rc +} + +function detect_os { + # Backwards compat for old distribution parameter names + distro=${distro:-$os} + + # Always use "stable" as the codename + codename="stable" + + arch=${arch:-$(arch || uname -m)} + + # Only detect OS if not manually specified + if [ -z "$distro" ]; then + detect_os_system || + detect_os_legacy_python || + detect_os_modern_python + fi + + # Always ensure we have a distro + (test -z "$distro") && { + echo_okfail_rc "1" "Unable to detect your OS distribution!" + cat <>>>: + >>>>: The 'distro' value is required. Without it, the install script + >>>>: cannot retrieve the correct configuration for this system. + >>>>: + >>>>: You can force this script to use a particular value by specifying distro + >>>>: via environment variable. E.g., to specify a distro + >>>>: such as $example_name, use the following: + >>>>: + >>>>: $prefix distro=$example_distro $self + >>>>: +EOF + die + } +} + +function create_repo_config { + if [ -z "$PKG_PATH" ]; then + repo_url="${PKG_URL}" + else + repo_url="${PKG_URL}/${PKG_PATH}" + fi + + # Create configuration with GPG key verification + local gpg_keyring_path="/usr/share/keyrings/${PACKAGE_NAME}-archive-keyring.gpg" + local apt_conf=$(cat <>>>: + >>>>: It looks like we can't access the GPG key at ${GPG_KEY_URL} + >>>>: +EOF + die + } +} + +function check_dpkg_tool { + local tool=${1} + local required=${2:-true} + local install=${3:-true} + + local text="Checking for apt dependency '$tool' ..." + echo_running "$text" + dpkg -l | grep "$tool\>" &>$tmp_log + echo_okfail "$text" || { + if $install; then + test "$apt_updated" == "yes" || update_apt + local text="Attempting to install '$tool' ..." + echo_running "$text" + apt-get install -y "$tool" &>$tmp_log + echo_okfail "$text" || { + if $required; then + die "Could not install '$tool', check your permissions, etc." + fi + } + else { + if $required; then + die "$tool is not installed, but is required by this script." + fi + } + fi + } + return 0 +} + +function update_apt { + local text="Updating apt repository metadata cache ..." + local tmp_log=$(mktemp .s3_deb_output_XXXXXXXXX.log) + echo_running "$text" + apt-get update &>$tmp_log + echo_okfail "$text" || { + echo_colour "red" "Failed to update via apt-get update" + cat $tmp_log + rm -rf $tmp_log + die "Failed to update via apt-get update - Context above (maybe no packages?)." + } + rm -rf $tmp_log + apt_updated="yes" +} + +function install_apt_prereqs { + # Debian-archive-keyring has to be installed for apt-transport-https. + test "${distro}" == "debian" && { + check_dpkg_tool "debian-keyring" + check_dpkg_tool "debian-archive-keyring" + } + + check_dpkg_tool "apt-transport-https" + check_dpkg_tool "ca-certificates" false + check_dpkg_tool "gnupg" +} + +function import_gpg_key { + local text="Importing '$PACKAGE_NAME' repository GPG key from S3 ..." + echo_running "$text" + + local gpg_keyring_path="/usr/share/keyrings/${PACKAGE_NAME}-archive-keyring.gpg" + + # Check if GPG key is accessible + check_gpg_key + + # Download and import GPG key + curl -1sLf "${GPG_KEY_URL}" | gpg --dearmor > $gpg_keyring_path + chmod 644 $gpg_keyring_path + + # Check for older apt versions that don't support signed-by + local signed_by_version="1.1" + local detected_version=$(dpkg -s apt | grep Version | cut -d' ' -f2) + + if [ "$(printf "%s\n" $detected_version $signed_by_version | sort -V | head -n 1)" != "$signed_by_version" ]; then + echo_helptext "Detected older apt version without signed-by support. Copying key to trusted.gpg.d." + cp ${gpg_keyring_path} /etc/apt/trusted.gpg.d/${PACKAGE_NAME}.gpg + chmod 644 /etc/apt/trusted.gpg.d/${PACKAGE_NAME}.gpg + fi + + echo_okfail "$text" || die "Could not import the GPG key for this repository" +} + +function setup_repository { + local repo_path="/etc/apt/sources.list.d/${PACKAGE_NAME}.list" + + local text="Installing '$PACKAGE_NAME' repository via apt ..." + echo_running "$text" + create_repo_config > "$repo_path" + chmod 644 $repo_path + echo_okfail "$text" || die "Could not install the repository, do you have permissions?" +} + +function usage () { + cat < + Check out the configuration docs for [Microsoft SQL Server + Connections](/integrations/app-connections/mssql) to learn how to obtain the + required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/mssql/delete.mdx b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx new file mode 100644 index 000000000..af45cb416 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/mssql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx new file mode 100644 index 000000000..9eb08c97d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/mssql/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx new file mode 100644 index 000000000..c916d2219 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/mssql/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/list.mdx b/docs/api-reference/endpoints/app-connections/mssql/list.mdx new file mode 100644 index 000000000..490bb497b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/mssql" +--- diff --git a/docs/api-reference/endpoints/app-connections/mssql/update.mdx b/docs/api-reference/endpoints/app-connections/mssql/update.mdx new file mode 100644 index 000000000..75f91dc3b --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/mssql/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/mssql/{connectionId}" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Connections](/integrations/app-connections/mssql) to learn how to obtain the + required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/postgres/available.mdx b/docs/api-reference/endpoints/app-connections/postgres/available.mdx new file mode 100644 index 000000000..92e360d06 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/postgres/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/create.mdx b/docs/api-reference/endpoints/app-connections/postgres/create.mdx new file mode 100644 index 000000000..3dca12325 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/postgres" +--- + + + Check out the configuration docs for [PostgreSQL + Connections](/integrations/app-connections/postgres) to learn how to obtain the + required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/postgres/delete.mdx b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx new file mode 100644 index 000000000..927bfec49 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/postgres/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx new file mode 100644 index 000000000..3ee3f5996 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/postgres/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx new file mode 100644 index 000000000..c9b29cb66 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/postgres/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/list.mdx b/docs/api-reference/endpoints/app-connections/postgres/list.mdx new file mode 100644 index 000000000..5d1be4664 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/postgres" +--- diff --git a/docs/api-reference/endpoints/app-connections/postgres/update.mdx b/docs/api-reference/endpoints/app-connections/postgres/update.mdx new file mode 100644 index 000000000..32a4217c1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/postgres/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/postgres/{connectionId}" +--- + + + Check out the configuration docs for [PostgreSQL + Connections](/integrations/app-connections/postgres) to learn how to obtain the + required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/identities/search.mdx b/docs/api-reference/endpoints/identities/search.mdx new file mode 100644 index 000000000..93906a33b --- /dev/null +++ b/docs/api-reference/endpoints/identities/search.mdx @@ -0,0 +1,4 @@ +--- +title: "Search" +openapi: "POST /api/v1/identities/search" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/list.mdx b/docs/api-reference/endpoints/secret-rotations/list.mdx new file mode 100644 index 000000000..8b3e931f0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx new file mode 100644 index 000000000..8b6c8bc88 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/mssql-credentials" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Credentials Rotations](/documentation/platform/secret-rotation/mssql) to learn how to obtain the + required parameters. + diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx new file mode 100644 index 000000000..117948674 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx new file mode 100644 index 000000000..e0fc208ee --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx new file mode 100644 index 000000000..442ab5bd7 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..311715879 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/mssql-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx new file mode 100644 index 000000000..e79ee758b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/mssql-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..543acb9e3 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/mssql-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx new file mode 100644 index 000000000..4dd5f3267 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/mssql-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/mssql-credentials/{rotationId}" +--- + + + Check out the configuration docs for [Microsoft SQL Server + Credentials Rotations](/documentation/platform/secret-rotation/mssql) to learn how to obtain the + required parameters. + diff --git a/docs/api-reference/endpoints/secret-rotations/options.mdx b/docs/api-reference/endpoints/secret-rotations/options.mdx new file mode 100644 index 000000000..9e1a4e544 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/options.mdx @@ -0,0 +1,4 @@ +--- +title: "Options" +openapi: "GET /api/v2/secret-rotations/options" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx new file mode 100644 index 000000000..fabd51f94 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/postgres-credentials" +--- + + + Check out the configuration docs for [PostgreSQL + Credentials Rotations](/documentation/platform/secret-rotation/postgres) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx new file mode 100644 index 000000000..7919313b6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx new file mode 100644 index 000000000..7914eac7e --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx new file mode 100644 index 000000000..f215a1d7b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..34f308514 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/postgres-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx new file mode 100644 index 000000000..6c93a2790 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/postgres-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..687c15279 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/postgres-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx new file mode 100644 index 000000000..7aebcb72c --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/postgres-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/postgres-credentials/{rotationId}" +--- + + + Check out the configuration docs for [PostgreSQL + Credentials Rotations](/documentation/platform/secret-rotation/postgres) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index c2e13ee1c..89824e9f7 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -8,6 +8,11 @@ You can use it across various environments, whether it's local development, CI/C ## Installation + + As of 04/08/25, all future releases for Debian/Ubuntu will be distributed via the official Infisical repository at https://artifacts-cli.infisical.com. + No new releases will be published for Debian/Ubuntu on Cloudsmith going forward. + + Use [brew](https://brew.sh/) package manager @@ -93,11 +98,12 @@ You can use it across various environments, whether it's local development, CI/C + Add Infisical repository ```bash curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' \ + 'https://artifacts-cli.infisical.com/setup.deb.sh' \ | sudo -E bash ``` diff --git a/docs/documentation/platform/access-controls/project-access-requests.mdx b/docs/documentation/platform/access-controls/project-access-requests.mdx new file mode 100644 index 000000000..38812c003 --- /dev/null +++ b/docs/documentation/platform/access-controls/project-access-requests.mdx @@ -0,0 +1,36 @@ +--- +title: "Project Access Requests" +description: "Learn how to request access to projects in Infisical." +--- + +The Project Access Request feature allows users to view all projects within organization, including those they don't currently have access to. +Users can request access to these projects by submitting a request that automatically notifies project administrators via email, along with any comments provided by the user. + +# Viewing Available Projects + +From the Infisical dashboard, users can view all projects within the organization: + +1. Navigate to the main dashboard after logging in +2. The overview page for each product displays two tabs: + + - **My Projects**: Projects you currently have access to + - **All Projects**: Complete list of projects in the organization + +![all-project-view](/images/platform/project-access-requests/all-project-view.png) + +# Requesting Access to a Project + +To request access to a project you don't currently have access for: + +1. Click the **Request Access** button next to the project name + ![all-project-view](/images/platform/project-access-requests/request-access.png) + +2. Add a comment explaining why you need access + ![all-project-view](/images/platform/project-access-requests/access-comment.png) + +3. Click **Submit Request** + + + Project administrators will receive email notification with details regarding + the access request. + diff --git a/docs/documentation/platform/admin-panel/org-admin-console.mdx b/docs/documentation/platform/admin-panel/org-admin-console.mdx index 39d7819a4..08a327268 100644 --- a/docs/documentation/platform/admin-panel/org-admin-console.mdx +++ b/docs/documentation/platform/admin-panel/org-admin-console.mdx @@ -4,13 +4,13 @@ description: "View and manage resources across your organization" --- - The Organization Admin Console can only be accessed by organization members with admin status. + The Organization Admin Console can only be accessed by organization members + with admin status. - ## Accessing the Organization Admin Console -On the sidebar, tap on your initials to access the settings dropdown and press the **Organization Admin Console** option. +On the sidebar, hover over **Admin** to access the settings dropdown and press the **Organization Admin Console** option. ![Access Organization Admin Console](/images/platform/admin-panels/access-org-admin-console.png) @@ -20,12 +20,9 @@ The Projects tab lists all the projects within your organization, including thos ![Projects Section](/images/platform/admin-panels/org-admin-console-projects.png) - ### Accessing a Project in Your Organization You can access a project that you are not a member of by tapping on the options menu of the project row and pressing the **Access** button. Doing so will grant you admin permissions for the selected project and add you as a member. ![Access project](/images/platform/admin-panels/org-admin-console-access.png) - - diff --git a/docs/documentation/platform/admin-panel/server-admin.mdx b/docs/documentation/platform/admin-panel/server-admin.mdx index 137cdde59..198e5c37c 100644 --- a/docs/documentation/platform/admin-panel/server-admin.mdx +++ b/docs/documentation/platform/admin-panel/server-admin.mdx @@ -13,7 +13,7 @@ customize settings and manage users for their entire Infisical instance. ## Accessing the Server Admin Console -On the sidebar, tap on your initials to access the settings dropdown and press the **Server Admin Console** option. +On the sidebar, hover over **Admin** to access the settings dropdown and press the **Server Admin Console** option. ![Access Server Admin Console](/images/platform/admin-panels/access-server-admin-panel.png) @@ -40,7 +40,7 @@ If you're using SAML/LDAP/OIDC for only one organization on your instance, you c By default, users signing up through SAML/LDAP/OIDC will still need to verify their email address to prevent email spoofing. This requirement can be skipped by enabling the switch to trust logins through the respective method. -### Notices +### Broadcast Messages Auth consent content is displayed to users on the login page. They can be used to display important information to users, such as a maintenance message or a new feature announcement. Both HTML and Markdown formatting are supported, allowing for customized styling like below: diff --git a/docs/documentation/platform/project.mdx b/docs/documentation/platform/project.mdx index bd80d8ae5..dcf447169 100644 --- a/docs/documentation/platform/project.mdx +++ b/docs/documentation/platform/project.mdx @@ -3,19 +3,21 @@ title: "Projects" description: "Learn more and understand the concept of Infisical projects." --- -A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. -Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. +A project in Infisical belongs to an [organization](./organization) and contains a number of environments, folders, and secrets. +Only users and machine identities who belong to a project can access resources inside of it according to predefined permissions. + +Infisical also allows users to request project access. Refer to the [project access request section](./access-controls/project-access-requests) ## Project environments -For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be -customized depending on the intended use case. +For both visual and organizational structure, Infisical allows splitting up secrets into environments (e.g., development, staging, production). In project settings, such environments can be +customized depending on the intended use case. ![project secrets overview](../../images/platform/project/project-environments.png) ## Secrets Overview -The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. +The **Secrets Overview** page captures a birds-eye-view of secrets and [folders](./folder) across environments. This is useful for comparing secrets, identifying if anything is missing, and making quick changes. ![project secrets overview](../../images/platform/project/project-secrets-overview-open.png) @@ -98,7 +100,7 @@ Then: - If users B and C fetch the secret D back, they both get the value E. - Please keep in mind that secret reminders won't work with personal overrides. + Please keep in mind that secret reminders won't work with personal overrides. ![project override secret](../../images/platform/project/project-secrets-override.png) @@ -112,4 +114,3 @@ To view the full details of each secret, you can hover over it and press on the This opens up a side-drawer: ![project secrets drawer](../../images/platform/project/project-secrets-drawer.png) - diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 119a8cefa..545ed6b3b 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -11,10 +11,11 @@ This means that updating the value of a base secret propagates directly to other ![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 -must be permissioned access to all base and dependent secrets. +Since secret referencing reconstructs values on the client side, any client (user, service token, or machine identity) fetching secrets must have proper permissions to access all base and dependent secrets. Without sufficient permissions, secret references will not resolve to their appropriate values. -For example, to access some secret `A` whose values depend on secrets `B` and `C` from different scopes, a client must have `read` access to the scopes of secrets `A`, `B`, and `C`. +For example, if secret A references values from secrets B and C located in different scopes, the client must have read access to all three scopes containing secrets A, B, and C. If permission to any referenced secret is missing, the reference will remain unresolved, potentially causing application errors or unexpected behavior. + +This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references. ### Syntax @@ -28,11 +29,11 @@ Then consider the following scenarios: Here are a few more helpful examples for how to reference secrets in different contexts: -| Reference syntax | Environment | Folder | Secret Key | -| --------------------- | ----------- | ------------ | ---------- | -| `${KEY1}` | same env | same folder | KEY1 | -| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 | -| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 | +| Reference syntax | Environment | Folder | Secret Key | +| ----------------------- | ----------- | ----------------------------- | ---------- | +| `${KEY1}` | same env | same folder | KEY1 | +| `${dev.KEY2}` | `dev` | `/` (root of dev environment) | KEY2 | +| `${prod.frontend.KEY2}` | `prod` | `/frontend` | KEY2 | ## Secret Imports @@ -59,4 +60,12 @@ To reorder a secret import, hover over it and drag the arrows handle to the posi ![reorder secret import](../../images/platform/secret-references-imports/secret-import-reorder.png) - + diff --git a/docs/documentation/platform/secret-rotation/mssql.mdx b/docs/documentation/platform/secret-rotation/mssql.mdx index c34bb9034..8789e4152 100644 --- a/docs/documentation/platform/secret-rotation/mssql.mdx +++ b/docs/documentation/platform/secret-rotation/mssql.mdx @@ -1,139 +1,163 @@ --- -title: "Microsoft SQL Server" -description: "Learn how to automatically rotate Microsoft SQL Server user passwords." +title: "Microsoft SQL Server Credentials" +description: "Learn how to automatically rotate Microsoft SQL Server credentials." --- -The Infisical SQL Server secret rotation allows you to automatically rotate your database users' passwords at a predefined interval. - ## Prerequisites -1. Create two SQL Server logins and database users with the required permissions. We'll refer to them as `user-a` and `user-b`. -2. Create another SQL Server login with permissions to alter logins for `user-a` and `user-b`. We'll refer to this as the `admin` login. +1. Create a [Microsoft SQL Server Connection](/integrations/app-connections/mssql) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. -Here's how to set up the prerequisites: +An example creation statement might look like: + ```SQL + -- create server-level logins + CREATE LOGIN [infisical_user_1] WITH PASSWORD = 'my-password'; + CREATE LOGIN [infisical_user_2] WITH PASSWORD = 'my-password'; + GRANT CONNECT SQL TO [infisical_user_1]; + GRANT CONNECT SQL TO [infisical_user_2]; -```sql --- Create the logins (at server level) -CREATE LOGIN [user-a] WITH PASSWORD = 'ComplexPassword1'; -CREATE LOGIN [user-b] WITH PASSWORD = 'ComplexPassword2'; + -- create database-level users with login from above + USE my_database; + CREATE USER [infisical_user_1] FOR LOGIN [infisical_user_1]; + CREATE USER [infisical_user_2] FOR LOGIN [infisical_user_2]; --- Create database users for the logins (in your specific database) -USE [YourDatabase]; -CREATE USER [user-a] FOR LOGIN [user-a]; -CREATE USER [user-b] FOR LOGIN [user-b]; + -- grant relevant permissions + GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_user_1]; + GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [infisical_user_2]; + ``` --- Grant necessary permissions to the users -GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [user-a]; -GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [user-b]; + + To learn more about Microsoft SQL Server's permission system, please visit their [documentation](https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-transact-sql?view=sql-server-ver16). + --- Create admin login with permission to alter other logins -CREATE LOGIN [admin] WITH PASSWORD = 'AdminComplexPassword'; -CREATE USER [admin] FOR LOGIN [admin]; --- Grant permission to alter any login -GRANT ALTER ANY LOGIN TO [admin]; -``` +## Create a Microsoft SQL Server Credentials Rotation in Infisical -To learn more about SQL Server's permission system, please visit this [documentation](https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/getting-started-with-database-engine-permissions). + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) -## How it works + 2. Select the **Microsoft SQL Server Credentials** option. + ![Select Microsoft SQL Server Credentials](/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png) -1. Infisical connects to your database using the provided `admin` login credentials. -2. A random value is generated and the password for `user-a` is updated with the new value. -3. The new password is then tested by logging into the database. -4. If test is successful, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). -5. The process is then repeated for `user-b` on the next rotation. -6. The cycle repeats until secret rotation is deleted/stopped. + 3. Select the **Microsoft SQL Server Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png) -## Rotation Configuration + - **Microsoft SQL Server Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. - - - Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar - - - - - SQL Server admin username - + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png) - - SQL Server admin password - + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. - - SQL Server host url (e.g., your-server.database.windows.net) - + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png) - - Database port number (default: 1433) - + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. - - Database name (default: master) - + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png) - - The first login name to rotate - `user-a` - + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. - - The second login name to rotate - `user-b` - + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png) - - Optional database certificate to connect with database - + 8. Your **Microsoft SQL Server Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png) + + + To create a Microsoft SQL Server Credentials Rotation, make an API request to the [Create Microsoft SQL Server + Credentials Rotation](/api-reference/endpoints/secret-rotations/mssql-credentials/create) API endpoint. - - - When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + ### Sample request - - The environment where the rotated credentials should be mapped to. - + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/mssql-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mssql-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "MSSQL_DB_USERNAME", + "password": "MSSQL_DB_PASSWORD" + } + }' + ``` - - The secret path where the rotated credentials should be mapped to. - + ### Sample response - - What interval should the credentials be rotated in days. - - - - Select an existing secret key where the rotated database username value should be saved to. - - - - Select an existing select key where the rotated database password value should be saved to. - - - - - -## FAQ - - - - When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. - - This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. - - To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. - - - - The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. - - You don't need to grant all permissions for your admin account but rather just the permission to alter logins (ALTER ANY LOGIN). - - - - When using Azure SQL Database, you'll need to: - - 1. Use the full server name as your host (e.g., your-server.database.windows.net) - 2. Ensure your admin account is either the Azure SQL Server admin or an Azure AD account with appropriate permissions - 3. Configure your Azure SQL Server firewall rules to allow connections from Infisical's IP addresses - - + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-mssql-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "MSSQL_DB_USERNAME", + "password": "MSSQL_DB_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "mssql", + "name": "my-mssql-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "mssql-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/documentation/platform/secret-rotation/overview.mdx b/docs/documentation/platform/secret-rotation/overview.mdx index 57ad17e09..ee1fd9e42 100644 --- a/docs/documentation/platform/secret-rotation/overview.mdx +++ b/docs/documentation/platform/secret-rotation/overview.mdx @@ -6,44 +6,92 @@ description: "Learn how to set up automated secret rotation in Infisical." ## Introduction -Secret rotation is a process that involves updating secret credentials periodically to minimize the risk of their compromise. -Rotating secrets helps prevent unauthorized access to systems and sensitive data by ensuring that old credentials are replaced with new ones regularly. +Secret rotation is a security best practice that involves systematically updating credentials and access tokens at regular intervals to minimize the risk of compromise. By proactively replacing existing secrets with new ones, organizations reduce the potential impact of credential theft or leakage. -Rotated secrets may include, but are not limited to: +Examples of rotated secrets include: -1. API keys for external services; -2. Database credentials for various platforms. +- API keys and authentication tokens for cloud services and third-party integrations +- Database credentials across production, staging, and development environments -## Rotation Process +## How Rotation Works -The practice of rotating secrets is a systematic and interval-based operation, carried out in four fundamental phases. +Secret Rotation systematically replaces secrets at regular intervals while ensuring zero downtime for your applications. This overlapping lifecycle approach maintains continuous availability while enhancing your security posture. -### 1. Creation +### Visual Timeline -The system initiates the rotation process by either making an API call to an external service or generating a new secret value internally. -Upon successful creation, the system will temporarily have three versions of the secret: +```mermaid +gantt + title Credential Lifecycle (Interval = 30 days) + dateFormat YYYY-MM-DD + axisFormat %b %d -- **Current active secret**: The one currently in use. -- **Future active secret (pending)**: The newly created secret, awaiting validation. -- **Previous active secret**: The old secret, soon to be retired. + section Credentials 1 + Active :active, a1, 2023-01-01, 30d + Inactive :done, i1, after a1, 30d + Revoked :crit, r1, after i1, 30d -### 2. Testing + section Credentials 2 + Active :active, a2, 2023-01-31, 30d + Inactive :done, i2, after a2, 30d + Revoked :crit, r2, after i2, 30d -The newly generated secret is subjected to a verification process to ensure its validity and functionality. -This involves conducting checks or tests that simulate actual operations the secret would perform. -Only the current active and the future active (pending) secrets are considered operational at this stage, while the previous active secret remains in standby mode. + section Credentials 3 + Active :active, a3, 2023-03-02, 30d + Inactive :done, i3, after a3, 30d + Revoked :crit, r3, after i3, 30d +``` -### 3. Deletion +### Credential States -Post-verification, the system deactivates and deletes the previous active secret, leaving only the current and future active (pending) secrets in the system. +Each set of credentials transitions through three distinct states: -### 4. Activation +- **Active**: The primary credentials that will be used for new connections +- **Inactive**: These credentials are still valid but are no longer issued for new connections +- **Revoked**: Permanently invalidated and deleted from the system -Finally, the system promotes the future active (pending) secret to be the new current active secret. It then triggers necessary side effects, such as invoking webhooks and generating events, to notify other services of the change. +### Rotation Cycle Example (30-Day Interval) + +Using a __30-Day__ rotation interval as an example, here's how the process unfolds: + +1. __Day 0__ + - `Credential set 1` is issued and set to **Active** + - Applications begin using this set for authentication + +2. __Day 30__ + - `Credential set 2` is issued and set to **Active** + - `Credential set 1` transitions to **Inactive** but remains valid + - New connections utilize set 2 while existing connections with set 1 continue to work + + + This overlapping validity period ensures that at any point during the active period of a credential set, you are guaranteed that retrieved credentials will be valid for the specified rotation period. + + +3. __Day 60__ + - `Credential set 3` is issued and set to **Active** + - `Credential set 2` transitions to **Inactive** but remains valid + - `Credential set 1` is **Revoked** and securely deleted + - By now, all applications should have transitioned to using set 2 or 3 + +4. __Day 90__ + - `Credential set 4` is issued and set to **Active** + - `Credential set 3` transitions to **Inactive** but remains valid + - `Credential set 2` is **Revoked** and securely deleted + - The cycle continues... + +### Benefits of This Approach + +- **Zero Downtime**: Applications always have valid credentials +- **Grace Period**: The inactive period gives applications time to update to new credentials +- **Reduced Risk**: Credentials are regularly cycled, limiting the impact of potential compromise +- **Predictable Schedule**: Makes credential management more systematic and easier to automate + +### Implementation Considerations + +- Choose a rotation interval appropriate for your security requirements and operational needs +- Ensure your applications can handle credential updates gracefully +- Monitor for applications still using credentials nearing revocation ## Infisical Secret Rotation Strategies -1. [SendGrid Integration](./sendgrid) -2. [PostgreSQL/CockroachDB Implementation](./postgres) -3. [MySQL/MariaDB Configuration](./mysql) -4. [AWS IAM User](./aws-iam) +- [PostgreSQL Credentials](./postgres) +- [Microsoft SQL Server Credentials](./mssql) diff --git a/docs/documentation/platform/secret-rotation/postgres.mdx b/docs/documentation/platform/secret-rotation/postgres.mdx index 0a6339e4e..e0606e6ab 100644 --- a/docs/documentation/platform/secret-rotation/postgres.mdx +++ b/docs/documentation/platform/secret-rotation/postgres.mdx @@ -1,104 +1,160 @@ --- -title: "PostgreSQL/CockroachDB" -description: "Learn how to automatically rotate PostgreSQL/CockroachDB user passwords." +title: "PostgreSQL Credentials" +description: "Learn how to automatically rotate PostgreSQL credentials." --- -The Infisical Postgres secret rotation allows you to automatically rotate your Postgres database user's password at a predefined interval. +## Prerequisites + +1. Create a [PostgreSQL Connection](/integrations/app-connections/postgres) with the required **Secret Rotation** permissions +2. Create two designated database users for Infisical to rotate the credentials for. Be sure to grant each user login permissions for the desired database with the necessary privileges their use case will require. + + An example creation statement might look like: + ```SQL + -- create user roles + CREATE USER infisical_user_1 WITH ENCRYPTED PASSWORD 'temporary_password'; + CREATE USER infisical_user_2 WITH ENCRYPTED PASSWORD 'temporary_password'; + + -- grant database connection permissions + GRANT CONNECT ON DATABASE my_database TO infisical_user_1; + GRANT CONNECT ON DATABASE my_database TO infisical_user_2; + + -- grant relevant table permissions + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_user_1; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO infisical_user_2; + ``` + + + To learn more about PostgreSQL's permission system, please visit their [documentation](https://www.postgresql.org/docs/current/sql-grant.html). + -## Prerequisite +## Create a PostgreSQL Credentials Rotation in Infisical -1. Create two users with the required permission in your PostgreSQL instance. We'll refer to them as `user-a` and `user-b`. -2. Create another PostgreSQL user with just the permission to update the passwords of `user-a` and `user-b`. We'll refer to this user as the `admin` user. + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) -To learn more about Postgres permission system, please visit this [documentation](https://www.postgresql.org/docs/9.1/sql-grant.html). + 2. Select the **PostgreSQL Credentials** option. + ![Select PostgreSQL Credentials](/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png) + 3. Select the **PostgreSQL Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png) -## How it works + - **PostgreSQL Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. -1. Infisical connects to your database using the provided `admin` user account. -2. A random value is generated and the password for `user-a` is updated with the new value. -3. The new password is then tested by logging into the database -4. If test is success, it's saved to the output secret mappings so that rest of the system gets the newly rotated value(s). -5. The process is then repeated for `user-b` on the next rotation. -6. The cycle repeats until secret rotation is deleted/stopped. + 4. Input the usernames of the database users created above that will be used for rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png) -## Rotation Configuration + - **Database Username 1** - the username of the first user that will be used for rotation. + - **Database Username 2** - the username of the second user that will be used for rotation. - - - Head over to Secret Rotation configuration page of your project by clicking on `Secret Rotation` in the left side bar - - + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png) - - - Rotator admin username - + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. - - Rotator admin password - + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png) - - Database host url - + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. - - Database port number - + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png) - - The first username of two to rotate - `user-a` - + 8. Your **PostgreSQL Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png) + + + To create a PostgreSQL Credentials Rotation, make an API request to the [Create PostgreSQL + Credentials Rotation](/api-reference/endpoints/secret-rotations/postgres-credentials/create) API endpoint. - - The second username of two to rotate - `user-b` - - - - Optional database certificate to connect with database - - - + ### Sample request - When a secret rotation is successful, the updated values needs to be saved to an existing key(s) in your project. + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/postgres-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-pg-rotation", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "my database credentials rotation", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isAutoRotationEnabled": true, + "rotationInterval": 30, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + }, + "secretsMapping": { + "username": "POSTGRES_DB_USERNAME", + "password": "POSTGRES_DB_PASSWORD" + } + }' + ``` - - The environment where the rotated credentials should be mapped to. - + ### Sample response - - The secret path where the rotated credentials should be mapped to. - - - - What interval should the credentials be rotated in days. - - - - Select an existing secret key where the rotated database username value should be saved to. - - - - Select an existing select key where the rotated database password value should be saved to. - - - - -## FAQ - - - - When a system has multiple nodes by horizontal scaling, redeployment doesn't happen instantly. - - This means that when the secrets are rotated, and the redeployment is triggered, the existing system will still be using the old credentials until the change rolls out. - - To avoid causing failure for them, the old credentials are not removed. Instead, in the next rotation, the previous user's credentials are updated. - - - The admin account is used by Infisical to update the credentials for `user-a` and `user-b`. - - You don't need to grant all permission for your admin account but rather just the permissions to update both of the user's passwords. - - + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-rotation", + "description": "my database credentials rotation", + "secretsMapping": { + "username": "POSTGRES_DB_USERNAME", + "password": "POSTGRES_DB_PASSWORD" + }, + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "postgres", + "name": "my-pg-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "postgres-credentials", + "parameters": { + "username1": "infisical_user_1", + "username2": "infisical_user_2" + } + } + } + ``` + + diff --git a/docs/images/app-connections/mssql/create-username-and-password-method.png b/docs/images/app-connections/mssql/create-username-and-password-method.png new file mode 100644 index 000000000..c30423857 Binary files /dev/null and b/docs/images/app-connections/mssql/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/mssql/select-mssql-connection.png b/docs/images/app-connections/mssql/select-mssql-connection.png new file mode 100644 index 000000000..cc5f14ce4 Binary files /dev/null and b/docs/images/app-connections/mssql/select-mssql-connection.png differ diff --git a/docs/images/app-connections/mssql/username-and-password-connection.png b/docs/images/app-connections/mssql/username-and-password-connection.png new file mode 100644 index 000000000..b188f1f45 Binary files /dev/null and b/docs/images/app-connections/mssql/username-and-password-connection.png differ diff --git a/docs/images/app-connections/postgres/create-username-and-password-method.png b/docs/images/app-connections/postgres/create-username-and-password-method.png new file mode 100644 index 000000000..deecd87b6 Binary files /dev/null and b/docs/images/app-connections/postgres/create-username-and-password-method.png differ diff --git a/docs/images/app-connections/postgres/select-postgres-connection.png b/docs/images/app-connections/postgres/select-postgres-connection.png new file mode 100644 index 000000000..e6e7053b0 Binary files /dev/null and b/docs/images/app-connections/postgres/select-postgres-connection.png differ diff --git a/docs/images/app-connections/postgres/username-and-password-connection.png b/docs/images/app-connections/postgres/username-and-password-connection.png new file mode 100644 index 000000000..c31cd4758 Binary files /dev/null and b/docs/images/app-connections/postgres/username-and-password-connection.png differ diff --git a/docs/images/platform/admin-panels/access-org-admin-console.png b/docs/images/platform/admin-panels/access-org-admin-console.png index 057c82944..8d291d449 100644 Binary files a/docs/images/platform/admin-panels/access-org-admin-console.png and b/docs/images/platform/admin-panels/access-org-admin-console.png differ diff --git a/docs/images/platform/admin-panels/access-server-admin-panel.png b/docs/images/platform/admin-panels/access-server-admin-panel.png index a27735de0..706ba0814 100644 Binary files a/docs/images/platform/admin-panels/access-server-admin-panel.png and b/docs/images/platform/admin-panels/access-server-admin-panel.png differ diff --git a/docs/images/platform/project-access-requests/access-comment.png b/docs/images/platform/project-access-requests/access-comment.png new file mode 100644 index 000000000..c85a07bf1 Binary files /dev/null and b/docs/images/platform/project-access-requests/access-comment.png differ diff --git a/docs/images/platform/project-access-requests/all-project-view.png b/docs/images/platform/project-access-requests/all-project-view.png new file mode 100644 index 000000000..13c6b42a4 Binary files /dev/null and b/docs/images/platform/project-access-requests/all-project-view.png differ diff --git a/docs/images/platform/project-access-requests/request-access.png b/docs/images/platform/project-access-requests/request-access.png new file mode 100644 index 000000000..53f492ed4 Binary files /dev/null and b/docs/images/platform/project-access-requests/request-access.png differ diff --git a/docs/images/secret-rotations-v2/generic/add-secret-rotation.png b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png new file mode 100644 index 000000000..86b84001b Binary files /dev/null and b/docs/images/secret-rotations-v2/generic/add-secret-rotation.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png new file mode 100644 index 000000000..fe2cc7f58 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png new file mode 100644 index 000000000..3b0c1c3d0 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png new file mode 100644 index 000000000..22cfb6478 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png new file mode 100644 index 000000000..a19ea2aae Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png new file mode 100644 index 000000000..2778bad8e Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png new file mode 100644 index 000000000..4171dad75 Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/mssql-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png b/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png new file mode 100644 index 000000000..7a212edaa Binary files /dev/null and b/docs/images/secret-rotations-v2/mssql-credentials/select-mssql-credentials-option.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png new file mode 100644 index 000000000..c2d35686f Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png new file mode 100644 index 000000000..35806b0ea Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png new file mode 100644 index 000000000..efc733df8 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png new file mode 100644 index 000000000..35904a466 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png new file mode 100644 index 000000000..800d9d823 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png new file mode 100644 index 000000000..575e58abc Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/postgres-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png b/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png new file mode 100644 index 000000000..64b3055e9 Binary files /dev/null and b/docs/images/secret-rotations-v2/postgres-credentials/select-postgres-credentials-option.png differ diff --git a/docs/integrations/app-connections/mssql.mdx b/docs/integrations/app-connections/mssql.mdx new file mode 100644 index 000000000..45082103b --- /dev/null +++ b/docs/integrations/app-connections/mssql.mdx @@ -0,0 +1,134 @@ +--- +title: "Microsoft SQL Server Connection" +description: "Learn how to configure a Microsoft SQL Server Connection for Infisical." +--- + +Infisical supports connecting to Microsoft SQL Server using database principals. + +## Configure a Microsoft SQL Server Principal for Infisical + + + + Infisical recommends creating a designated server login and database user in your Microsoft SQL Server database for your connection. + ```SQL + -- Create login at the server level + CREATE LOGIN [infisical_app] WITH PASSWORD = 'my-password'; + + -- Grant server-level connect permission + GRANT CONNECT SQL TO [infisical_app]; + + -- If you intend to use Platform Managed Credentials (see below) + GRANT ALTER ANY LOGIN TO [infisical_app]; + + -- Switch to the specific database where you want to create the user + USE my_database; + + -- Create the database user mapped to the login + CREATE USER [infisical_app] FOR LOGIN [infisical_app]; + ``` + + + Depending on how you intend to use your Microsoft SQL Server connection, you'll need to grant one or more of the following permissions. + + + To learn more about Microsoft SQL Server's permission system, please visit their [documentation](https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-transact-sql?view=sql-server-ver16). + + + + + For Secret Rotations, your Infisical user will require the ability to alter other logins' passwords: + ```SQL + GRANT ALTER ANY LOGIN TO infisical_login; + ``` + + + + + You'll need the following information to create your Microsoft SQL Server connection: + - `host` - The hostname or IP address of your Microsoft SQL Server server + - `port` - The port number your Microsoft SQL Server server is listening on (default: 1433) + - `database` - The name of the specific database you want to connect to + - `username` - The username of the login created in the steps above + - `password` - The password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **Microsoft SQL Server Connection** option. + ![Select Microsoft SQL Server Connection](/images/app-connections/mssql/select-mssql-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to Microsoft SQL Server**. + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + ![Create Microsoft SQL Server Connection](/images/app-connections/mssql/create-username-and-password-method.png) + + 4. Your **Microsoft SQL Server Connection** is now available for use. + ![Assume Role Microsoft SQL Server Connection](/images/app-connections/mssql/username-and-password-connection.png) + + + To create a Microsoft SQL Server Connection, make an API request to the [Create Microsoft SQL Server + Connection](/api-reference/endpoints/app-connections/mssql/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/mssql \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-mssql-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1433, + "database": "default", + "username": "infisical_login", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "mssql", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 1433, + "database": "default", + "username": "infisical_login", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 64f3616de..92e6ab9d2 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -74,4 +74,9 @@ in the UI or by passing the associated `connectionId` when generating resources Infisical is continuously expanding its third-party application support. If your desired application isn't listed, you can still use previous methods of connecting to it such as our Native Integrations. - \ No newline at end of file + + +## Platform Managed Credentials + +Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option, +Infisical will modify the credentials to prevent external use of the configured access entity. \ No newline at end of file diff --git a/docs/integrations/app-connections/postgres.mdx b/docs/integrations/app-connections/postgres.mdx new file mode 100644 index 000000000..523fc35a8 --- /dev/null +++ b/docs/integrations/app-connections/postgres.mdx @@ -0,0 +1,124 @@ +--- +title: "PostgreSQL Connection" +description: "Learn how to configure a PostgreSQL Connection for Infisical." +--- + +Infisical supports connecting to PostgreSQL using a database role. + +## Configure a PostgreSQL Role for Infisical + + + + Infisical recommends creating a designated role in your PostgreSQL database for your connection. + ```SQL + -- create user role + CREATE ROLE infisical_role WITH LOGIN PASSWORD 'my-password'; + + -- grant login access to the specified database + GRANT CONNECT ON DATABASE my_database TO infisical_role; + ``` + + + Depending on how you intend to use your PostgreSQL connection, you'll need to grant one or more of the following permissions. + + To learn more about PostgreSQL's permission system, please visit their [documentation](https://www.postgresql.org/docs/current/sql-grant.html). + + + + For Secret Rotations, your Infisical user will require the ability to alter other users' passwords: + ```SQL + -- enable permissions to alter login credentials + ALTER ROLE infisical_role WITH CREATEROLE; + ``` + + + + + You'll need the following information to create your PostgreSQL connection: + - `host` - The hostname or IP address of your PostgreSQL server + - `port` - The port number your PostgreSQL server is listening on (default: 5432) + - `database` - The name of the specific database you want to connect to + - `username` - The role name of the login created in the steps above + - `password` - The role password of the login created in the steps above + - `sslCertificate` (optional) - The SSL certificate required for connection (if configured) + + + +## Create Connection in Infisical + + + + 1. Navigate to the App Connections tab on the Organization Settings page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + 2. Select the **PostgreSQL Connection** option. + ![Select PostgreSQL Connection](/images/app-connections/postgres/select-postgres-connection.png) + + 3. Select the **Username & Password** method option and provide the details obtained from the previous section and press **Connect to PostgreSQL**. + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can enable the Platform Managed Credentials option. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + ![Create PostgreSQL Connection](/images/app-connections/postgres/create-username-and-password-method.png) + + 4. Your **PostgreSQL Connection** is now available for use. + ![Assume Role PostgreSQL Connection](/images/app-connections/postgres/username-and-password-connection.png) + + + To create a PostgreSQL Connection, make an API request to the [Create PostgreSQL + Connection](/api-reference/endpoints/app-connections/postgres/create) API endpoint. + + + Optionally, if you'd like Infisical to manage the credentials of this connection, you can set the `isPlatformManagedCredentials` option to `true`. + If enabled, Infisical will update the password of the connection on creation to prevent external access to this database role. + + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/postgres \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-pg-connection", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 5432, + "database": "default", + "username": "infisical_role", + "password": "my-password", + "sslEnabled": true, + "sslRejectUnauthorized": true + }, + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-pg-connection", + "version": 1, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "app": "postgres", + "method": "username-and-password", + "isPlatformManagedCredentials": true, + "credentials": { + "host": "123.4.5.6", + "port": 5432, + "database": "default", + "username": "infisical_role", + "sslEnabled": true, + "sslRejectUnauthorized": true + } + } + } + ``` + + diff --git a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx index 82e60af7f..21f54994a 100644 --- a/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd.mdx @@ -264,6 +264,7 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws - `credentialsRef.secretName`: The name of the Kubernetes secret. - `credentialsRef.secretNamespace`: The namespace of the Kubernetes secret. + Example: ```yaml @@ -296,6 +297,9 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws - `serviceAccountRef`: The name and namespace of the service account that will be used to authenticate with Infisical. - `serviceAccountRef.name`: The name of the service account. - `serviceAccountRef.namespace`: The namespace of the service account. + - `autoCreateServiceAccountToken`: If set to `true`, the operator will automatically create a short-lived service account token on-demand for the service account. Defaults to `false`. + - `serviceAccountTokenAudiences`: Optionally specify audience for the service account token. This field is only relevant if you have set `autoCreateServiceAccountToken` to `true`. No audience is specified by default. + Example: @@ -303,6 +307,9 @@ The available authentication methods are `universalAuth`, `kubernetesAuth`, `aws spec: kubernetesAuth: identityId: + autoCreateServiceAccountToken: true # Automatically creates short-lived service account tokens for the service account. + serviceAccountTokenAudiences: + - # Optionally specify audience for the service account token. No audience is specified by default. serviceAccountRef: name: namespace: diff --git a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx index 0664f0cd8..50f07bb76 100644 --- a/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-push-secret-crd.mdx @@ -291,6 +291,8 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y - `serviceAccountRef`: The name and namespace of the service account that will be used to authenticate with Infisical. - `serviceAccountRef.name`: The name of the service account. - `serviceAccountRef.namespace`: The namespace of the service account. + - `autoCreateServiceAccountToken`: If set to `true`, the operator will automatically create a short-lived service account token on-demand for the service account. Defaults to `false`. + - `serviceAccountTokenAudiences`: Optionally specify audience for the service account token. This field is only relevant if you have set `autoCreateServiceAccountToken` to `true`. No audience is specified by default. Example: @@ -298,6 +300,9 @@ After applying the InfisicalPushSecret CRD, you should notice that the secrets y spec: kubernetesAuth: identityId: + autoCreateServiceAccountToken: true # Automatically creates short-lived service account tokens for the service account. + serviceAccountTokenAudiences: + - # Optionally specify audience for the service account token. No audience is specified by default. serviceAccountRef: name: namespace: diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index f23eb010d..4c33b893b 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -156,157 +156,420 @@ 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. + + + Short-lived service account tokens are automatically created by the operator and are valid only for a short period of time. This is the recommended approach for using Kubernetes auth in the Infisical Secrets Operator. - ```yaml infisical-service-account.yaml - apiVersion: v1 - kind: ServiceAccount - metadata: - name: infisical-auth - namespace: default + + + **1.1.** Start by creating a reviewer service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. - ``` + ```yaml infisical-reviewer-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-reviewer + 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: + ```bash + kubectl apply -f infisical-reviewer-service-account.yaml + ``` - ```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 - ``` + **1.2.** Bind the reviewer 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: - ``` - kubectl apply -f cluster-role-binding.yaml - ``` + ```yaml infisical-reviewer-cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-token-reviewer-role-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-token-reviewer + namespace: default + ``` - 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: + ```bash + kubectl apply -f infisical-reviewer-cluster-role-binding.yaml + ``` - ```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" - ``` + **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-reviewer-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-reviewer-token + annotations: + kubernetes.io/service-account.name: "infisical-token-reviewer" + ``` - ``` - kubectl apply -f service-account-token.yaml - ``` + ```bash + kubectl apply -f service-account-reviewer-token.yaml + ``` - 1.4. Link the secret in step 1.3 to the service account in step 1.1: + **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 - ``` + ```bash + kubectl patch serviceaccount infisical-token-reviewer -p '{"secrets": [{"name": "infisical-token-reviewer-token"}]}' -n default + ``` - 1.5. Finally, retrieve the token reviewer JWT token from the secret. + **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 - ``` + ```bash + kubectl get secret infisical-token-reviewer-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. + 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**. - - 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) - ![identities organization](/images/platform/identities/identities-org.png) + When creating an identity, you specify an organization level [role](/documentation/platform/role-based-access-controls) for it to assume; you can configure roles in Organization Settings > Access Control > Organization Roles. - When creating an identity, you specify an organization level [role](/documentation/platform/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) - ![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: - 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. - - 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**. - 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). + - - 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) + ![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 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**. + 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. + 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](/images/platform/identities/identities-project.png) - ![identities project create](/images/platform/identities/identities-project-create.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. - + - + + You have already created the reviewer service account in step **1.1**. Now, create a new Kubernetes service account that will be used to authenticate with Infisical. + This service account will create short-lived tokens that will be used to authenticate with Infisical. The operator itself will handle the creation of these tokens automatically. - - 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. - + ```yaml infisical-service-account.yaml + kind: ServiceAccount + apiVersion: v1 + metadata: + name: infisical-service-account + ``` -## Example + ```bash + kubectl apply -f infisical-service-account.yaml -n default + ``` -```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 - ... -``` + + 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 you set `authentication.kubernetesAuth.autoCreateServiceAccountToken` to `true` to automatically create short-lived service account tokens for the service account. + + + + + 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: + autoCreateServiceAccountToken: true # Automatically creates short-lived service account tokens for the service account. + serviceAccountTokenAudiences: + - # Optionally specify audience for the service account token. No audience is specified by default. + serviceAccountRef: + name: infisical-service-account # The service account we just created in the previous step. + 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 + ... + ``` + + + + Manual long-lived service account tokens are manually created by the user and are valid indefinitely unless deleted or rotated. In most cases, you should be using the automatic short-lived service account tokens as they are more secure and easier to use. + + + **1.1.** Start by creating a reviewer service account in your Kubernetes cluster that will be used by Infisical to authenticate with the Kubernetes API Server. + + ```yaml infisical-reviewer-service-account.yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: infisical-token-reviewer + namespace: default + + ``` + + ```bash + kubectl apply -f infisical-reviewer-service-account.yaml + ``` + + **1.2.** Bind the reviewer 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 infisical-reviewer-cluster-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-token-reviewer-role-binding + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-token-reviewer + namespace: default + ``` + + ```bash + kubectl apply -f infisical-reviewer-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-reviewer-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-token-reviewer-token + annotations: + kubernetes.io/service-account.name: "infisical-token-reviewer" + ``` + + + ```bash + kubectl apply -f service-account-reviewer-token.yaml + ``` + + **1.4.** Link the secret in step 1.3 to the service account in step 1.1: + + ```bash + kubectl patch serviceaccount infisical-token-reviewer -p '{"secrets": [{"name": "infisical-token-reviewer-token"}]}' -n default + ``` + + **1.5.** Finally, retrieve the token reviewer JWT token from the secret. + + ```bash + kubectl get secret infisical-token-reviewer-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) + + + + + You have already created the reviewer service account in step **1.1**. Now, create a new Kubernetes service account that will be used to authenticate with Infisical. + + ```yaml infisical-service-account.yaml + kind: ServiceAccount + apiVersion: v1 + metadata: + name: infisical-service-account + ``` + + ```bash + kubectl apply -f infisical-service-account.yaml -n default + ``` + + + + Create a service account token for the newly created Kubernetes service account from the previous step. + + ```yaml infisical-service-account-token.yaml + apiVersion: v1 + kind: Secret + type: kubernetes.io/service-account-token + metadata: + name: infisical-service-account-token + annotations: + kubernetes.io/service-account.name: "infisical-service-account" + ``` + + ```bash + kubectl apply -f infisical-service-account-token.yaml -n default + ``` + + Patch the service account with the newly created service account token. + + ```bash + kubectl patch serviceaccount infisical-service-account -p '{"secrets": [{"name": "infisical-service-account-token"}]}' -n default + ``` + + + + 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: infisical-service-account # The service account we just created in the previous step. (*not* the reviewer service account) + 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 + ... + ``` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 553b9d305..4e0c592cb 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -187,12 +187,15 @@ Supports conditions and permission inversion #### Subject: `secret-rotation` -| Action | Description | -| -------- | ------------------------------------- | -| `read` | View secret rotation policies | -| `create` | Set up automatic secret rotation | -| `edit` | Modify rotation schedules or policies | -| `delete` | Remove rotation policies | +Supports conditions and permission inversion +| Action | Description | +| ------------------------------ | ---------------------------------------------- | +| `read` | View secret rotation configurations | +| `read-generated-credentials` | View the generated credentials of a rotation | +| `create` | Set up secret rotation configurations | +| `edit` | Modify secret rotation configurations | +| `rotate-secrets` | Rotate the generated credentials of a rotation | +| `delete` | Remove secret rotation configurations | #### Subject: `secret-syncs` diff --git a/docs/mint.json b/docs/mint.json index b455967ba..923ca8a08 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -161,6 +161,7 @@ "documentation/platform/access-controls/additional-privileges", "documentation/platform/access-controls/temporary-access", "documentation/platform/access-controls/access-requests", + "documentation/platform/access-controls/project-access-requests", "documentation/platform/pr-workflows", "documentation/platform/groups" ] @@ -177,11 +178,8 @@ "group": "Secret Rotation", "pages": [ "documentation/platform/secret-rotation/overview", - "documentation/platform/secret-rotation/sendgrid", "documentation/platform/secret-rotation/postgres", - "documentation/platform/secret-rotation/mysql", - "documentation/platform/secret-rotation/mssql", - "documentation/platform/secret-rotation/aws-iam" + "documentation/platform/secret-rotation/mssql" ] }, { @@ -210,7 +208,10 @@ }, { "group": "Gateway", - "pages": ["documentation/platform/gateways/overview", "documentation/platform/gateways/gateway-security"] + "pages": [ + "documentation/platform/gateways/overview", + "documentation/platform/gateways/gateway-security" + ] }, "documentation/platform/project-templates", { @@ -419,7 +420,9 @@ "integrations/app-connections/databricks", "integrations/app-connections/gcp", "integrations/app-connections/github", - "integrations/app-connections/humanitec" + "integrations/app-connections/humanitec", + "integrations/app-connections/mssql", + "integrations/app-connections/postgres" ] } ] @@ -579,7 +582,8 @@ "api-reference/endpoints/identities/update", "api-reference/endpoints/identities/delete", "api-reference/endpoints/identities/get-by-id", - "api-reference/endpoints/identities/list" + "api-reference/endpoints/identities/list", + "api-reference/endpoints/identities/search" ] }, { @@ -823,6 +827,39 @@ "api-reference/endpoints/secret-imports/delete" ] }, + { + "group": "Secret Rotations", + "pages": [ + "api-reference/endpoints/secret-rotations/list", + "api-reference/endpoints/secret-rotations/options", + { + "group": "Microsoft SQL Server Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/mssql-credentials/create", + "api-reference/endpoints/secret-rotations/mssql-credentials/delete", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/mssql-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/mssql-credentials/list", + "api-reference/endpoints/secret-rotations/mssql-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/mssql-credentials/update" + ] + }, + { + "group": "PostgreSQL Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/postgres-credentials/create", + "api-reference/endpoints/secret-rotations/postgres-credentials/delete", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/postgres-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/postgres-credentials/list", + "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/postgres-credentials/update" + ] + } + ] + }, { "group": "Identity Specific Privilege", "pages": [ @@ -922,6 +959,30 @@ "api-reference/endpoints/app-connections/humanitec/update", "api-reference/endpoints/app-connections/humanitec/delete" ] + }, + { + "group": "Microsoft SQL Server", + "pages": [ + "api-reference/endpoints/app-connections/mssql/list", + "api-reference/endpoints/app-connections/mssql/available", + "api-reference/endpoints/app-connections/mssql/get-by-id", + "api-reference/endpoints/app-connections/mssql/get-by-name", + "api-reference/endpoints/app-connections/mssql/create", + "api-reference/endpoints/app-connections/mssql/update", + "api-reference/endpoints/app-connections/mssql/delete" + ] + }, + { + "group": "PostgreSQL", + "pages": [ + "api-reference/endpoints/app-connections/postgres/list", + "api-reference/endpoints/app-connections/postgres/available", + "api-reference/endpoints/app-connections/postgres/get-by-id", + "api-reference/endpoints/app-connections/postgres/get-by-name", + "api-reference/endpoints/app-connections/postgres/create", + "api-reference/endpoints/app-connections/postgres/update", + "api-reference/endpoints/app-connections/postgres/delete" + ] } ] }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1fa72b81a..abf46cadb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -66,6 +67,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", @@ -1570,6 +1572,260 @@ } } }, + "node_modules/@lexical/clipboard": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.29.0.tgz", + "integrity": "sha512-llxZosYCwH13p2GfPfhAinukdvAZYxWuwf5md107X80hsE8TQJj25unjqTwRKQ+w/wD+hpmBMziU8+K/WTitWQ==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/code": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.29.0.tgz", + "integrity": "sha512-yKGzoKpyIO39Xf7OKLPpoCE5V8mTDCM3l3CDHZR3X1gM/VZQzf4jAiO3b06y9YkQ2fM8kqwchYu87wGvs8/iIQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0", + "prismjs": "^1.30.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.29.0.tgz", + "integrity": "sha512-uUq0m9ql/7mthp7Ho1vnG7Id6imQ5kD5mxUhX2lmgHretS+yAHGsGsGiPIVHdPWeVmUb2n4IVDJ+cJbUsUjQJw==", + "license": "MIT", + "dependencies": { + "@lexical/html": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.29.0.tgz", + "integrity": "sha512-Zaky2jd/Pp1blAZqPeGNdyhxnVL4lwVjbWPxhfS1gbW4Q5CBQ3aD3B0T4ljiKfmRNJm004LJ9q7KjhlRbREvZA==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.29.0.tgz", + "integrity": "sha512-fa7s0Yi2RKz/GvgT5XU9fborx6VPU3VtvvEPaIXgyd6zXZRiOhD9rGypwB3oj4fMK1ndx2dX0m7SwhMJo48D8w==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.29.0.tgz", + "integrity": "sha512-OrCwZycp/yaq63mw511NutkwAB+W6WSchG1xTxlLh6nbc8jnbvKhCf4CGbnrvlhD7hTuzxJ8FI9/2M/2zv/mNQ==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.29.0.tgz", + "integrity": "sha512-+jV6ijppOpxpUGeXkGssXJbsAmFALfeLrgbM0xuZbxZ7RgYZ+5Atn00WjSno7+JV5EOuRkYmCNtS1tiHtXMY1g==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.29.0.tgz", + "integrity": "sha512-wGbKRF0x/6ZQHuCfr8m8qD1J0R1kFmWINBG2A1hUXPDf7UY5qm/nS2oKNDGpjiDMGwkVZ7n7WfzeBGO+KRe/Lg==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.29.0.tgz", + "integrity": "sha512-sWiof+i2ff8rL7KxJ3dxHLwyJfX423e1EVLmAdQEOPhyZJiNbeLTSNhNGsZ8FjFoBwvTTEDwuQZm3iT3hliKOg==", + "license": "MIT", + "dependencies": { + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.29.0.tgz", + "integrity": "sha512-UB3x6pyUdpZHRqF4tiajLnC1+Umvt7x8Rkkdi29aNNvzIWniVwGkBOlmvFus7x+4dOV1D1fydwiP4m38nGgLDw==", + "license": "MIT", + "dependencies": { + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.29.0.tgz", + "integrity": "sha512-4Od8WoDoviv9DxJZVgrIORTIAzyoGOpztbGbIBXguGmwvy7NnHQDh9fZYIYRrdI1Awp1VVGdJ3ku/7KTgSOoRw==", + "license": "MIT", + "dependencies": { + "@lexical/code": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/offset": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.29.0.tgz", + "integrity": "sha512-VyD2Ff3rBJpo++Fxvi3MNYmDELa+9nA0EgXqGRNb3MvRehRjHbaDbymtLMMHIwvbkF5lnra+ubStcTRQmoQxXw==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.29.0.tgz", + "integrity": "sha512-IzH3M652Ej2gB2sK65N3yTgyiQAa3I3tqKbSnBRiXu/+isxHoCy/qRr9/kL63uy7zhGvgV+EYsoffQCawIFt8Q==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.29.0.tgz", + "integrity": "sha512-F5C3meDb2HmO0NmKJBVRkjmX9PNln6O1jXU/APJuSFBdvfcIWSY58ncHR4zy2M5LF1Q5PQMWyIay9p+SqOtY5A==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.29.0.tgz", + "integrity": "sha512-YMlnljW/jxmwSzsRv5UPatfOoMZXqxFmRIEltTUIQfrOFdqn+ssUtCpjE6xRD1oxD6KpSIekakzLs+y/8+7CuQ==", + "license": "MIT", + "dependencies": { + "@lexical/devtools-core": "0.29.0", + "@lexical/dragon": "0.29.0", + "@lexical/hashtag": "0.29.0", + "@lexical/history": "0.29.0", + "@lexical/link": "0.29.0", + "@lexical/list": "0.29.0", + "@lexical/mark": "0.29.0", + "@lexical/markdown": "0.29.0", + "@lexical/overflow": "0.29.0", + "@lexical/plain-text": "0.29.0", + "@lexical/rich-text": "0.29.0", + "@lexical/table": "0.29.0", + "@lexical/text": "0.29.0", + "@lexical/utils": "0.29.0", + "@lexical/yjs": "0.29.0", + "lexical": "0.29.0", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.29.0.tgz", + "integrity": "sha512-fSKgXGxJUOWo7dwSTUYFVBNNk4pPN8norsZfdmKM1kGDS1/GKuVzlzHLKZ7rQb8RLD5a43p4ifEL+28P+q0Qqg==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.29.0.tgz", + "integrity": "sha512-lX9CRrXgKte65cozTHFXwUJ2fvZD92OEtos+YU+U40GJjf3NdheGeKDxDfOpF4AXrYRSszY7E0CzmIvuEs0p4A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.29.0.tgz", + "integrity": "sha512-Jdj32kBDeJh/0dGaZB14JggnEIS956/cN7grnLr7cmhhVzDicvLMBENSXQVEJAQVcSIU4G9EvxC7GJZ9VgqDnA==", + "license": "MIT", + "dependencies": { + "@lexical/clipboard": "0.29.0", + "@lexical/utils": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.29.0.tgz", + "integrity": "sha512-QnNGr6ickTLk76o3PdxJjPwt//dpuh8idVfR73WdCIoAwkhiEPUxxTZERoMsudXj6O/lJ+/HhI61wVjLckYr3A==", + "license": "MIT", + "dependencies": { + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.29.0.tgz", + "integrity": "sha512-y2hhWQDjcXdplsAaQMuZx6ht9u1I4BV5NynA+WKoQ3h8vKxzeDnpCxVOK/zxU1R5dhM/nilnFu7uhvrSeEn+TQ==", + "license": "MIT", + "dependencies": { + "@lexical/list": "0.29.0", + "@lexical/selection": "0.29.0", + "@lexical/table": "0.29.0", + "lexical": "0.29.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.29.0.tgz", + "integrity": "sha512-6IXWWlGkVJEzWP/+LcuKYJ9jmcFp8k7TT/jmz4V5gBD9Ut3swOGsIA/sQCtB9y7jad10csaDVmFdFzGNWKVH9A==", + "license": "MIT", + "dependencies": { + "@lexical/offset": "0.29.0", + "@lexical/selection": "0.29.0", + "lexical": "0.29.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@lottiefiles/dotlottie-react": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.12.0.tgz", @@ -8871,6 +9127,17 @@ "node": ">=10" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/iterator.prototype": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.4.tgz", @@ -9100,6 +9367,34 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.29.0.tgz", + "integrity": "sha512-eoBHUEn0LmExKeK6x2cFKU0FPaMk2Bc5HgiCzTiv5ymKtwWw7LeKcxaNPmLxRRdQpcWV1IMKjayAbw7Lt/Gu7w==", + "license": "MIT" + }, + "node_modules/lib0": { + "version": "0.2.102", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.102.tgz", + "integrity": "sha512-g70kydI0I1sZU0ChO8mBbhw0oUW/8U0GHzygpvEIx8k+jgOpqnTSb/E+70toYVqHxBhrERD21TwD5QcZJQ40ZQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -10857,6 +11152,15 @@ } } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -11142,6 +11446,22 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -13587,9 +13907,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.16.tgz", + "integrity": "sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==", "dev": true, "license": "MIT", "dependencies": { @@ -14131,6 +14451,24 @@ "node": ">=8" } }, + "node_modules/yjs": { + "version": "13.6.24", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.24.tgz", + "integrity": "sha512-xn/pYLTZa3uD1uDG8lpxfLRo5SR/rp0frdASOl2a71aYNvUXdWcLtVL91s2y7j+Q8ppmjZ9H3jsGVgoFMbT2VA==", + "license": "MIT", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 95ad59c7d..e750783b2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@hcaptcha/react-hcaptcha": "^1.11.0", "@headlessui/react": "^1.7.19", "@hookform/resolvers": "^3.9.1", + "@lexical/react": "^0.29.0", "@lottiefiles/dotlottie-react": "^0.12.0", "@octokit/rest": "^21.0.2", "@peculiar/x509": "^1.12.3", @@ -70,6 +71,7 @@ "jspdf": "^2.5.2", "jsrp": "^0.2.4", "jwt-decode": "^4.0.0", + "lexical": "^0.29.0", "ms": "^2.1.3", "nprogress": "^0.2.0", "picomatch": "^4.0.2", diff --git a/frontend/public/images/integrations/MsSql.png b/frontend/public/images/integrations/MsSql.png new file mode 100644 index 000000000..108ed60f9 Binary files /dev/null and b/frontend/public/images/integrations/MsSql.png differ diff --git a/frontend/public/images/integrations/MySql.png b/frontend/public/images/integrations/MySql.png new file mode 100644 index 000000000..d92befdbc Binary files /dev/null and b/frontend/public/images/integrations/MySql.png differ diff --git a/frontend/public/images/integrations/Postgres.png b/frontend/public/images/integrations/Postgres.png new file mode 100644 index 000000000..b7152860d Binary files /dev/null and b/frontend/public/images/integrations/Postgres.png differ diff --git a/frontend/public/images/integrations/SendGrid.png b/frontend/public/images/integrations/SendGrid.png new file mode 100644 index 000000000..3d2c9a92d Binary files /dev/null and b/frontend/public/images/integrations/SendGrid.png differ diff --git a/frontend/public/images/secretRotation/secret-rotations-v2-location.png b/frontend/public/images/secretRotation/secret-rotations-v2-location.png new file mode 100644 index 000000000..6c0e7d8f1 Binary files /dev/null and b/frontend/public/images/secretRotation/secret-rotations-v2-location.png differ diff --git a/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx new file mode 100644 index 000000000..8a824d101 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; + +import { SecretRotationV2Form } from "@app/components/secret-rotations-v2/forms"; +import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader"; +import { SecretRotationV2Select } from "@app/components/secret-rotations-v2/SecretRotationV2Select"; +import { Modal, ModalContent } from "@app/components/v2"; +import { SecretRotation, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +type SharedProps = { + secretPath: string; + environment?: string; + environments?: WorkspaceEnv[]; +}; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} & SharedProps; + +type ContentProps = { + onComplete: (secretRotation: TSecretRotationV2) => void; + selectedRotation: SecretRotation | null; + setSelectedRotation: (selectedRotation: SecretRotation | null) => void; +} & SharedProps; + +const Content = ({ setSelectedRotation, selectedRotation, ...props }: ContentProps) => { + if (selectedRotation) { + return ( + setSelectedRotation(null)} + type={selectedRotation} + {...props} + /> + ); + } + + return ; +}; + +export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: Props) => { + const [selectedRotation, setSelectedRotation] = useState(null); + + return ( + { + if (!open) setSelectedRotation(null); + onOpenChange(open); + }} + > + + ) : ( + "Add Secret Rotation" + ) + } + onPointerDownOutside={(e) => e.preventDefault()} + className={selectedRotation ? "max-w-2xl" : "max-w-3xl"} + subTitle={ + selectedRotation ? undefined : "Select a provider to create a secret rotation for." + } + bodyClassName="overflow-visible" + > + { + setSelectedRotation(null); + onOpenChange(false); + }} + selectedRotation={selectedRotation} + setSelectedRotation={setSelectedRotation} + {...props} + /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx new file mode 100644 index 000000000..16f9a67d4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/DeleteSecretRotationV2Modal.tsx @@ -0,0 +1,102 @@ +import { useEffect, useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal, Switch } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { useDeleteSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeleteSecretRotationV2Modal = ({ + isOpen, + onOpenChange, + secretRotation, + onComplete +}: Props) => { + const deleteSecretRotation = useDeleteSecretRotationV2(); + const [revokeGeneratedCredentials, setRevokeGeneratedCredentials] = useState(false); + const [deleteSecrets, setDeleteSecrets] = useState(false); + + useEffect(() => { + if (!isOpen) { + setRevokeGeneratedCredentials(false); + setDeleteSecrets(false); + } + }, [isOpen]); + + if (!secretRotation) return null; + + const { id: rotationId, name, type, projectId, folder } = secretRotation; + + const handleDeleteSecretRotation = async () => { + const rotationType = SECRET_ROTATION_MAP[type].name; + + try { + await deleteSecretRotation.mutateAsync({ + rotationId, + type, + revokeGeneratedCredentials, + deleteSecrets, + projectId, + secretPath: folder.path + }); + + createNotification({ + text: `Successfully deleted ${rotationType} Rotation`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch { + createNotification({ + text: `Failed to delete ${rotationType} Rotation`, + type: "error" + }); + } + }; + + return ( + + + Revoke Credentials + +

+ Generated credentials will {revokeGeneratedCredentials ? "" : "not"} be revoked on deletion + {revokeGeneratedCredentials ? "" : " and remain active"}. +

+ + Delete Secrets + +

+ Rotation secrets will {deleteSecrets ? "" : "not"} be removed from your project on deletion. +

+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx new file mode 100644 index 000000000..e9efc27f2 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/EditSecretRotationV2Modal.tsx @@ -0,0 +1,34 @@ +import { SecretRotationV2ModalHeader } from "@app/components/secret-rotations-v2/SecretRotationV2ModalHeader"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationV2Form } from "./forms"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + secretRotation?: TSecretRotationV2; +}; + +export const EditSecretRotationV2Modal = ({ secretRotation, onOpenChange, ...props }: Props) => { + if (!secretRotation) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + secretRotation={secretRotation} + type={secretRotation.type} + secretPath={secretRotation.folder.path} + environment={secretRotation.environment.slug} + /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx new file mode 100644 index 000000000..e2c931d49 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/RotateSecretRotationV2Modal.tsx @@ -0,0 +1,88 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; +import { useRotateSecretRotationV2 } from "@app/hooks/api/secretRotationsV2/mutations"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretRotation: TSecretRotationV2; + onComplete: () => void; +}; + +const Content = ({ secretRotation, onComplete }: ContentProps) => { + const rotateSecrets = useRotateSecretRotationV2(); + + const { id: rotationId, type, projectId, folder } = secretRotation; + const rotationType = SECRET_ROTATION_MAP[type].name; + + const handleRotateSecrets = async () => { + try { + await rotateSecrets.mutateAsync({ + rotationId, + type, + projectId, + secretPath: folder.path + }); + + createNotification({ + text: `Successfully rotated ${rotationType} secrets`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to rotate ${rotationType} secrets`, + type: "error" + }); + } + }; + + return ( +
+

+ Are you sure you want to rotate the secrets for this {rotationType} Rotation? +

+
+ + + + +
+
+ ); +}; + +export const RotateSecretRotationV2Modal = ({ isOpen, onOpenChange, secretRotation }: Props) => { + if (!secretRotation) return null; + + const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx new file mode 100644 index 000000000..319607d70 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2ModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + type: SecretRotation; + isConfigured: boolean; +}; + +export const SecretRotationV2ModalHeader = ({ type, isConfigured }: Props) => { + const destinationDetails = SECRET_ROTATION_MAP[type]; + + return ( +
+ {`${destinationDetails.name} +
+
+ {destinationDetails.name} Rotation + +
+ + Docs + +
+
+
+

+ {isConfigured + ? `Edit ${destinationDetails.name} Rotation` + : `Rotate ${destinationDetails.name}`} +

+
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx new file mode 100644 index 000000000..c464279ed --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2Select.tsx @@ -0,0 +1,98 @@ +import { faWrench } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Spinner, Tooltip } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { SecretRotation, useSecretRotationV2Options } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + onSelect: (type: SecretRotation) => void; +}; + +export const SecretRotationV2Select = ({ onSelect }: Props) => { + const { isPending, data: secretRotationOptions } = useSecretRotationV2Options(); + + if (isPending) { + return ( +
+ +

Loading options...

+
+ ); + } + + return ( +
+ {secretRotationOptions?.map(({ type }) => { + const { image, name } = SECRET_ROTATION_MAP[type]; + + let size: number; + + switch (type) { + case SecretRotation.MsSqlCredentials: + size = 50; + break; + default: + size = 45; + } + + return ( + + ); + })} + +

Infisical is constantly adding support for more services.

+

+ {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

+ + } + > +
+ +
+ Coming Soon +
+
+
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx new file mode 100644 index 000000000..a7e6cbbce --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/SecretRotationV2StatusBadge.tsx @@ -0,0 +1,109 @@ +import { faBan, faRotate, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format, formatDistanceToNow } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Tooltip } from "@app/components/v2"; +import { Badge } from "@app/components/v2/Badge/Badge"; +import { SecretRotationStatus, TSecretRotationV2 } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + secretRotation: TSecretRotationV2; + className?: string; +}; + +export const SecretRotationV2StatusBadge = ({ secretRotation, className }: Props) => { + const { isAutoRotationEnabled, rotationStatus, nextRotationAt, lastRotationMessage } = + secretRotation; + + if (rotationStatus === SecretRotationStatus.Failed) { + let errorMessage = lastRotationMessage; + if (lastRotationMessage) { + try { + errorMessage = JSON.stringify(JSON.parse(lastRotationMessage), null, 2); + } catch { + errorMessage = lastRotationMessage; + } + } + + return ( + +
+
+ +
Failure Reason
+
+
{errorMessage}
+
+ {nextRotationAt && ( + + Next rotation attempt on {format(nextRotationAt, "MM/dd/yyyy")} at{" "} + {format(nextRotationAt, "h:mm aa")}. + + )} + + } + > +
+ + + Rotation Failed + +
+
+ ); + } + + if (!isAutoRotationEnabled) { + return ( + + + Auto-Rotation Disabled + + ); + } + + const daysToRotation = + (new Date(nextRotationAt).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24); + + return ( + + + Rotates on {format(nextRotationAt, "MM/dd/yyyy")} at {format(nextRotationAt, "h:mm aa")} + {" "} + (Local Time) + + } + > +
+ = 7 ? "success" : "primary"} + className={twMerge( + "flex h-5 w-min items-center gap-1.5 whitespace-nowrap capitalize", + className + )} + > + + {daysToRotation < 0 + ? "Rotating" + : `Rotates ${formatDistanceToNow(nextRotationAt, { addSuffix: true })}`} + +
+
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx new file mode 100644 index 000000000..7a3c05d3d --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -0,0 +1,105 @@ +import { ReactNode } from "react"; +import { faRotate } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; + +import { Modal, ModalContent, Spinner } from "@app/components/v2"; +import { SECRET_ROTATION_MAP } from "@app/helpers/secretRotationsV2"; +import { + SecretRotation, + TSecretRotationV2, + useViewSecretRotationV2GeneratedCredentials +} from "@app/hooks/api/secretRotationsV2"; + +import { ViewSqlRotationGeneratedCredentials } from "./shared"; + +type Props = { + secretRotation?: TSecretRotationV2; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + secretRotation: TSecretRotationV2; +}; + +const Content = ({ secretRotation }: ContentProps) => { + const { id: rotationId, type, nextRotationAt } = secretRotation; + + const { data: generatedCredentialsResponse, isPending } = + useViewSecretRotationV2GeneratedCredentials({ + rotationId, + type + }); + + if (isPending) { + return ( +
+ +

Loading generated credentials...

+
+ ); + } + + if (!generatedCredentialsResponse) { + return ( +
+

No generated credentials found for this rotation.

+
+ ); + } + + let Component: ReactNode; + switch (generatedCredentialsResponse.type) { + case SecretRotation.PostgresCredentials: + case SecretRotation.MsSqlCredentials: + Component = ( + + ); + break; + default: + throw new Error("Unhandled View Generated Credential Rotation Type"); + } + + return ( +
+ {Component} + {nextRotationAt && ( +
+ + + Next rotation occurs on: {format(nextRotationAt, "MM/dd/yyyy")} at{" "} + {format(nextRotationAt, "h:mm aa")}{" "} + (Local Time) + +
+ )} +
+ ); +}; + +export const ViewSecretRotationV2GeneratedCredentialsModal = ({ + isOpen, + onOpenChange, + secretRotation +}: Props) => { + if (!secretRotation) return null; + + const rotationType = SECRET_ROTATION_MAP[secretRotation.type].name; + + return ( + + { + event.preventDefault(); + }} + title="Generated Credentials" + subTitle={`View the current and retired ${rotationType}.`} + > + + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts new file mode 100644 index 000000000..31aa279b4 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/index.ts @@ -0,0 +1 @@ +export * from "./ViewSecretRotationV2GeneratedCredentials"; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx new file mode 100644 index 000000000..783510336 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay.tsx @@ -0,0 +1,55 @@ +import { useReducer } from "react"; +import { faCheck, faCopy, faEyeSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { GenericFieldLabel, IconButton, Tooltip } from "@app/components/v2"; +import { useTimedReset } from "@app/hooks"; + +type Props = { + children?: string; + label: string; + isSensitive?: boolean; +}; + +export const CredentialDisplay = ({ children, label, isSensitive }: Props) => { + const [showCredential, toggleShowCredential] = useReducer((prev) => !prev, !isSensitive); + + const [, isCopyingCredential, setCopyCredential] = useTimedReset({ + initialState: "Copy ID to clipboard" + }); + + return ( + + {children ? ( +
+ {showCredential ? children : "****************************"} + + { + setCopyCredential(children); + navigator.clipboard.writeText(children); + }} + ariaLabel="Copy credential" + variant="plain" + size="xs" + > + + + + {isSensitive && ( + + + + + + )} +
+ ) : null} +
+ ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..08068b1fc --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/ViewSqlRotationGeneratedCredentials.tsx @@ -0,0 +1,57 @@ +import { faCheck, faClockRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; +import { TViewSecretRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2"; + +type Props = { + generatedCredentialsResponse: TViewSecretRotationGeneratedCredentialsResponse; +}; + +export const ViewSqlRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + <> +
+
+ + + Current Credentials + +
+

+ The active credential set currently mapped to the rotation secrets. +

+
+ {activeCredentials?.username} + + {activeCredentials?.password} + +
+
+
+
+ + + Retired Credentials + +
+

+ The retired credential set that will be revoked during the next rotation cycle. +

+
+ {inactiveCredentials?.username} + + {inactiveCredentials?.password} + +
+
+ + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts new file mode 100644 index 000000000..b964f5bba --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/index.ts @@ -0,0 +1 @@ +export * from "./ViewSqlRotationGeneratedCredentials"; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx new file mode 100644 index 000000000..d2ef136f3 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConfigurationFields.tsx @@ -0,0 +1,122 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { format, setHours, setMinutes } from "date-fns"; + +import { FilterableSelect, FormControl, Input, Switch } from "@app/components/v2"; +import { getRotateAtLocal } from "@app/helpers/secretRotationsV2"; +import { WorkspaceEnv } from "@app/hooks/api/workspace/types"; + +import { TSecretRotationV2Form } from "./schemas"; +import { SecretRotationV2ConnectionField } from "./SecretRotationV2ConnectionField"; + +type Props = { + isUpdate: boolean; + environments?: WorkspaceEnv[]; +}; + +export const SecretRotationV2ConfigurationFields = ({ isUpdate, environments }: Props) => { + const { control } = useFormContext(); + + return ( + <> +

+ Configure the connection rotation strategy for this Secret Rotation. +

+ {!isUpdate && environments && ( + ( + + option?.name} + getOptionValue={(option) => option?.id} + /> + + )} + /> + )} + + + ( + + + + )} + control={control} + name="rotationInterval" + /> + { + return ( + + { + const time = e.target.value; + if (time) { + const [hours, minutes] = time.split(":").map((str) => parseInt(str, 10)); + const newSelectedDate = setHours(setMinutes(new Date(), minutes), hours); + onChange({ + hours: newSelectedDate.getUTCHours(), + minutes: newSelectedDate.getUTCMinutes() + }); + } + }} + className="bg-mineshaft-700 text-white [color-scheme:dark]" + /> + + ); + }} + control={control} + name="rotateAtUtc" + /> + { + return ( + + +

Auto-Rotation {value ? "Enabled" : "Disabled"}

+
+
+ ); + }} + /> + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx new file mode 100644 index 000000000..665ab05a6 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ConnectionField.tsx @@ -0,0 +1,102 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { SECRET_ROTATION_CONNECTION_MAP } from "@app/helpers/secretRotationsV2"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TSecretRotationV2Form } from "./schemas"; + +type Props = { + onChange?: VoidFunction; + isUpdate: boolean; +}; + +export const SecretRotationV2ConnectionField = ({ onChange: callback, isUpdate }: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const rotationType = watch("type"); + const app = SECRET_ROTATION_CONNECTION_MAP[rotationType]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + const appName = APP_CONNECTION_MAP[app].name; + + return ( + <> + ( + + Check out{" "} + + our docs + {" "} + to ensure your connection has the required permissions for secret rotation. +

+ ) + } + > + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + isDisabled={isUpdate} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> +
+ )} + control={control} + name="connection" + /> + {!isUpdate && availableConnections?.length === 0 && ( +

+ + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} +

+ )} + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx new file mode 100644 index 000000000..573217f92 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2DetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TSecretRotationV2Form } from "./schemas"; + +export const SecretRotationV2DetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

+ Provide a name and description for this Secret Rotation. +

+ ( + + + + )} + control={control} + name="name" + /> + ( + +