diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 62ec5d2b7..000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,102 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL Advanced" - -on: - push: - branches: [ "main", "development" ] - pull_request: - branches: [ "main", "development" ] - schedule: - - cron: '33 7 * * 3' - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: actions - build-mode: none - - language: go - build-mode: autobuild - - language: javascript-typescript - build-mode: none - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" 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/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index c9f2837df..84cced88f 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -594,6 +594,7 @@ export const scimServiceFactory = ({ }, tx ); + await orgMembershipDAL.updateById( membership.id, { 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 e24cd923e..4a8e74532 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,13 +262,14 @@ export const secretApprovalRequestServiceFactory = ({ id: el.id, version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, - isRotatedSecret: el.secret.isRotatedSecret, - // eslint-disable-next-line no-nested-ternary - secretValue: el.secret.isRotatedSecret - ? undefined - : el.encryptedValue - ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() - : "", + isRotatedSecret: el.secret?.isRotatedSecret ?? false, + secretValue: + // eslint-disable-next-line no-nested-ternary + el.secret && el.secret.isRotatedSecret + ? undefined + : el.encryptedValue + ? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString() + : "", secretComment: el.encryptedComment ? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString() : "", @@ -615,7 +616,7 @@ export const secretApprovalRequestServiceFactory = ({ tx, inputSecrets: secretUpdationCommits.map((el) => { const encryptedValue = - !el.secret.isRotatedSecret && typeof el.encryptedValue !== "undefined" + !el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined" ? { encryptedValue: el.encryptedValue as Buffer, references: el.encryptedValue diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0f25324c3..60e924af1 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -66,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; @@ -1694,6 +1705,9 @@ export const AppConnections = { 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." + }, + VERCEL: { + apiToken: "The API token used to authenticate with Vercel." } } }; @@ -1813,6 +1827,13 @@ export const SecretSyncs = { org: "The ID of the Humanitec org to sync secrets to.", env: "The ID of the Humanitec environment to sync secrets to.", scope: "The Humanitec scope that secrets should be synced to." + }, + VERCEL: { + app: "The ID of the Vercel app to sync secrets to.", + appName: "The name of the Vercel app to sync secrets to.", + env: "The ID of the Vercel environment to sync secrets to.", + branch: "The branch to sync preview secrets to.", + teamId: "The ID of the Vercel team to sync secrets to." } } }; 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..6431bf953 --- /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().max(5).optional() }).optional(); +}; diff --git a/backend/src/lib/validator/validate-string.ts b/backend/src/lib/validator/validate-string.ts index bc279fdbf..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", @@ -101,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/server/app.ts b/backend/src/server/app.ts index 26f556508..3f5c477ef 100644 --- a/backend/src/server/app.ts +++ b/backend/src/server/app.ts @@ -113,7 +113,7 @@ export const main = async ({ db, hsmModule, auditLogDb, smtp, logger, queue, key await server.register(fastifyErrHandler); // Rate limiters and security headers - if (appCfg.isProductionMode) { + if (appCfg.isProductionMode && appCfg.isCloud) { await server.register(ratelimiter, globalRateLimiterCfg()); } diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index d09a2c40b..9f93eaea0 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -45,4 +45,6 @@ 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."); +) + .refine((el) => !el.includes(":"), "Secret name cannot contain colon.") + .refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 1024cae69..040d32bf4 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -31,6 +31,7 @@ import { PostgresConnectionListItemSchema, SanitizedPostgresConnectionSchema } from "@app/services/app-connection/postgres"; +import { SanitizedVercelConnectionSchema, VercelConnectionListItemSchema } from "@app/services/app-connection/vercel"; import { AuthMode } from "@app/services/auth/auth-type"; // can't use discriminated due to multiple schemas for certain apps @@ -42,6 +43,7 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureAppConfigurationConnectionSchema.options, ...SanitizedDatabricksConnectionSchema.options, ...SanitizedHumanitecConnectionSchema.options, + ...SanitizedVercelConnectionSchema.options, ...SanitizedPostgresConnectionSchema.options, ...SanitizedMsSqlConnectionSchema.options, ...SanitizedCamundaConnectionSchema.options @@ -55,6 +57,7 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureAppConfigurationConnectionListItemSchema, DatabricksConnectionListItemSchema, HumanitecConnectionListItemSchema, + VercelConnectionListItemSchema, PostgresConnectionListItemSchema, MsSqlConnectionListItemSchema, CamundaConnectionListItemSchema 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 d1831c0a5..aa5297800 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -10,6 +10,7 @@ import { registerGitHubConnectionRouter } from "./github-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; +import { registerVercelConnectionRouter } from "./vercel-connection-router"; export * from "./app-connection-router"; @@ -22,6 +23,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Vercel, + server, + sanitizedResponseSchema: SanitizedVercelConnectionSchema, + createSchema: CreateVercelConnectionSchema, + updateSchema: UpdateVercelConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/projects`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string(), + apps: z + .object({ + id: z.string(), + name: z.string(), + envs: z + .object({ + id: z.string(), + slug: z.string(), + type: z.string(), + target: z.array(z.string()).optional(), + description: z.string().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional() + }) + .array() + .optional(), + previewBranches: z.array(z.string()).optional() + }) + .array() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const projects: VercelOrgWithApps[] = await server.services.appConnection.vercel.listProjects( + connectionId, + req.permission + ); + + return projects; + } + }); +}; 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/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 2126048b8..ad087fdd1 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -9,6 +9,7 @@ import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerGcpSyncRouter } from "./gcp-sync-router"; import { registerGitHubSyncRouter } from "./github-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerVercelSyncRouter } from "./vercel-sync-router"; export * from "./secret-sync-router"; @@ -21,5 +22,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts new file mode 100644 index 000000000..e6e2f40c6 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/vercel-sync-router.ts @@ -0,0 +1,13 @@ +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { CreateVercelSyncSchema, UpdateVercelSyncSchema, VercelSyncSchema } from "@app/services/secret-sync/vercel"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerVercelSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Vercel, + server, + responseSchema: VercelSyncSchema, + createSchema: CreateVercelSyncSchema, + updateSchema: UpdateVercelSyncSchema + }); diff --git a/backend/src/server/routes/v2/identity-project-router.ts b/backend/src/server/routes/v2/identity-project-router.ts index 18244068c..4205d9326 100644 --- a/backend/src/server/routes/v2/identity-project-router.ts +++ b/backend/src/server/routes/v2/identity-project-router.ts @@ -351,4 +351,56 @@ export const registerIdentityProjectRouter = async (server: FastifyZodProvider) return { identityMembership }; } }); + + server.route({ + method: "GET", + url: "/identity-memberships/:identityMembershipId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + params: z.object({ + identityMembershipId: z.string().trim() + }), + response: { + 200: z.object({ + identityMembership: z.object({ + id: z.string(), + identityId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + identity: IdentitiesSchema.pick({ name: true, id: true }).extend({ + authMethods: z.array(z.string()) + }), + project: SanitizedProjectSchema.pick({ name: true, id: true }) + }) + }) + } + }, + handler: async (req) => { + const identityMembership = await server.services.identityProject.getProjectIdentityByMembershipId({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityMembershipId: req.params.identityMembershipId + }); + return { identityMembership }; + } + }); }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 93c65e595..df77cf02e 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureKeyVault = "azure-key-vault", AzureAppConfiguration = "azure-app-configuration", Humanitec = "humanitec", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", Camunda = "camunda" diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 340026853..696f3d4a3 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -42,6 +42,8 @@ import { } from "./humanitec"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; +import { VercelConnectionMethod } from "./vercel"; +import { getVercelConnectionListItem, validateVercelConnectionCredentials } from "./vercel/vercel-connection-fns"; export const listAppConnectionOptions = () => { return [ @@ -52,6 +54,7 @@ export const listAppConnectionOptions = () => { getAzureAppConfigurationConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), + getVercelConnectionListItem(), getPostgresConnectionListItem(), getMsSqlConnectionListItem(), getCamundaConnectionListItem() @@ -111,7 +114,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record case CamundaConnectionMethod.ClientCredentials: return "Client Credentials"; case HumanitecConnectionMethod.ApiToken: + case VercelConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -181,5 +186,6 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, - [AppConnection.Camunda]: platformManagedCredentialsNotSupported + [AppConnection.Camunda]: platformManagedCredentialsNotSupported, + [AppConnection.Vercel]: platformManagedCredentialsNotSupported }; diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 9fffe5ac0..9df7037c2 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -8,6 +8,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", + [AppConnection.Vercel]: "Vercel", [AppConnection.Postgres]: "PostgreSQL", [AppConnection.MsSql]: "Microsoft SQL Server", [AppConnection.Camunda]: "Camunda" diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 9572e37c1..3114b4731 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -43,6 +43,8 @@ import { ValidateHumanitecConnectionCredentialsSchema } from "./humanitec"; import { humanitecConnectionService } from "./humanitec/humanitec-connection-service"; import { ValidateMsSqlConnectionCredentialsSchema } from "./mssql"; import { ValidatePostgresConnectionCredentialsSchema } from "./postgres"; +import { ValidateVercelConnectionCredentialsSchema } from "./vercel"; +import { vercelConnectionService } from "./vercel/vercel-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -60,6 +62,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record { + return { + name: "Vercel" as const, + app: AppConnection.Vercel as const, + methods: Object.values(VercelConnectionMethod) as [VercelConnectionMethod.ApiToken] + }; +}; + +export const validateVercelConnectionCredentials = async (config: TVercelConnectionConfig) => { + const { credentials: inputCredentials } = config; + + let response: AxiosResponse | null = null; + + try { + response = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v9/projects`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}` + } + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection - verify credentials" + }); + } + + if (!response?.data) { + throw new InternalServerError({ + message: "Failed to get organizations: Response was empty" + }); + } + + return inputCredentials; +}; + +interface ApiResponse { + pagination?: { + count: number; + next: number; + }; + data: T[]; + [key: string]: unknown; +} + +async function fetchAllPages( + apiUrl: string, + apiToken: string, + initialParams: Record = {}, + dataPath?: string +): Promise { + const allItems: T[] = []; + let hasMoreItems = true; + let params: Record = { ...initialParams, limit: 100 }; + + while (hasMoreItems) { + try { + const response = await request.get>(apiUrl, { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (!response?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Response was empty or malformed` + }); + } + + let itemsData: T[]; + + if (dataPath && dataPath in response.data) { + itemsData = response.data[dataPath] as T[]; + } else { + itemsData = response.data.data; + } + + if (!Array.isArray(itemsData)) { + throw new InternalServerError({ + message: `Failed to fetch data from ${apiUrl}: Expected array but got ${typeof itemsData}` + }); + } + + allItems.push(...itemsData); + + if (response.data.pagination?.next) { + params = { ...params, since: response.data.pagination.next }; + } else { + hasMoreItems = false; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${apiUrl}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +} + +async function fetchOrgProjects(orgId: string, apiToken: string): Promise { + return fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects`, + apiToken, + { teamId: orgId }, + "projects" + ); +} + +async function fetchProjectEnvironments( + projectId: string, + teamId: string, + apiToken: string +): Promise { + try { + return await fetchAllPages( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${projectId}/custom-environments?teamId=${teamId}`, + apiToken, + {}, + "environments" + ); + } catch (error) { + return []; + } +} + +async function fetchPreviewBranches(projectId: string, apiToken: string): Promise { + try { + const { data } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v1/integrations/git-branches`, + { + params: { + projectId + }, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.filter((b) => b.ref !== "main").map((b) => b.ref); + } catch (error) { + return []; + } +} + +type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; + +export const listProjects = async (appConnection: TVercelConnection): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const orgs = await fetchAllPages(`${IntegrationUrls.VERCEL_API_URL}/v2/teams`, apiToken, {}, "teams"); + + const personalAccountResponse = await request.get(`${IntegrationUrls.VERCEL_API_URL}/v2/user`, { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + }); + + if (personalAccountResponse?.data?.user) { + const { user } = personalAccountResponse.data; + orgs.push({ + id: user.id, + name: user.name || "Personal Account", + slug: user.username || "personal" + }); + } + + const orgsWithApps: VercelOrgWithApps[] = []; + + const orgPromises = orgs.map(async (org) => { + try { + const projects = await fetchOrgProjects(org.id, apiToken); + + const enhancedProjectsPromises = projects.map(async (project) => { + try { + const [environments, previewBranches] = await Promise.all([ + fetchProjectEnvironments(project.name, org.id, apiToken), + fetchPreviewBranches(project.id, apiToken) + ]); + + return { + name: project.name, + id: project.id, + envs: environments, + previewBranches + }; + } catch (error) { + return { + name: project.name, + id: project.id, + envs: [], + previewBranches: [] + }; + } + }); + + const enhancedProjects = await Promise.all(enhancedProjectsPromises); + + return { + ...org, + apps: enhancedProjects + }; + } catch (error) { + return null; + } + }); + + const results = await Promise.all(orgPromises); + + results.forEach((result) => { + if (result !== null) { + orgsWithApps.push(result); + } + }); + + return orgsWithApps; +}; + +export const getProjectEnvironmentVariables = (project: VercelApp): Record => { + const envVars: Record = {}; + + if (!project.envs) return envVars; + + project.envs.forEach((env) => { + if (env.slug && env.type !== "gitBranch") { + const { id, slug } = env; + envVars[id] = slug; + } + }); + + return envVars; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts new file mode 100644 index 000000000..60baa4f5c --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-schemas.ts @@ -0,0 +1,58 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { VercelConnectionMethod } from "./vercel-connection-enums"; + +export const VercelConnectionAccessTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API Token required").describe(AppConnections.CREDENTIALS.VERCEL.apiToken) +}); + +const BaseVercelConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +export const VercelConnectionSchema = BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedVercelConnectionSchema = z.discriminatedUnion("method", [ + BaseVercelConnectionSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: VercelConnectionAccessTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateVercelConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(VercelConnectionMethod.ApiToken).describe(AppConnections.CREATE(AppConnection.Vercel).method), + credentials: VercelConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Vercel).credentials + ) + }) +]); + +export const CreateVercelConnectionSchema = ValidateVercelConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Vercel) +); + +export const UpdateVercelConnectionSchema = z + .object({ + credentials: VercelConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Vercel).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Vercel)); + +export const VercelConnectionListItemSchema = z.object({ + name: z.literal("Vercel"), + app: z.literal(AppConnection.Vercel), + methods: z.nativeEnum(VercelConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/vercel/vercel-connection-service.ts b/backend/src/services/app-connection/vercel/vercel-connection-service.ts new file mode 100644 index 000000000..68e5215e9 --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-service.ts @@ -0,0 +1,29 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listProjects as getVercelProjects } from "./vercel-connection-fns"; +import { TVercelConnection } from "./vercel-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const vercelConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listProjects = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Vercel, connectionId, actor); + try { + const projects = await getVercelProjects(appConnection); + return projects; + } catch (error) { + logger.error(error, "Failed to establish connection with Vercel"); + return []; + } + }; + + return { + listProjects + }; +}; diff --git a/backend/src/services/app-connection/vercel/vercel-connection-types.ts b/backend/src/services/app-connection/vercel/vercel-connection-types.ts new file mode 100644 index 000000000..4ab69d1df --- /dev/null +++ b/backend/src/services/app-connection/vercel/vercel-connection-types.ts @@ -0,0 +1,73 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateVercelConnectionSchema, + ValidateVercelConnectionCredentialsSchema, + VercelConnectionSchema +} from "./vercel-connection-schemas"; + +export type TVercelConnection = z.infer; + +export type TVercelConnectionInput = z.infer & { + app: AppConnection.Vercel; +}; + +export type TValidateVercelConnectionCredentialsSchema = typeof ValidateVercelConnectionCredentialsSchema; + +export type TVercelConnectionConfig = DiscriminativePick & { + orgId: string; +}; + +export type VercelTeam = { + id: string; + name: string; + slug: string; +}; + +export type VercelEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type VercelAppMeta = { + githubCommitRef?: string; + githubCommitSha?: string; + githubCommitMessage?: string; + githubCommitAuthorName?: string; +}; + +export type VercelDeployment = { + id: string; + name: string; + url: string; + created: number; + meta?: VercelAppMeta; + target?: "production" | "preview" | "development"; +}; + +export type VercelApp = { + name: string; + id: string; + envs?: VercelEnvironment[]; + previewBranches?: string[]; +}; + +export type VercelOrgWithApps = VercelTeam & { + apps: VercelApp[]; +}; + +export type VercelUserResponse = { + user: { + id: string; + name: string; + username: string; + }; +}; diff --git a/backend/src/services/identity-project/identity-project-service.ts b/backend/src/services/identity-project/identity-project-service.ts index ba63f7afd..14df0cd4a 100644 --- a/backend/src/services/identity-project/identity-project-service.ts +++ b/backend/src/services/identity-project/identity-project-service.ts @@ -21,6 +21,7 @@ import { TCreateProjectIdentityDTO, TDeleteProjectIdentityDTO, TGetProjectIdentityByIdentityIdDTO, + TGetProjectIdentityByMembershipIdDTO, TListProjectIdentityDTO, TUpdateProjectIdentityDTO } from "./identity-project-types"; @@ -370,11 +371,48 @@ export const identityProjectServiceFactory = ({ return identityMembership; }; + const getProjectIdentityByMembershipId = async ({ + identityMembershipId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TGetProjectIdentityByMembershipIdDTO) => { + const membership = await identityProjectDAL.findOne({ id: identityMembershipId }); + + if (!membership) { + throw new NotFoundError({ + message: `Project membership with ID '${identityMembershipId}' not found` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: membership.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.Any + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionIdentityActions.Read, + subject(ProjectPermissionSub.Identity, { identityId: membership.identityId }) + ); + + const [identityMembership] = await identityProjectDAL.findByProjectId(membership.projectId, { + identityId: membership.identityId + }); + + return identityMembership; + }; + return { createProjectIdentity, updateProjectIdentity, deleteProjectIdentity, listProjectIdentities, - getProjectIdentityByIdentityId + getProjectIdentityByIdentityId, + getProjectIdentityByMembershipId }; }; diff --git a/backend/src/services/identity-project/identity-project-types.ts b/backend/src/services/identity-project/identity-project-types.ts index 607fd4823..bc85ca398 100644 --- a/backend/src/services/identity-project/identity-project-types.ts +++ b/backend/src/services/identity-project/identity-project-types.ts @@ -52,6 +52,10 @@ export type TGetProjectIdentityByIdentityIdDTO = { identityId: string; } & TProjectPermission; +export type TGetProjectIdentityByMembershipIdDTO = { + identityMembershipId: string; +} & Omit; + export enum ProjectIdentityOrderBy { Name = "name" } 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/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index 9503d45a9..70febfc0d 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -7,7 +7,8 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", - Camunda = "camunda" + Camunda = "camunda", + Vercel = "vercel" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 077d0ac05..437b358ee 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -27,6 +27,7 @@ import { GCP_SYNC_LIST_OPTION } from "./gcp"; import { GcpSyncFns } from "./gcp/gcp-sync-fns"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; +import { VERCEL_SYNC_LIST_OPTION, VercelSyncFns } from "./vercel"; const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AWSParameterStore]: AWS_PARAMETER_STORE_SYNC_LIST_OPTION, @@ -37,7 +38,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.AzureAppConfiguration]: AZURE_APP_CONFIGURATION_SYNC_LIST_OPTION, [SecretSync.Databricks]: DATABRICKS_SYNC_LIST_OPTION, [SecretSync.Humanitec]: HUMANITEC_SYNC_LIST_OPTION, - [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION + [SecretSync.Camunda]: CAMUNDA_SYNC_LIST_OPTION, + [SecretSync.Vercel]: VERCEL_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -128,6 +130,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).syncSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.syncSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -177,6 +181,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).getSecrets(secretSync); + case SecretSync.Vercel: + secretMap = await VercelSyncFns.getSecrets(secretSync); break; default: throw new Error( @@ -225,6 +231,8 @@ export const SecretSyncFns = { appConnectionDAL, kmsService }).removeSecrets(secretSync, secretMap); + case SecretSync.Vercel: + return VercelSyncFns.removeSecrets(secretSync, secretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 9830a89f0..10e89ff03 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -10,7 +10,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AzureAppConfiguration]: "Azure App Configuration", [SecretSync.Databricks]: "Databricks", [SecretSync.Humanitec]: "Humanitec", - [SecretSync.Camunda]: "Camunda" + [SecretSync.Camunda]: "Camunda", + [SecretSync.Vercel]: "Vercel" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -22,5 +23,6 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, - [SecretSync.Camunda]: AppConnection.Camunda + [SecretSync.Camunda]: AppConnection.Camunda, + [SecretSync.Vercel]: AppConnection.Vercel }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 056749a92..091f91240 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -55,6 +55,7 @@ import { THumanitecSyncListItem, THumanitecSyncWithCredentials } from "./humanitec"; +import { TVercelSync, TVercelSyncInput, TVercelSyncListItem, TVercelSyncWithCredentials } from "./vercel"; export type TSecretSync = | TAwsParameterStoreSync @@ -65,7 +66,8 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync - | TCamundaSync; + | TCamundaSync + | TVercelSync; export type TSecretSyncWithCredentials = | TAwsParameterStoreSyncWithCredentials @@ -76,7 +78,8 @@ export type TSecretSyncWithCredentials = | TAzureAppConfigurationSyncWithCredentials | TDatabricksSyncWithCredentials | THumanitecSyncWithCredentials - | TCamundaSyncWithCredentials; + | TCamundaSyncWithCredentials + | TVercelSyncWithCredentials; export type TSecretSyncInput = | TAwsParameterStoreSyncInput @@ -87,7 +90,8 @@ export type TSecretSyncInput = | TAzureAppConfigurationSyncInput | TDatabricksSyncInput | THumanitecSyncInput - | TCamundaSyncInput; + | TCamundaSyncInput + | TVercelSyncInput; export type TSecretSyncListItem = | TAwsParameterStoreSyncListItem @@ -98,7 +102,8 @@ export type TSecretSyncListItem = | TAzureAppConfigurationSyncListItem | TDatabricksSyncListItem | THumanitecSyncListItem - | TCamundaSyncListItem; + | TCamundaSyncListItem + | TVercelSyncListItem; export type TSyncOptionsConfig = { canImportSecrets: boolean; diff --git a/backend/src/services/secret-sync/vercel/index.ts b/backend/src/services/secret-sync/vercel/index.ts new file mode 100644 index 000000000..b8379b5d9 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/index.ts @@ -0,0 +1,5 @@ +export * from "./vercel-sync-constants"; +export * from "./vercel-sync-enums"; +export * from "./vercel-sync-fns"; +export * from "./vercel-sync-schemas"; +export * from "./vercel-sync-types"; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts new file mode 100644 index 000000000..60b3eb00a --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const VERCEL_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Vercel", + destination: SecretSync.Vercel, + connection: AppConnection.Vercel, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts new file mode 100644 index 000000000..36c46985b --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-enums.ts @@ -0,0 +1,12 @@ +export enum VercelSyncScope { + Application = "application", + Environment = "environment" +} + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts new file mode 100644 index 000000000..713971283 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { DefaultVercelEnvType, TVercelSyncWithCredentials, VercelApiSecret } from "./vercel-sync-types"; + +function isVercelDefaultEnvType(value: string): value is DefaultVercelEnvType { + return Object.values(VercelEnvironmentType).map(String).includes(value); +} + +const MAX_RETRIES = 5; + +const sleep = async () => + new Promise((resolve) => { + setTimeout(resolve, 60000); + }); + +const getVercelSecretsWithRetries = async ( + secretSync: TVercelSyncWithCredentials, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + try { + const { data } = await request.get<{ envs: VercelApiSecret[] }>( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + return data.envs; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getVercelSecretsWithRetries(secretSync, attempt + 1); + } + throw error; + } +}; + +const getDecryptedVercelSecret = async ( + secretSync: TVercelSyncWithCredentials, + secret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + const params: { [key: string]: string } = { + decrypt: "true", + ...(destinationConfig.branch ? { gitBranch: destinationConfig.branch } : {}) + }; + + try { + const { data: decryptedSecret } = await request.get( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${secret.id}?teamId=${destinationConfig.teamId}`, + { + params, + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + + return decryptedSecret as VercelApiSecret; + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await getDecryptedVercelSecret(secretSync, secret, attempt + 1); + } + throw error; + } +}; + +const getVercelSecrets = async (secretSync: TVercelSyncWithCredentials): Promise => { + const { destinationConfig } = secretSync; + + const secrets = await getVercelSecretsWithRetries(secretSync); + + const filteredSecrets = secrets.filter((secret) => { + if (!isVercelDefaultEnvType(destinationConfig.env)) { + if (secret.customEnvironmentIds?.includes(destinationConfig.env)) { + return true; + } + return false; + } + if (secret.target.includes(destinationConfig.env)) { + // If it's preview environment with a branch specified + if ( + destinationConfig.env === VercelEnvironmentType.Preview && + destinationConfig.branch && + secret.gitBranch && + secret.gitBranch !== destinationConfig.branch + ) { + return false; + } + return true; + } + return false; + }); + + // For secrets of type "encrypted", we need to get their decrypted value + const secretsWithValues = await Promise.all( + filteredSecrets.map(async (secret) => { + if (secret.type === "encrypted") { + const decryptedSecret = await getDecryptedVercelSecret(secretSync, secret); + return decryptedSecret; + } + return secret; + }) + ); + + return secretsWithValues; +}; + +const deleteSecret = async ( + secretSync: TVercelSyncWithCredentials, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + try { + await request.delete( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await deleteSecret(secretSync, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +const createSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + key: string, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + await request.post( + `${IntegrationUrls.VERCEL_API_URL}/v10/projects/${destinationConfig.app}/env?teamId=${destinationConfig.teamId}`, + { + key, + value: secretMap[key].value, + type: "encrypted", + target: isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + customEnvironmentIds: !isVercelDefaultEnvType(destinationConfig.env) ? [destinationConfig.env] : [], + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await createSecret(secretSync, secretMap, key, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: key + }); + } +}; + +const updateSecret = async ( + secretSync: TVercelSyncWithCredentials, + secretMap: TSecretMap, + vercelSecret: VercelApiSecret, + attempt = 0 +): Promise => { + try { + const { + destinationConfig, + connection: { + credentials: { apiToken } + } + } = secretSync; + + let target = [...vercelSecret.target]; + if (isVercelDefaultEnvType(destinationConfig.env) && !vercelSecret.target.includes(destinationConfig.env)) { + target = [...target, destinationConfig.env]; + } + let customEnvironmentIds = [...(vercelSecret.customEnvironmentIds || [])]; + if ( + !isVercelDefaultEnvType(destinationConfig.env) && + !vercelSecret.customEnvironmentIds?.includes(destinationConfig.env) + ) { + customEnvironmentIds = [...customEnvironmentIds, destinationConfig.env]; + } + + await request.patch( + `${IntegrationUrls.VERCEL_API_URL}/v9/projects/${destinationConfig.app}/env/${vercelSecret.id}?teamId=${destinationConfig.teamId}`, + { + ...(vercelSecret.type !== "sensitive" && { key: vercelSecret.key }), + value: secretMap[vercelSecret.key].value, + type: vercelSecret.type, + target, + customEnvironmentIds, + ...(destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch + ? { gitBranch: destinationConfig.branch } + : {}) + }, + { + headers: { + Authorization: `Bearer ${apiToken}`, + "Accept-Encoding": "application/json" + } + } + ); + } catch (error) { + if ((error as { response: { status: number } }).response.status === 429 && attempt < MAX_RETRIES) { + await sleep(); + return await updateSecret(secretSync, secretMap, vercelSecret, attempt + 1); + } + throw new SecretSyncError({ + error, + secretKey: vercelSecret.key + }); + } +}; + +export const VercelSyncFns = { + syncSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + const vercelSecretsMap = new Map(vercelSecrets.map((s) => [s.key, s])); + + // Create or update secrets + for await (const key of Object.keys(secretMap)) { + const existingSecret = vercelSecretsMap.get(key); + + if (!existingSecret) { + await createSecret(secretSync, secretMap, key); + } else if (existingSecret.value !== secretMap[key].value) { + await updateSecret(secretSync, secretMap, existingSecret); + } + } + + // Delete secrets if disableSecretDeletion is not set + if (secretSync.syncOptions.disableSecretDeletion) return; + + for await (const vercelSecret of vercelSecrets) { + if (!secretMap[vercelSecret.key]) { + await deleteSecret(secretSync, vercelSecret); + } + } + }, + + getSecrets: async (secretSync: TVercelSyncWithCredentials): Promise => { + const vercelSecrets = await getVercelSecrets(secretSync); + return Object.fromEntries(vercelSecrets.map((s) => [s.key, { value: s.value ?? "" }])); + }, + + removeSecrets: async (secretSync: TVercelSyncWithCredentials, secretMap: TSecretMap) => { + const vercelSecrets = await getVercelSecrets(secretSync); + + for await (const vercelSecret of vercelSecrets) { + if (vercelSecret.key in secretMap) { + await deleteSecret(secretSync, vercelSecret); + } + } + } +}; diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts new file mode 100644 index 000000000..84d7a6da4 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-schemas.ts @@ -0,0 +1,49 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; + +const VercelSyncDestinationConfigSchema = z.object({ + app: z.string().min(1, "App ID is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.app), + appName: z.string().min(1, "App Name is required").describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.appName), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()).describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.env), + branch: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.branch), + teamId: z.string().describe(SecretSyncs.DESTINATION_CONFIG.VERCEL.teamId) +}); + +const VercelSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const VercelSyncSchema = BaseSecretSyncSchema(SecretSync.Vercel, VercelSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const CreateVercelSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema +}); + +export const UpdateVercelSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.Vercel, + VercelSyncOptionsConfig +).extend({ + destinationConfig: VercelSyncDestinationConfigSchema.optional() +}); + +export const VercelSyncListItemSchema = z.object({ + name: z.literal("Vercel"), + connection: z.literal(AppConnection.Vercel), + destination: z.literal(SecretSync.Vercel), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-types.ts b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts new file mode 100644 index 000000000..d6d2b6433 --- /dev/null +++ b/backend/src/services/secret-sync/vercel/vercel-sync-types.ts @@ -0,0 +1,40 @@ +import z from "zod"; + +import { TVercelConnection } from "@app/services/app-connection/vercel"; + +import { VercelEnvironmentType } from "./vercel-sync-enums"; +import { CreateVercelSyncSchema, VercelSyncListItemSchema, VercelSyncSchema } from "./vercel-sync-schemas"; + +export type TVercelSyncListItem = z.infer; + +export type TVercelSync = z.infer; + +export type TVercelSyncInput = z.infer; + +export type TVercelSyncWithCredentials = TVercelSync & { + connection: TVercelConnection; +}; + +export type VercelSecret = { + description: string; + is_secret: boolean; + key: string; + source: "app" | "env"; + value: string; +}; + +export interface VercelApiSecret { + id: string; + key: string; + value: string; + type: string; + target: string[]; + customEnvironmentIds?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; + configurationId?: string; + system?: boolean; +} + +export type DefaultVercelEnvType = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; 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/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 [Vercel Connections](/integrations/app-connections/vercel) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/vercel/delete.mdx b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx new file mode 100644 index 000000000..4e5b12eff --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx new file mode 100644 index 000000000..fdeb715a8 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/vercel/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx new file mode 100644 index 000000000..258ed67c7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/vercel/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/list.mdx b/docs/api-reference/endpoints/app-connections/vercel/list.mdx new file mode 100644 index 000000000..5412d35bb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/vercel" +--- diff --git a/docs/api-reference/endpoints/app-connections/vercel/update.mdx b/docs/api-reference/endpoints/app-connections/vercel/update.mdx new file mode 100644 index 000000000..d0e2f4ae2 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/vercel/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/vercel/{connectionId}" +--- + + + Check out the configuration docs for [Vercel Connections](/integrations/app-connections/vercel) 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-syncs/vercel/create.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx new file mode 100644 index 000000000..e14d6dddd --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx new file mode 100644 index 000000000..746e7ffe5 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx new file mode 100644 index 000000000..9a4efd1e6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx new file mode 100644 index 000000000..3f71a6b3b --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/vercel/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx new file mode 100644 index 000000000..807eb2850 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx new file mode 100644 index 000000000..905470d0d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/vercel" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx new file mode 100644 index 000000000..49c76ef99 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx new file mode 100644 index 000000000..2b3bc8324 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/vercel/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx new file mode 100644 index 000000000..75be8dd89 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/vercel/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/vercel/{syncId}" +--- diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index a20f52171..822a8b24a 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -4,6 +4,34 @@ title: "Changelog" The changelog below reflects new product developments and updates on a monthly basis. +## March 2025 + +- Released [Infisical Gateway](https://infisical.com/docs/documentation/platform/gateways/overview) for secure access to private resources without needing direct inbound connections to private networks. +- Enhanced [Terraform](https://infisical.com/docs/integrations/frameworks/terraform#terraform) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. +- Self-hosted improvements: Usage and billing visibility for enabled features, ability to delete users, and support for multiple super admins. +- UI and UX updates: Improved secret import interface on the overview page, password reset without backup PDF. +- CLI enhancements: Various improvements including multiline secret support and ability to pass headers. +- Kubernetes operator updates: Auto-reloading for DaemonSets and StatefulSets (previously only Deployments), added support for ConfigMaps. +- Implemented powerful [Access Control](https://infisical.com/docs/documentation/platform/access-controls/overview#access-controls) updates including \"**Grant Privileges**\" feature for designating specific users for policy management, **Access Tree** visualization for simulating permissions, and ability to restrict scope of secret sharing within organizations. +- Released new **Secret Requests** feature under Secret Share, added support for reminders with webhook triggers and implementing password policies for dynamic secrets. +- Enhanced secret version history to show who made changes. +- New integrations and syncs: **Crossplane** provider, **Humanitec** secret sync, **Airflow** system integration +- Performed significant performance optimizations including a 50% reduction in database usage and optimized client secret handling for universal auth. +- Enhanced security features with ability to add custom instance banners (useful for regulated industries), short-lived tokens for Kubernetes auth, and OIDC claim passing from machine identity login to permissions. +- [Golang SDK](https://infisical.com/docs/sdks/languages/go#infisical-go-sdk): New API added for enhanced functionality +- Added capability to programmatically configure an Infisical instance from start to finish without UI interaction. + +## February 2025 + +- Released [KMIP integration](https://infisical.com/docs/documentation/platform/kms/kmip) with PKI structure, auth model integration with machine identities, complete set of client operations, and client certificate authentication flow. +- Added new [AWS App Connection](https://infisical.com/docs/integrations/app-connections/aws) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/aws-secrets-manager) functionality for enhanced AWS integration. +- Released new [Azure Key Vault App Connection](https://infisical.com/docs/integrations/app-connections/azure-key-vault) and [Secret Sync](https://infisical.com/docs/integrations/secret-syncs/azure-key-vault), plus Terraform provider support. +- Introduced more comprehensive logging with detailed records for secret sharing and metadata in audit logs. +- Introduced new [permission types](https://infisical.com/docs/internals/permissions/project-permissions#subject-secrets): \"View Value\" vs \"Describe Value\" for more granular access control over secrets. +- Updated encryption logic with unified approach for all platform data, ensuring consistency across the system. +- Added support for [OIDC group mapping](https://infisical.com/docs/documentation/platform/sso/general-oidc) to automatically map groups to Infisical for role-based access control. +- Added [Terraform Cloud support for OIDC](https://infisical.com/docs/documentation/platform/identities/oidc-auth/terraform-cloud#terraform-cloud). + ## January 2025 - Released new integration architecture with decoupled authentication, replacing native integrations with [App Connections](https://infisical.com/docs/integrations/app-connections/overview) and [Secret Syncs](https://infisical.com/docs/integrations/secret-syncs/overview). Initial support for AWS Parameter Store, GitHub, and GCP Secret Manager with improved API and Terraform integration capabilities. @@ -15,7 +43,6 @@ The changelog below reflects new product developments and updates on a monthly b - Implemented secret Access Visibility allowing users to view all entities with access to specific secrets in the secret side panel. - Added secret filtering by metadata and SSH assigned certificates (Version 1). - ## December 2024 - Added [GCP KMS](https://infisical.com/docs/documentation/platform/kms/overview) integration support. - Added support for [K8s CSI integration](https://infisical.com/docs/integrations/platforms/kubernetes-csi) and ability to point K8s operator to specific secret versions. 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/images/app-connections/vercel/vercel-app-connection-created.png b/docs/images/app-connections/vercel/vercel-app-connection-created.png new file mode 100644 index 000000000..8fb371440 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-created.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-modal.png b/docs/images/app-connections/vercel/vercel-app-connection-modal.png new file mode 100644 index 000000000..6b789713d Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-modal.png differ diff --git a/docs/images/app-connections/vercel/vercel-app-connection-option.png b/docs/images/app-connections/vercel/vercel-app-connection-option.png new file mode 100644 index 000000000..b4308a1a2 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-app-connection-option.png differ diff --git a/docs/images/app-connections/vercel/vercel-copy-token.png b/docs/images/app-connections/vercel/vercel-copy-token.png new file mode 100644 index 000000000..d6491c02e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-copy-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-create-token.png b/docs/images/app-connections/vercel/vercel-create-token.png new file mode 100644 index 000000000..c507f0d14 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-create-token.png differ diff --git a/docs/images/app-connections/vercel/vercel-main-page.png b/docs/images/app-connections/vercel/vercel-main-page.png new file mode 100644 index 000000000..e1a28248e Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-main-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-settings-page.png b/docs/images/app-connections/vercel/vercel-settings-page.png new file mode 100644 index 000000000..86e67d6e1 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-settings-page.png differ diff --git a/docs/images/app-connections/vercel/vercel-token-created.png b/docs/images/app-connections/vercel/vercel-token-created.png new file mode 100644 index 000000000..5e57a4bd8 Binary files /dev/null and b/docs/images/app-connections/vercel/vercel-token-created.png differ diff --git a/docs/images/secret-syncs/vercel/select-vercel-option.png b/docs/images/secret-syncs/vercel/select-vercel-option.png new file mode 100644 index 000000000..b63d33cc7 Binary files /dev/null and b/docs/images/secret-syncs/vercel/select-vercel-option.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-created.png b/docs/images/secret-syncs/vercel/vercel-created.png new file mode 100644 index 000000000..fe955b00f Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-created.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-destination.png b/docs/images/secret-syncs/vercel/vercel-destination.png new file mode 100644 index 000000000..4d0f73d35 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-destination.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-details.png b/docs/images/secret-syncs/vercel/vercel-details.png new file mode 100644 index 000000000..4421c426c Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-details.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-options.png b/docs/images/secret-syncs/vercel/vercel-options.png new file mode 100644 index 000000000..1d38e7ae6 Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-options.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-review.png b/docs/images/secret-syncs/vercel/vercel-review.png new file mode 100644 index 000000000..7a921f4ec Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-review.png differ diff --git a/docs/images/secret-syncs/vercel/vercel-source.png b/docs/images/secret-syncs/vercel/vercel-source.png new file mode 100644 index 000000000..efb753aad Binary files /dev/null and b/docs/images/secret-syncs/vercel/vercel-source.png differ diff --git a/docs/integrations/app-connections/vercel.mdx b/docs/integrations/app-connections/vercel.mdx new file mode 100644 index 000000000..8ef4a5647 --- /dev/null +++ b/docs/integrations/app-connections/vercel.mdx @@ -0,0 +1,97 @@ +--- +title: "Vercel Connection" +description: "Learn how to configure a Vercel Connection for Infisical." +--- + +Infisical supports connecting to Vercel using an API Token to securely sync your secrets to Vercel. + +## Setup Vercel Connection in Infisical + + + + Navigate to the Vercel **Account Settings** page by clicking on your profile icon in the top-right corner. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-main-page.png) + + + Select the **API Tokens** tab from the left sidebar navigation menu. + ![Vercel API Tokens Tab](/images/app-connections/vercel/vercel-settings-page.png) + + + Click the **Create** button and provide a name for your token (e.g., "Infisical Integration"). + Choose appropriate scope permissions based on your requirements. + + If you configure an expiry date for your API token, you will need to manually rotate to a new token prior to expiration to avoid integration downtime. Consider setting a calendar reminder for this task. + + ![Vercel Create API Token](/images/app-connections/vercel/vercel-create-token.png) + + + After creation, a modal with the API token will be displayed. Copy this token immediately and store it securely, as you won't be able to view it again after closing this dialog. + ![Vercel Copy API Token](/images/app-connections/vercel/vercel-copy-token.png) + + + You should now see your newly created token in the list of API tokens on the Vercel dashboard. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-token-created.png) + + + + + 1. Navigate to App Connections + + In your Infisical dashboard, go to **Organization Settings** and select the **App Connections** tab. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + 2. Add Connection + + Click the **+ Add Connection** button and select the **Vercel Connection** option from the available integrations. + ![Select Vercel Connection](/images/app-connections/vercel/vercel-app-connection-option.png) + 3. Fill the Vercel Connection Modal + + Complete the Vercel Connection form by entering: + - A descriptive name for the connection + - The API Token you generated in steps 3-4 + - An optional description for future reference + ![Vercel Connection Modal](/images/app-connections/vercel/vercel-app-connection-modal.png) + 4. Connection Created + + After clicking Create, your **Vercel Connection** is established and ready to use with your Infisical projects. + ![Vercel Connection Created](/images/app-connections/vercel/vercel-app-connection-created.png) + + + To create a Vercel Connection, make an API request to the [Create Vercel + Connection](/api-reference/endpoints/app-connections/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-connection", + "method": "api-token", + "credentials": { + "apiToken": "...", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-connection", + "version": 123, + "orgId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-04-01T05:31:56Z", + "updatedAt": "2025-04-01T05:31:56Z", + "app": "vercel", + "method": "api-token", + "credentials": {} + } + } + ``` + + + + \ No newline at end of file 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/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx new file mode 100644 index 000000000..593874dee --- /dev/null +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -0,0 +1,148 @@ +--- +title: "Vercel Sync" +description: "Learn how to configure a Vercel Sync for Infisical." +--- + +**Prerequisites:** + + - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) + - Create a [Vercel Connection](/integrations/app-connections/vercel) + + + + 1. Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + 2. Select the **Vercel** option. + ![Select Vercel](/images/secret-syncs/vercel/select-vercel-option.png) + + 3. Configure the **Source** from where secrets should be retrieved, then click **Next**. + ![Configure Source](/images/secret-syncs/vercel/vercel-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + 4. Configure the **Destination** to where secrets should be deployed, then click **Next**. + ![Configure Destination](/images/secret-syncs/vercel/vercel-destination.png) + + - **Vercel Connection**: The Vercel Connection to authenticate with. + - **Vercel App**: The application to deploy secrets to. + - **Vercel App Environment**: The environment to deploy secrets to. + - **Vercel Preview Branch (Optional)**: Specify a branch for preview deployments if needed. + + After configuring these parameters, click the **Next** button to continue to the Sync Options step. + + 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + ![Configure Options](/images/secret-syncs/vercel/vercel-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. + - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + 6. Configure the **Details** of your Vercel Sync, then click **Next**. + ![Configure Details](/images/secret-syncs/vercel/vercel-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + 7. Review your Vercel Sync configuration, then click **Create Sync**. + ![Confirm Configuration](/images/secret-syncs/vercel/vercel-review.png) + + 8. If enabled, your Vercel Sync will begin syncing your secrets to the destination endpoint. + ![Sync Secrets](/images/secret-syncs/vercel/vercel-created.png) + + + + To create an **Vercel Sync**, make an API request to the [Create Vercel Sync](/api-reference/endpoints/secret-syncs/vercel/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/vercel \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-vercel-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "an example sync", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/my-secrets", + "isEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-vercel-sync", + "description": "an example sync", + "isEnabled": true, + "version": 1, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "syncStatus": "succeeded", + "lastSyncJobId": "123", + "lastSyncMessage": null, + "lastSyncedAt": "2023-11-07T05:31:56Z", + "importStatus": null, + "lastImportJobId": null, + "lastImportMessage": null, + "lastImportedAt": null, + "removeStatus": null, + "lastRemoveJobId": null, + "lastRemoveMessage": null, + "lastRemovedAt": null, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "vercel", + "name": "my-vercel-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/my-secrets" + }, + "destination": "vercel", + "destinationConfig": { + "app": "prj_bz7zgHvQETPvJWc5tmIr0tGRH9kE", + "env": "preview", + "branch": "test", + "appName": "nextjs-boilerplate", + "teamId": "team_0d444b5088888dd257" + } + } + } + ``` + + diff --git a/docs/mint.json b/docs/mint.json index 7523d5fa9..d0ad859b7 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -422,6 +422,7 @@ "integrations/app-connections/gcp", "integrations/app-connections/github", "integrations/app-connections/humanitec", + "integrations/app-connections/vercel", "integrations/app-connections/mssql", "integrations/app-connections/postgres" ] @@ -443,7 +444,8 @@ "integrations/secret-syncs/databricks", "integrations/secret-syncs/gcp-secret-manager", "integrations/secret-syncs/github", - "integrations/secret-syncs/humanitec" + "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/vercel" ] } ] @@ -584,7 +586,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" ] }, { @@ -973,6 +976,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/app-connections/vercel/list", + "api-reference/endpoints/app-connections/vercel/available", + "api-reference/endpoints/app-connections/vercel/get-by-id", + "api-reference/endpoints/app-connections/vercel/get-by-name", + "api-reference/endpoints/app-connections/vercel/create", + "api-reference/endpoints/app-connections/vercel/update", + "api-reference/endpoints/app-connections/vercel/delete" + ] + }, { "group": "Microsoft SQL Server", "pages": [ @@ -1125,6 +1140,20 @@ "api-reference/endpoints/secret-syncs/humanitec/sync-secrets", "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] + }, + { + "group": "Vercel", + "pages": [ + "api-reference/endpoints/secret-syncs/vercel/list", + "api-reference/endpoints/secret-syncs/vercel/get-by-id", + "api-reference/endpoints/secret-syncs/vercel/get-by-name", + "api-reference/endpoints/secret-syncs/vercel/create", + "api-reference/endpoints/secret-syncs/vercel/update", + "api-reference/endpoints/secret-syncs/vercel/delete", + "api-reference/endpoints/secret-syncs/vercel/sync-secrets", + "api-reference/endpoints/secret-syncs/vercel/remove-secrets", + "api-reference/endpoints/secret-syncs/vercel/import-secrets" + ] } ] }, 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/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index ea41c68ab..d0a53019d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -12,6 +12,7 @@ import { DatabricksSyncFields } from "./DatabricksSyncFields"; import { GcpSyncFields } from "./GcpSyncFields"; import { GitHubSyncFields } from "./GitHubSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { VercelSyncFields } from "./VercelSyncFields"; export const SecretSyncDestinationFields = () => { const { watch } = useFormContext(); @@ -37,6 +38,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Camunda: return ; + case SecretSync.Vercel: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx new file mode 100644 index 000000000..5c328079d --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/VercelSyncFields.tsx @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; +import { + TVercelConnectionApp, + useVercelConnectionListOrganizations +} from "@app/hooks/api/appConnections/vercel"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +const vercelEnvironments = [ + { name: "Development", slug: "development" }, + { name: "Preview", slug: "preview" }, + { name: "Production", slug: "production" } +]; + +export const VercelSyncFields = () => { + const { control, watch, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Vercel } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const currentApp = watch("destinationConfig.app"); + const currentEnv = watch("destinationConfig.env"); + + const { data: projects, isLoading: isProjectsLoading } = useVercelConnectionListOrganizations( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const selectedProject = projects + ?.find((project) => project.apps.some((app) => app.id === currentApp)) + ?.apps.find((app) => app.id === currentApp); + + const allApps = + projects?.flatMap((project) => + project.apps.map((app) => ({ ...app, project: project.name, projectId: project.id })) + ) || []; + + const environmentOptions = useMemo(() => { + return vercelEnvironments + .map((env) => ({ + key: env.slug, + type: env.slug, + name: env.name + })) + .concat( + selectedProject?.envs?.map((env) => ({ + key: env.id, + type: env.type, + name: env.slug + })) || [] + ); + }, [currentApp]); + + const previewBranchOptions = + selectedProject?.previewBranches?.map((branch) => ({ + id: branch, + name: branch + })) || []; + + const isPreviewEnvironment = currentEnv === "preview"; + + return ( + <> + { + setValue("destinationConfig.app", ""); + setValue("destinationConfig.appName", ""); + setValue("destinationConfig.env", "production"); + setValue("destinationConfig.branch", ""); + }} + /> + + ( + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > + app.id === value) ?? null} + onChange={(option) => { + const appId = (option as SingleValue)?.id ?? null; + onChange(appId); + setValue("destinationConfig.branch", ""); + setValue( + "destinationConfig.teamId", + (option as SingleValue)?.projectId || "" + ); + setValue( + "destinationConfig.appName", + (option as SingleValue)?.name || "" + ); + }} + options={allApps} + placeholder="Select a project..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + groupBy="project" + /> +
+ )} + /> + + ( + + env.key === value)?.key, + type: environmentOptions.find((env) => env.key === value)?.type, + name: environmentOptions.find((env) => env.key === value)?.name + } + : null + } + onChange={(option) => { + const envKey = (option as any)?.key ?? null; + onChange(envKey); + + setValue("destinationConfig.branch", ""); + }} + options={environmentOptions} + placeholder="Select an environment..." + getOptionLabel={(option) => option.name || option.key || ""} + getOptionValue={(option) => option.key || ""} + /> + + )} + /> + + {isPreviewEnvironment && ( + ( + + branch.id === value) ?? null} + onChange={(option) => onChange((option as SingleValue<{ id: string }>)?.id || "")} + options={previewBranchOptions} + placeholder="Select a branch..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option?.id || ""} + isClearable + /> + + )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 063c06323..6c09c65cc 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -40,6 +40,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.Camunda: + case SecretSync.Vercel: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 4587d7ca5..573854906 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -22,6 +22,7 @@ import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { VercelSyncReviewFields } from "./VercelSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); @@ -76,6 +77,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Camunda: DestinationFieldsComponent = ; break; + case SecretSync.Vercel: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx new file mode 100644 index 000000000..43e83cf96 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/VercelSyncReviewFields.tsx @@ -0,0 +1,23 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncReviewFields = () => { + const { watch } = useFormContext(); + const envId = watch("destinationConfig.env"); + const branchId = watch("destinationConfig.branch"); + const appName = watch("destinationConfig.appName"); + + return ( + <> + {appName} + {envId} + {envId === VercelEnvironmentType.Preview && branchId && ( + {branchId} + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 31fe6c2f5..e8f68de91 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -10,6 +10,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { VercelSyncDestinationSchema } from "./vercel-sync-destination-schema"; const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, @@ -20,7 +21,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AzureAppConfigurationSyncDestinationSchema, DatabricksSyncDestinationSchema, HumanitecSyncDestinationSchema, - CamundaSyncDestinationSchema + CamundaSyncDestinationSchema, + VercelSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts new file mode 100644 index 000000000..9d3678803 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/vercel-sync-destination-schema.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +export const VercelSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Vercel), + destinationConfig: z.object({ + app: z.string().trim().min(1, "Project required"), + appName: z.string().trim().min(1, "Project required"), + env: z.nativeEnum(VercelEnvironmentType).or(z.string()), + branch: z.string().trim().optional(), + teamId: z.string().trim() + }) + }) +); diff --git a/frontend/src/components/v2/Editor/Editor.tsx b/frontend/src/components/v2/Editor/Editor.tsx new file mode 100644 index 000000000..e9c49cbac --- /dev/null +++ b/frontend/src/components/v2/Editor/Editor.tsx @@ -0,0 +1,159 @@ +/* eslint-disable no-underscore-dangle */ +import { forwardRef, InputHTMLAttributes } from "react"; +import { InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin"; +import { ReactNode } from "@tanstack/react-router"; +import { cva, VariantProps } from "cva"; +import { EditorState, LexicalEditor } from "lexical"; +import { twMerge } from "tailwind-merge"; + +import { HighlightNode } from "./EditorHighlight"; +import { EditorPlaceholderPlugin } from "./EditorPlaceholderPlugin"; + +// Catch any errors that occur during Lexical updates and log them +// or throw them as needed. If you don't throw them, Lexical will +// try to recover gracefully without losing user data. +function onError(error: Error) { + console.error(error); +} + +const inputVariants = cva( + "input w-full py-[0.375rem] text-gray-400 placeholder:text-sm placeholder-gray-500 placeholder-opacity-50 outline-none focus:ring-2 hover:ring-bunker-400/60 duration-100", + { + variants: { + size: { + xs: ["text-xs"], + sm: ["text-sm"], + md: ["text-md"], + lg: ["text-lg"] + }, + isRounded: { + true: ["rounded-md"], + false: "" + }, + variant: { + filled: ["bg-mineshaft-900", "text-gray-400"], + outline: ["bg-transparent"], + plain: "bg-transparent outline-none" + }, + isError: { + true: "focus:ring-red/50 placeholder-red-300", + false: "focus:ring-primary-400/50 focus:ring-1" + } + }, + compoundVariants: [] + } +); + +const inputParentContainerVariants = cva("inline-flex font-inter items-center border relative", { + variants: { + isRounded: { + true: ["rounded-md"], + false: "" + }, + isError: { + true: "border-red", + false: "border-mineshaft-500" + }, + isFullWidth: { + true: "w-full", + false: "" + }, + variant: { + filled: ["bg-bunker-800", "text-gray-400"], + outline: ["bg-transparent"], + plain: "border-none" + } + } +}); + +type Props = Omit< + InputHTMLAttributes, + "size" | "onChange" | "placeholder" | "aria-placeholder" +> & + VariantProps & { + children?: ReactNode; + namespace?: string; + placeholder?: string; + isFullWidth?: boolean; + isRequired?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + isDisabled?: boolean; + isReadOnly?: boolean; + containerClassName?: string; + onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set) => void; + initialValue?: string; + }; + +export const Editor = forwardRef( + ( + { + children, + namespace = "infisical-editor", + className, + containerClassName, + isRounded = true, + isFullWidth = true, + isDisabled, + isError = false, + isRequired, + leftIcon, + rightIcon, + variant = "filled", + size = "md", + isReadOnly, + placeholder, + onChange, + ...props + }, + ref + ) => { + const initialConfig: InitialConfigType = { + namespace, + onError, + nodes: [HighlightNode] + }; + + return ( +
+ {leftIcon && {leftIcon}} + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + + + {children} + + {rightIcon && {rightIcon}} +
+ ); + } +); diff --git a/frontend/src/components/v2/Editor/EditorHighlight.tsx b/frontend/src/components/v2/Editor/EditorHighlight.tsx new file mode 100644 index 000000000..bb57828fb --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorHighlight.tsx @@ -0,0 +1,127 @@ +/* eslint-disable no-underscore-dangle,@typescript-eslint/class-methods-use-this */ +import { useCallback, useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalTextEntity } from "@lexical/react/useLexicalTextEntity"; +import { + $applyNodeReplacement, + EditorConfig, + LexicalNode, + SerializedTextNode, + Spread, + TextNode +} from "lexical"; + +type HighlightTheme = { contentClassName: string }; +type Trigger = { startTrigger: string; endTrigger: string }; + +export type SerializedHighlightNode = Spread< + { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + }, + SerializedTextNode +>; + +export class HighlightNode extends TextNode { + __highlightTheme: HighlightTheme; + __trigger: Trigger; + + constructor( + text: string, + highlightTheme: HighlightTheme = { + contentClassName: "ph-no-capture text-yellow-200/80" + }, + trigger: Trigger = { startTrigger: "${", endTrigger: "}" }, + key?: string + ) { + super(text, key); + this.__highlightTheme = highlightTheme; + this.__trigger = trigger; + } + + static getType(): string { + return "highlight"; + } + + static clone(node: HighlightNode): HighlightNode { + return new HighlightNode(node.__text, node.__highlightTheme, node.__trigger, node.__key); + } + + static importJSON(serializedNode: SerializedHighlightNode): HighlightNode { + return $applyNodeReplacement(new HighlightNode("")).updateFromJSON(serializedNode); + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + dom.style.cursor = "default"; + dom.className = this.__highlightTheme.contentClassName; + return dom; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + isTextEntity(): true { + return true; + } +} + +export function $createKeywordNode(keyword: string = ""): HighlightNode { + return $applyNodeReplacement(new HighlightNode(keyword)); +} + +export function $isKeywordNode(node: LexicalNode | null | undefined): boolean { + return node instanceof HighlightNode; +} + +type Props = { + contentClassName?: string; + startTrigger?: string; + endTrigger?: string; +}; + +export const EditorHighlightPlugin = ({ + endTrigger = "}", + startTrigger = "${", + contentClassName = "ph-no-capture text-yellow-200/80" +}: Props) => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([HighlightNode])) { + throw new Error("HighlightsPlugin: HighlightsNode not registered on editor"); + } + }, [editor]); + + const createKeywordNode = useCallback((textNode: TextNode): HighlightNode => { + return $applyNodeReplacement( + new HighlightNode( + textNode.getTextContent(), + { contentClassName }, + { startTrigger, endTrigger } + ) + ); + }, []); + + const getKeywordMatch = useCallback((text: string) => { + for (let i = 0; i < text.length; i += 1) { + if (text.slice(i, i + 2) === startTrigger) { + const closingBracketIndex = text.indexOf(endTrigger, i + 2); + if (closingBracketIndex !== -1) { + return { start: i, end: closingBracketIndex + 1 }; + } + return null; + } + } + return null; + }, []); + + useLexicalTextEntity(getKeywordMatch, HighlightNode, createKeywordNode); + + return null; +}; diff --git a/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx new file mode 100644 index 000000000..7bddf17de --- /dev/null +++ b/frontend/src/components/v2/Editor/EditorPlaceholderPlugin.tsx @@ -0,0 +1,22 @@ +import { useEffect } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useLexicalIsTextContentEmpty } from "@lexical/react/useLexicalIsTextContentEmpty"; + +export const EditorPlaceholderPlugin = ({ placeholder }: { placeholder: string | undefined }) => { + const [editor] = useLexicalComposerContext(); + const isEmpty = useLexicalIsTextContentEmpty(editor); + + /* Set the placeholder on root. */ + useEffect(() => { + const rootElement = editor.getRootElement() as HTMLElement; + if (rootElement) { + if (isEmpty && placeholder) { + rootElement.setAttribute("placeholder", placeholder); + } else { + rootElement.removeAttribute("placeholder"); + } + } + }, [editor, isEmpty]); // eslint-disable-line + + return null; +}; diff --git a/frontend/src/components/v2/Editor/index.tsx b/frontend/src/components/v2/Editor/index.tsx new file mode 100644 index 000000000..6da88bf75 --- /dev/null +++ b/frontend/src/components/v2/Editor/index.tsx @@ -0,0 +1,2 @@ +export { Editor } from "./Editor"; +export { EditorHighlightPlugin } from "./EditorHighlight"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 092fd6697..9dcf72e40 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -11,6 +11,7 @@ export * from "./DatePicker"; export * from "./DeleteActionModal"; export * from "./Drawer"; export * from "./Dropdown"; +export * from "./Editor"; export * from "./EmailServiceSetupModal"; export * from "./EmptyState"; export * from "./FilterableSelect"; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index a28bc454b..cf87ae50e 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,7 +13,8 @@ import { HumanitecConnectionMethod, MsSqlConnectionMethod, PostgresConnectionMethod, - TAppConnection + TAppConnection, + VercelConnectionMethod } from "@app/hooks/api/appConnections/types"; export const APP_CONNECTION_MAP: Record = { @@ -30,6 +31,7 @@ export const APP_CONNECTION_MAP: Record = { [SecretSync.AzureAppConfiguration]: AppConnection.AzureAppConfiguration, [SecretSync.Databricks]: AppConnection.Databricks, [SecretSync.Humanitec]: AppConnection.Humanitec, - [SecretSync.Camunda]: AppConnection.Camunda + [SecretSync.Camunda]: AppConnection.Camunda, + [SecretSync.Vercel]: AppConnection.Vercel }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 19381e56c..ce8fea116 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -6,6 +6,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", + Vercel = "vercel", Postgres = "postgres", MsSql = "mssql", Camunda = "camunda" diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 409b73554..73d42c463 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -39,6 +39,10 @@ export type THumanitecConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Humanitec; }; +export type TVercelConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Vercel; +}; + export type TPostgresConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Postgres; }; @@ -59,6 +63,7 @@ export type TAppConnectionOption = | TAzureKeyVaultConnectionOption | TDatabricksConnectionOption | THumanitecConnectionOption + | TVercelConnectionOption | TPostgresConnectionOption | TMsSqlConnectionOption | TCamundaConnectionOption; @@ -71,6 +76,7 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnectionOption; [AppConnection.Databricks]: TDatabricksConnectionOption; [AppConnection.Humanitec]: THumanitecConnectionOption; + [AppConnection.Vercel]: TVercelConnectionOption; [AppConnection.Postgres]: TPostgresConnectionOption; [AppConnection.MsSql]: TMsSqlConnectionOption; [AppConnection.Camunda]: TCamundaConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 05cef80fb..b8a529a18 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -10,6 +10,7 @@ import { TGitHubConnection } from "./github-connection"; import { THumanitecConnection } from "./humanitec-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TPostgresConnection } from "./postgres-connection"; +import { TVercelConnection } from "./vercel-connection"; export * from "./aws-connection"; export * from "./azure-app-configuration-connection"; @@ -21,6 +22,7 @@ export * from "./github-connection"; export * from "./humanitec-connection"; export * from "./mssql-connection"; export * from "./postgres-connection"; +export * from "./vercel-connection"; export type TAppConnection = | TAwsConnection @@ -30,6 +32,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TDatabricksConnection | THumanitecConnection + | TVercelConnection | TPostgresConnection | TMsSqlConnection | TCamundaConnection; @@ -67,6 +70,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; + [AppConnection.Vercel]: TVercelConnection; [AppConnection.Postgres]: TPostgresConnection; [AppConnection.MsSql]: TMsSqlConnection; [AppConnection.Camunda]: TCamundaConnection; diff --git a/frontend/src/hooks/api/appConnections/types/vercel-connection.ts b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts new file mode 100644 index 000000000..d733639ab --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/vercel-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum VercelConnectionMethod { + ApiToken = "api-token" +} + +export type TVercelConnection = TRootAppConnection & { app: AppConnection.Vercel } & { + method: VercelConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/index.ts b/frontend/src/hooks/api/appConnections/vercel/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/vercel/queries.tsx b/frontend/src/hooks/api/appConnections/vercel/queries.tsx new file mode 100644 index 000000000..fa66adcdb --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/queries.tsx @@ -0,0 +1,37 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { appConnectionKeys } from "../queries"; +import { TVercelConnectionOrganization } from "./types"; + +const vercelConnectionKeys = { + all: [...appConnectionKeys.all, "vercel"] as const, + listOrganizations: (connectionId: string) => + [...vercelConnectionKeys.all, "organizations", connectionId] as const +}; + +export const useVercelConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TVercelConnectionOrganization[], + unknown, + TVercelConnectionOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: vercelConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/vercel/${connectionId}/projects` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/vercel/types.ts b/frontend/src/hooks/api/appConnections/vercel/types.ts new file mode 100644 index 000000000..1e25ce9ee --- /dev/null +++ b/frontend/src/hooks/api/appConnections/vercel/types.ts @@ -0,0 +1,30 @@ +export type TVercelApp = { + id: string; + name: string; + envs: { id: string; name: string }[]; +}; + +export type TVercelConnectionEnvironment = { + id: string; + slug: string; + type: string; + target?: string[]; + gitBranch?: string; + createdAt?: number; + updatedAt?: number; +}; + +export type TVercelConnectionApp = { + id: string; + name: string; + envs?: TVercelConnectionEnvironment[]; + previewBranches?: string[]; + projectId: string; +}; + +export type TVercelConnectionOrganization = { + id: string; + name: string; + slug: string; + apps: TVercelConnectionApp[]; +}; diff --git a/frontend/src/hooks/api/identities/index.tsx b/frontend/src/hooks/api/identities/index.tsx index 261556752..f3b9fa012 100644 --- a/frontend/src/hooks/api/identities/index.tsx +++ b/frontend/src/hooks/api/identities/index.tsx @@ -46,5 +46,6 @@ export { useGetIdentityTokenAuth, useGetIdentityTokensTokenAuth, useGetIdentityUniversalAuth, - useGetIdentityUniversalAuthClientSecrets + useGetIdentityUniversalAuthClientSecrets, + useSearchIdentities } from "./queries"; diff --git a/frontend/src/hooks/api/identities/queries.tsx b/frontend/src/hooks/api/identities/queries.tsx index 005882359..6b8a1d1cc 100644 --- a/frontend/src/hooks/api/identities/queries.tsx +++ b/frontend/src/hooks/api/identities/queries.tsx @@ -15,11 +15,13 @@ import { IdentityMembershipOrg, IdentityOidcAuth, IdentityTokenAuth, - IdentityUniversalAuth + IdentityUniversalAuth, + TSearchIdentitiesDTO } from "./types"; export const identitiesKeys = { getIdentityById: (identityId: string) => [{ identityId }, "identity"] as const, + searchIdentities: (dto: TSearchIdentitiesDTO) => ["identity", "search", dto] as const, getIdentityUniversalAuth: (identityId: string) => [{ identityId }, "identity-universal-auth"] as const, getIdentityUniversalAuthClientSecrets: (identityId: string) => @@ -53,6 +55,26 @@ export const useGetIdentityById = (identityId: string) => { }); }; +export const useSearchIdentities = (dto: TSearchIdentitiesDTO) => { + const { limit, search, offset, orderBy, orderDirection } = dto; + return useQuery({ + queryKey: identitiesKeys.searchIdentities(dto), + queryFn: async () => { + const { data } = await apiRequest.post<{ + identities: IdentityMembershipOrg[]; + totalCount: number; + }>("/api/v1/identities/search", { + limit, + offset, + orderBy, + orderDirection, + search + }); + return data; + } + }); +}; + export const useGetIdentityProjectMemberships = (identityId: string) => { return useQuery({ enabled: Boolean(identityId), diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 35a9870bc..ca06219aa 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -1,3 +1,5 @@ +import { OrderByDirection } from "../generic/types"; +import { OrgIdentityOrderBy } from "../organization/types"; import { TOrgRole } from "../roles/types"; import { ProjectUserMembershipTemporaryMode, Workspace } from "../workspace/types"; import { IdentityAuthMethod, IdentityJwtConfigurationType } from "./enums"; @@ -540,3 +542,14 @@ export type TProjectIdentitiesList = { identityMemberships: IdentityMembership[]; totalCount: number; }; + +export type TSearchIdentitiesDTO = { + limit?: number; + offset?: number; + orderBy?: OrgIdentityOrderBy; + orderDirection?: OrderByDirection; + search: { + name?: { $contains: string }; + role?: { $in: string[] }; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 9a5b7208b..9fa5f6c99 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -7,7 +7,8 @@ export enum SecretSync { AzureAppConfiguration = "azure-app-configuration", Databricks = "databricks", Humanitec = "humanitec", - Camunda = "camunda" + Camunda = "camunda", + Vercel = "vercel" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f0049eab2..207ab0118 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TCamundaSync } from "./camunda-sync"; import { TGcpSync } from "./gcp-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TVercelSync } from "./vercel-sync"; export type TSecretSyncOption = { name: string; @@ -26,7 +27,8 @@ export type TSecretSync = | TAzureAppConfigurationSync | TDatabricksSync | THumanitecSync - | TCamundaSync; + | TCamundaSync + | TVercelSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts new file mode 100644 index 000000000..ffae61e23 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/vercel-sync.ts @@ -0,0 +1,27 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export const VercelEnvironmentType = { + Development: "development", + Preview: "preview", + Production: "production" +} as const; + +export type VercelEnvironment = (typeof VercelEnvironmentType)[keyof typeof VercelEnvironmentType]; + +export type TVercelSync = TRootSecretSync & { + destination: SecretSync.Vercel; + destinationConfig: { + app: string; + env: VercelEnvironment | string; + branch?: string; + appName?: string; + teamId: string; + }; + connection: { + app: AppConnection.Vercel; + name: string; + id: string; + }; +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 3c9610c04..1c5b0c465 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -191,3 +191,10 @@ html { #nprogress .bar { @apply bg-primary-400; } + +[contentEditable="true"]:before { + content: attr(placeholder); + position: absolute; + top: 0.5rem; + @apply text-sm text-gray-500 opacity-50; +} diff --git a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 3e6ca67f6..a9242c252 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -33,14 +33,12 @@ import { IdentityPanel } from "@app/pages/admin/OverviewPage/components/Identity import { AuthPanel } from "./components/AuthPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; -import { RateLimitPanel } from "./components/RateLimitPanel"; import { UserPanel } from "./components/UserPanel"; enum TabSections { Settings = "settings", Encryption = "encryption", Auth = "auth", - RateLimit = "rate-limit", Integrations = "integrations", Users = "users", Identities = "identities", @@ -163,7 +161,6 @@ export const OverviewPage = () => { General Encryption Authentication - Rate Limit Integrations User Identities Machine Identities @@ -262,7 +259,6 @@ export const OverviewPage = () => { { - console.log("clearing"); onChange(""); }} > @@ -403,9 +399,6 @@ export const OverviewPage = () => { - - - diff --git a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx deleted file mode 100644 index 74264132e..000000000 --- a/frontend/src/pages/admin/OverviewPage/components/RateLimitPanel.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { createNotification } from "@app/components/notifications"; -import { Button, ContentLoader, FormControl, Input } from "@app/components/v2"; -import { useSubscription } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetRateLimit, useUpdateRateLimit } from "@app/hooks/api"; - -const formSchema = z.object({ - readRateLimit: z.number(), - writeRateLimit: z.number(), - secretsRateLimit: z.number(), - authRateLimit: z.number(), - inviteUserRateLimit: z.number(), - mfaRateLimit: z.number(), - publicEndpointLimit: z.number() -}); - -type TRateLimitForm = z.infer; - -export const RateLimitPanel = () => { - const { data: rateLimit, isPending } = useGetRateLimit(); - const { subscription } = useSubscription(); - const { mutateAsync: updateRateLimit } = useUpdateRateLimit(); - const { handlePopUpToggle, handlePopUpOpen, popUp } = usePopUp(["upgradePlan"] as const); - - const { - control, - handleSubmit, - formState: { isSubmitting, isDirty } - } = useForm({ - resolver: zodResolver(formSchema), - values: { - // eslint-disable-next-line - readRateLimit: rateLimit?.readRateLimit ?? 600, - writeRateLimit: rateLimit?.writeRateLimit ?? 200, - secretsRateLimit: rateLimit?.secretsRateLimit ?? 60, - authRateLimit: rateLimit?.authRateLimit ?? 60, - inviteUserRateLimit: rateLimit?.inviteUserRateLimit ?? 30, - mfaRateLimit: rateLimit?.mfaRateLimit ?? 20, - publicEndpointLimit: rateLimit?.publicEndpointLimit ?? 30 - } - }); - - const onRateLimitFormSubmit = async (formData: TRateLimitForm) => { - try { - if (subscription && !subscription.customRateLimits) { - handlePopUpOpen("upgradePlan"); - return; - } - - const { - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - } = formData; - - await updateRateLimit({ - readRateLimit, - writeRateLimit, - secretsRateLimit, - authRateLimit, - inviteUserRateLimit, - mfaRateLimit, - publicEndpointLimit - }); - createNotification({ - text: "Rate limits have been successfully updated. Please allow at least 10 minutes for the changes to take effect.", - type: "success" - }); - } catch (e) { - console.error(e); - createNotification({ - type: "error", - text: "Failed to update rate limiting setting." - }); - } - }; - - return isPending ? ( - - ) : ( -
-
-
Configure rate limits
- ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> - ( - - field.onChange(Number(e.target.value))} - /> - - )} - /> -
- - handlePopUpToggle("upgradePlan", isOpen)} - text="You can configure custom rate limits if you switch to Infisical's Enterprise plan." - /> - - ); -}; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index cf57aebbb..ddfcc7a7c 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -1,7 +1,10 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; import { faArrowDown, faArrowUp, faEllipsis, + faFilter, faMagnifyingGlass, faServer } from "@fortawesome/free-solid-svg-icons"; @@ -12,14 +15,19 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { + Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EmptyState, + FormControl, IconButton, Input, Pagination, + Popover, + PopoverContent, + PopoverTrigger, Select, SelectItem, Spinner, @@ -30,11 +38,12 @@ import { Td, Th, THead, + Tooltip, Tr } from "@app/components/v2"; import { OrgPermissionIdentityActions, OrgPermissionSubjects, useOrganization } from "@app/context"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api"; +import { useGetOrgRoles, useSearchIdentities, useUpdateIdentity } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -68,22 +77,22 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { page, setPerPage } = usePagination(OrgIdentityOrderBy.Name); + const [filteredRoles, setFilteredRoles] = useState([]); const organizationId = currentOrg?.id || ""; const { mutateAsync: updateMutateAsync } = useUpdateIdentity(); - const { data, isPending, isFetching } = useGetIdentityMembershipOrgs( - { - organizationId, - offset, - limit, - orderDirection, - orderBy, - search: debouncedSearch - }, - { placeholderData: (prevData) => prevData } - ); + const { data, isPending, isFetching } = useSearchIdentities({ + offset, + limit, + orderDirection, + orderBy, + search: { + name: debouncedSearch ? { $contains: debouncedSearch } : undefined, + role: filteredRoles?.length ? { $in: filteredRoles } : undefined + } + }); const { totalCount = 0 } = data ?? {}; useResetPageHelper({ @@ -91,6 +100,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { offset, setPage }); + const filterForm = useForm<{ roles: string }>(); const { data: roles } = useGetOrgRoles(organizationId); @@ -132,13 +142,78 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search identities by name..." - /> +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search identities by name..." + /> +
+ + + + + + + + + +
+ Advance Filter +
+
{ + setFilteredRoles(el.roles?.split(",")?.filter(Boolean) || []); + })} + > + ( + + + + )} + /> +
+ + {Boolean(filteredRoles.length) && ( + + )} +
+ +
+
+
+
@@ -190,7 +265,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { {isPending && } {!isPending && - data?.identityMemberships.map(({ identity: { id, name }, role, customRole }) => { + data?.identities?.map(({ identity: { id, name }, role, customRole }) => { return ( { onChangePerPage={(newPerPage) => setPerPage(newPerPage)} /> )} - {!isPending && data && data?.identityMemberships.length === 0 && ( + {!isPending && data && data?.identities.length === 0 && ( 0 + debouncedSearch.trim().length > 0 || filteredRoles?.length > 0 ? "No identities match search filter" : "No identities have been created in this organization" } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index ba59b8368..a79da885b 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -258,7 +258,7 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
- Email + Username { e.stopPropagation(); - if (currentOrg?.scimEnabled) { + if (currentOrg?.scimEnabled && isActive) { createNotification({ text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", type: "error" diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index 3d142b0ad..59bdd581f 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -19,6 +19,7 @@ import { GitHubConnectionForm } from "./GitHubConnectionForm"; import { HumanitecConnectionForm } from "./HumanitecConnectionForm"; import { MsSqlConnectionForm } from "./MsSqlConnectionForm"; import { PostgresConnectionForm } from "./PostgresConnectionForm"; +import { VercelConnectionForm } from "./VercelConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -71,6 +72,8 @@ const CreateForm = ({ app, onComplete }: CreateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: @@ -127,6 +130,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Humanitec: return ; + case AppConnection.Vercel: + return ; case AppConnection.Postgres: return ; case AppConnection.MsSql: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx new file mode 100644 index 000000000..d655a5c6a --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/VercelConnectionForm.tsx @@ -0,0 +1,135 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { + TVercelConnection, + VercelConnectionMethod +} from "@app/hooks/api/appConnections/types/vercel-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TVercelConnection; + onSubmit: (formData: FormData) => void; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Vercel) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(VercelConnectionMethod.ApiToken), + credentials: z.object({ + apiToken: z.string().trim().min(1, "Service API Token required") + }) + }) +]); + +type FormData = z.infer; + +export const VercelConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Vercel, + method: VercelConnectionMethod.ApiToken + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx index fa4249fc4..d50baf135 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/AddMemberModal.tsx @@ -115,8 +115,29 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => { }); } else { const inviteeEmails = selectedMembers - .map((member) => member?.user.username as string) - .filter(Boolean); + .map((member) => { + if (!member) return null; + + if (member.user.email) { + return member.user.email; + } + + if (member.user.username) { + return member.user.username; + } + + return null; + }) + .filter(Boolean) as string[]; + + if (inviteeEmails.length !== selectedMembers.length) { + createNotification({ + text: "Failed to add users to project. One or more users were invalid.", + type: "error" + }); + return; + } + if (inviteeEmails.length || newInvitees.length) { await addMembersToProject({ inviteeEmails: [...inviteeEmails, ...newInvitees], diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index 80ae5dd2e..825a34a33 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -86,7 +86,20 @@ const Page = () => { title={identityMembershipDetails?.identity?.name} description={`Identity joined on ${identityMembershipDetails?.createdAt && formatRelative(new Date(identityMembershipDetails?.createdAt || ""), new Date())}`} > -
+
+ { return ; case SecretSync.Camunda: return ; + case SecretSync.Vercel: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx new file mode 100644 index 000000000..a0c267a89 --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/VercelSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TVercelSync } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 0c4c788e3..9671bfacb 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -77,6 +77,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.clusterUUID; secondaryText = "Cluster"; break; + case SecretSync.Vercel: + primaryText = destinationConfig.appName || destinationConfig.app; + secondaryText = destinationConfig.env; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 4e149b2d3..9b8ff36a7 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -19,6 +19,7 @@ import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinat import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; import { GcpSyncDestinationSection } from "./GcpSyncDestinationSection"; import { HumanitecSyncDestinationSection } from "./HumanitecSyncDestinationSection"; +import { VercelSyncDestinationSection } from "./VercelSyncDestinationSection"; type Props = { secretSync: TSecretSync; @@ -61,6 +62,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.Camunda: DestinationComponents = ; break; + case SecretSync.Vercel: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx new file mode 100644 index 000000000..bc4a8531d --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/VercelSyncDestinationSection.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from "react"; + +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { TVercelSync, VercelEnvironmentType } from "@app/hooks/api/secretSyncs/types/vercel-sync"; + +type Props = { + secretSync: TVercelSync; +}; + +export const VercelSyncDestinationSection = ({ secretSync }: Props) => { + const { destinationConfig } = secretSync; + + let Components: ReactNode; + if (destinationConfig.env === VercelEnvironmentType.Preview && destinationConfig.branch) { + Components = ( + <> + + {destinationConfig.appName || destinationConfig.app} + + {destinationConfig.env} + {destinationConfig.branch} + + ); + } else { + Components = ( + <> + + {destinationConfig.appName || destinationConfig.app} + + {destinationConfig.env} + + ); + } + + return Components; +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 99f313571..eb3dad61a 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -49,6 +49,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Databricks: case SecretSync.Humanitec: case SecretSync.Camunda: + case SecretSync.Vercel: AdditionalSyncOptionsComponent = null; break; default: diff --git a/helm-charts/secrets-operator/Chart.yaml b/helm-charts/secrets-operator/Chart.yaml index fea5a1006..c8bfc3539 100644 --- a/helm-charts/secrets-operator/Chart.yaml +++ b/helm-charts/secrets-operator/Chart.yaml @@ -13,9 +13,9 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: v0.9.0 +version: v0.9.1 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "v0.9.0" +appVersion: "v0.9.1" diff --git a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicaldynamicsecret-crd.yaml index 9030680e7..505a9ef04 100644 --- a/helm-charts/secrets-operator/templates/infisicaldynamicsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicaldynamicsecret-crd.yaml @@ -74,6 +74,13 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set to + `true`, the operator will automatically create a service account + token for the configured service account. This field is recommended + in most cases. + type: boolean identityId: type: string serviceAccountRef: @@ -86,6 +93,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account token. + This is only relevant if `autoCreateServiceAccountToken` is + true. + items: + type: string + type: array required: - identityId - serviceAccountRef diff --git a/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml index 0c73101a3..5afb4f519 100644 --- a/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalpushsecret-crd.yaml @@ -74,6 +74,13 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set to + `true`, the operator will automatically create a service account + token for the configured service account. This field is recommended + in most cases. + type: boolean identityId: type: string serviceAccountRef: @@ -86,6 +93,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account token. + This is only relevant if `autoCreateServiceAccountToken` is + true. + items: + type: string + type: array required: - identityId - serviceAccountRef diff --git a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml index da6edab68..a8a729ded 100644 --- a/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml +++ b/helm-charts/secrets-operator/templates/infisicalsecret-crd.yaml @@ -137,6 +137,12 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set to + `true`, the operator will automatically create a service account + token for the configured service account. + type: boolean identityId: type: string secretsScope: @@ -164,6 +170,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account token. + This is only relevant if `autoCreateServiceAccountToken` is + true. + items: + type: string + type: array required: - identityId - secretsScope diff --git a/helm-charts/secrets-operator/templates/manager-rbac.yaml b/helm-charts/secrets-operator/templates/manager-rbac.yaml index f47ebd64c..3ab1a7409 100644 --- a/helm-charts/secrets-operator/templates/manager-rbac.yaml +++ b/helm-charts/secrets-operator/templates/manager-rbac.yaml @@ -23,6 +23,13 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list - apiGroups: - "" resources: @@ -42,6 +49,12 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create - apiGroups: - apps resources: @@ -62,6 +75,12 @@ rules: - list - update - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create - apiGroups: - secrets.infisical.com resources: diff --git a/helm-charts/secrets-operator/values.yaml b/helm-charts/secrets-operator/values.yaml index 80ed74356..cb96f3959 100644 --- a/helm-charts/secrets-operator/values.yaml +++ b/helm-charts/secrets-operator/values.yaml @@ -32,7 +32,7 @@ controllerManager: - ALL image: repository: infisical/kubernetes-operator - tag: v0.9.0 + tag: v0.9.1 resources: limits: cpu: 500m diff --git a/k8-operator/.gitignore b/k8-operator/.gitignore index e917e5cef..8f80e8ef5 100644 --- a/k8-operator/.gitignore +++ b/k8-operator/.gitignore @@ -24,3 +24,6 @@ Dockerfile.cross *.swp *.swo *~ + +# Testing directories +auto-token \ No newline at end of file diff --git a/k8-operator/api/v1alpha1/common.go b/k8-operator/api/v1alpha1/common.go index 2489b0b95..2362857d8 100644 --- a/k8-operator/api/v1alpha1/common.go +++ b/k8-operator/api/v1alpha1/common.go @@ -49,6 +49,14 @@ type GenericKubernetesAuth struct { IdentityID string `json:"identityId"` // +kubebuilder:validation:Required ServiceAccountRef KubernetesServiceAccountRef `json:"serviceAccountRef"` + + // Optionally automatically create a service account token for the configured service account. + // If this is set to `true`, the operator will automatically create a service account token for the configured service account. This field is recommended in most cases. + // +kubebuilder:validation:Optional + AutoCreateServiceAccountToken bool `json:"autoCreateServiceAccountToken"` + // The audiences to use for the service account token. This is only relevant if `autoCreateServiceAccountToken` is true. + // +kubebuilder:validation:Optional + ServiceAccountTokenAudiences []string `json:"serviceAccountTokenAudiences"` } type TLSConfig struct { diff --git a/k8-operator/api/v1alpha1/infisicalsecret_types.go b/k8-operator/api/v1alpha1/infisicalsecret_types.go index e90c06938..ff26a878c 100644 --- a/k8-operator/api/v1alpha1/infisicalsecret_types.go +++ b/k8-operator/api/v1alpha1/infisicalsecret_types.go @@ -38,6 +38,14 @@ type KubernetesAuthDetails struct { // +kubebuilder:validation:Required SecretsScope MachineIdentityScopeInWorkspace `json:"secretsScope"` + + // Optionally automatically create a service account token for the configured service account. + // If this is set to `true`, the operator will automatically create a service account token for the configured service account. + // +kubebuilder:validation:Optional + AutoCreateServiceAccountToken bool `json:"autoCreateServiceAccountToken"` + // The audiences to use for the service account token. This is only relevant if `autoCreateServiceAccountToken` is true. + // +kubebuilder:validation:Optional + ServiceAccountTokenAudiences []string `json:"serviceAccountTokenAudiences"` } type KubernetesServiceAccountRef struct { diff --git a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go index 2ad97108d..4958e9c76 100644 --- a/k8-operator/api/v1alpha1/zz_generated.deepcopy.go +++ b/k8-operator/api/v1alpha1/zz_generated.deepcopy.go @@ -48,7 +48,7 @@ func (in *Authentication) DeepCopyInto(out *Authentication) { out.ServiceAccount = in.ServiceAccount out.ServiceToken = in.ServiceToken out.UniversalAuth = in.UniversalAuth - out.KubernetesAuth = in.KubernetesAuth + in.KubernetesAuth.DeepCopyInto(&out.KubernetesAuth) out.AwsIamAuth = in.AwsIamAuth out.AzureAuth = in.AzureAuth out.GcpIdTokenAuth = in.GcpIdTokenAuth @@ -207,7 +207,7 @@ func (in *GenericGcpIdTokenAuth) DeepCopy() *GenericGcpIdTokenAuth { func (in *GenericInfisicalAuthentication) DeepCopyInto(out *GenericInfisicalAuthentication) { *out = *in out.UniversalAuth = in.UniversalAuth - out.KubernetesAuth = in.KubernetesAuth + in.KubernetesAuth.DeepCopyInto(&out.KubernetesAuth) out.AwsIamAuth = in.AwsIamAuth out.AzureAuth = in.AzureAuth out.GcpIdTokenAuth = in.GcpIdTokenAuth @@ -228,6 +228,11 @@ func (in *GenericInfisicalAuthentication) DeepCopy() *GenericInfisicalAuthentica func (in *GenericKubernetesAuth) DeepCopyInto(out *GenericKubernetesAuth) { *out = *in out.ServiceAccountRef = in.ServiceAccountRef + if in.ServiceAccountTokenAudiences != nil { + in, out := &in.ServiceAccountTokenAudiences, &out.ServiceAccountTokenAudiences + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GenericKubernetesAuth. @@ -336,7 +341,7 @@ func (in *InfisicalDynamicSecretList) DeepCopyObject() runtime.Object { func (in *InfisicalDynamicSecretSpec) DeepCopyInto(out *InfisicalDynamicSecretSpec) { *out = *in in.ManagedSecretReference.DeepCopyInto(&out.ManagedSecretReference) - out.Authentication = in.Authentication + in.Authentication.DeepCopyInto(&out.Authentication) out.DynamicSecret = in.DynamicSecret out.TLS = in.TLS } @@ -476,7 +481,7 @@ func (in *InfisicalPushSecretSecretSource) DeepCopy() *InfisicalPushSecretSecret func (in *InfisicalPushSecretSpec) DeepCopyInto(out *InfisicalPushSecretSpec) { *out = *in out.Destination = in.Destination - out.Authentication = in.Authentication + in.Authentication.DeepCopyInto(&out.Authentication) in.Push.DeepCopyInto(&out.Push) out.TLS = in.TLS } @@ -583,7 +588,7 @@ func (in *InfisicalSecretList) DeepCopyObject() runtime.Object { func (in *InfisicalSecretSpec) DeepCopyInto(out *InfisicalSecretSpec) { *out = *in out.TokenSecretReference = in.TokenSecretReference - out.Authentication = in.Authentication + in.Authentication.DeepCopyInto(&out.Authentication) in.ManagedSecretReference.DeepCopyInto(&out.ManagedSecretReference) if in.ManagedKubeSecretReferences != nil { in, out := &in.ManagedKubeSecretReferences, &out.ManagedKubeSecretReferences @@ -654,6 +659,11 @@ func (in *KubernetesAuthDetails) DeepCopyInto(out *KubernetesAuthDetails) { *out = *in out.ServiceAccountRef = in.ServiceAccountRef out.SecretsScope = in.SecretsScope + if in.ServiceAccountTokenAudiences != nil { + in, out := &in.ServiceAccountTokenAudiences, &out.ServiceAccountTokenAudiences + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubernetesAuthDetails. diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicaldynamicsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicaldynamicsecrets.yaml index c1cb7255d..341155efb 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicaldynamicsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicaldynamicsecrets.yaml @@ -73,6 +73,13 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set + to `true`, the operator will automatically create a service + account token for the configured service account. This field + is recommended in most cases. + type: boolean identityId: type: string serviceAccountRef: @@ -85,6 +92,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account + token. This is only relevant if `autoCreateServiceAccountToken` + is true. + items: + type: string + type: array required: - identityId - serviceAccountRef diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml index 25fafd98a..37df72854 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalpushsecrets.yaml @@ -73,6 +73,13 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set + to `true`, the operator will automatically create a service + account token for the configured service account. This field + is recommended in most cases. + type: boolean identityId: type: string serviceAccountRef: @@ -85,6 +92,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account + token. This is only relevant if `autoCreateServiceAccountToken` + is true. + items: + type: string + type: array required: - identityId - serviceAccountRef diff --git a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml index 49ed43a77..8012521d9 100644 --- a/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml +++ b/k8-operator/config/crd/bases/secrets.infisical.com_infisicalsecrets.yaml @@ -136,6 +136,12 @@ spec: type: object kubernetesAuth: properties: + autoCreateServiceAccountToken: + description: Optionally automatically create a service account + token for the configured service account. If this is set + to `true`, the operator will automatically create a service + account token for the configured service account. + type: boolean identityId: type: string secretsScope: @@ -163,6 +169,13 @@ spec: - name - namespace type: object + serviceAccountTokenAudiences: + description: The audiences to use for the service account + token. This is only relevant if `autoCreateServiceAccountToken` + is true. + items: + type: string + type: array required: - identityId - secretsScope diff --git a/k8-operator/config/rbac/role.yaml b/k8-operator/config/rbac/role.yaml index ab0b4463a..542face87 100644 --- a/k8-operator/config/rbac/role.yaml +++ b/k8-operator/config/rbac/role.yaml @@ -16,6 +16,13 @@ rules: - list - update - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list - apiGroups: - "" resources: @@ -35,6 +42,12 @@ rules: - get - list - watch +- apiGroups: + - "" + resources: + - serviceaccounts/token + verbs: + - create - apiGroups: - apps resources: @@ -55,6 +68,12 @@ rules: - list - update - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create - apiGroups: - secrets.infisical.com resources: diff --git a/k8-operator/controllers/infisicaldynamicsecret/infisicaldynamicsecret_controller.go b/k8-operator/controllers/infisicaldynamicsecret/infisicaldynamicsecret_controller.go index 5d2470067..a65676739 100644 --- a/k8-operator/controllers/infisicaldynamicsecret/infisicaldynamicsecret_controller.go +++ b/k8-operator/controllers/infisicaldynamicsecret/infisicaldynamicsecret_controller.go @@ -45,6 +45,9 @@ func (r *InfisicalDynamicSecretReconciler) GetLogger(req ctrl.Request) logr.Logg // +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete // +kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;update // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch +//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list +//+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create +//+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create func (r *InfisicalDynamicSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { diff --git a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go index a5526bd6c..ebf537a63 100644 --- a/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go +++ b/k8-operator/controllers/infisicalpushsecret/infisicalpushsecret_controller.go @@ -48,6 +48,9 @@ func (r *InfisicalPushSecretReconciler) GetLogger(req ctrl.Request) logr.Logger //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups=apps,resources=deployments,verbs=list;watch;get;update //+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch +//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list +//+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create +//+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go b/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go index 301f3856f..bf7d75830 100644 --- a/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go +++ b/k8-operator/controllers/infisicalsecret/infisicalsecret_controller.go @@ -44,6 +44,9 @@ func (r *InfisicalSecretReconciler) GetLogger(req ctrl.Request) logr.Logger { //+kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;delete //+kubebuilder:rbac:groups=apps,resources=deployments;daemonsets;statefulsets,verbs=list;watch;get;update //+kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch +//+kubebuilder:rbac:groups="",resources=pods,verbs=get;list +//+kubebuilder:rbac:groups="authentication.k8s.io",resources=tokenreviews,verbs=create +//+kubebuilder:rbac:groups="",resources=serviceaccounts/token,verbs=create // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. diff --git a/k8-operator/packages/util/auth.go b/k8-operator/packages/util/auth.go index f4f9348f1..7305ca45e 100644 --- a/k8-operator/packages/util/auth.go +++ b/k8-operator/packages/util/auth.go @@ -8,12 +8,51 @@ import ( corev1 "k8s.io/api/core/v1" + authenticationv1 "k8s.io/api/authentication/v1" + "github.com/Infisical/infisical/k8-operator/api/v1alpha1" + "github.com/aws/smithy-go/ptr" infisicalSdk "github.com/infisical/go-sdk" "sigs.k8s.io/controller-runtime/pkg/client" ) -func GetServiceAccountToken(k8sClient client.Client, namespace string, serviceAccountName string) (string, error) { +func GetServiceAccountToken(k8sClient client.Client, namespace string, serviceAccountName string, autoCreateServiceAccountToken bool, serviceAccountTokenAudiences []string) (string, error) { + + if autoCreateServiceAccountToken { + restClient, err := GetRestClientFromClient() + if err != nil { + return "", fmt.Errorf("failed to get REST client: %w", err) + } + + tokenRequest := &authenticationv1.TokenRequest{ + Spec: authenticationv1.TokenRequestSpec{ + ExpirationSeconds: ptr.Int64(600), // 10 minutes. the token only needs to be valid for when we do the initial k8s login. + }, + } + + if len(serviceAccountTokenAudiences) > 0 { + // Conditionally add the audiences if they are specified. + // Failing to do this causes a default audience to be used, which is not what we want if the user doesn't specify any. + tokenRequest.Spec.Audiences = serviceAccountTokenAudiences + } + + result := &authenticationv1.TokenRequest{} + err = restClient. + Post(). + Namespace(namespace). + Resource("serviceaccounts"). + Name(serviceAccountName). + SubResource("token"). + Body(tokenRequest). + Do(context.Background()). + Into(result) + + if err != nil { + return "", fmt.Errorf("failed to create token: %w", err) + } + + return result.Status.Token, nil + } serviceAccount := &corev1.ServiceAccount{} err := k8sClient.Get(context.TODO(), client.ObjectKey{Name: serviceAccountName, Namespace: namespace}, serviceAccount) @@ -172,7 +211,9 @@ func HandleKubernetesAuth(ctx context.Context, reconcilerClient client.Client, s Namespace: infisicalPushSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Namespace, Name: infisicalPushSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Name, }, - SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + AutoCreateServiceAccountToken: infisicalPushSecret.Spec.Authentication.KubernetesAuth.AutoCreateServiceAccountToken, + ServiceAccountTokenAudiences: infisicalPushSecret.Spec.Authentication.KubernetesAuth.ServiceAccountTokenAudiences, } case SecretCrd.INFISICAL_DYNAMIC_SECRET: @@ -188,7 +229,9 @@ func HandleKubernetesAuth(ctx context.Context, reconcilerClient client.Client, s Namespace: infisicalDynamicSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Namespace, Name: infisicalDynamicSecret.Spec.Authentication.KubernetesAuth.ServiceAccountRef.Name, }, - SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + SecretsScope: v1alpha1.MachineIdentityScopeInWorkspace{}, + AutoCreateServiceAccountToken: infisicalDynamicSecret.Spec.Authentication.KubernetesAuth.AutoCreateServiceAccountToken, + ServiceAccountTokenAudiences: infisicalDynamicSecret.Spec.Authentication.KubernetesAuth.ServiceAccountTokenAudiences, } } @@ -196,7 +239,14 @@ func HandleKubernetesAuth(ctx context.Context, reconcilerClient client.Client, s return AuthenticationDetails{}, ErrAuthNotApplicable } - serviceAccountToken, err := GetServiceAccountToken(reconcilerClient, kubernetesAuthSpec.ServiceAccountRef.Namespace, kubernetesAuthSpec.ServiceAccountRef.Name) + serviceAccountToken, err := GetServiceAccountToken( + reconcilerClient, + kubernetesAuthSpec.ServiceAccountRef.Namespace, + kubernetesAuthSpec.ServiceAccountRef.Name, + kubernetesAuthSpec.AutoCreateServiceAccountToken, + kubernetesAuthSpec.ServiceAccountTokenAudiences, + ) + if err != nil { return AuthenticationDetails{}, fmt.Errorf("unable to get service account token [err=%s]", err) } diff --git a/k8-operator/packages/util/kubernetes.go b/k8-operator/packages/util/kubernetes.go index 75d5d6bc4..b11c0a623 100644 --- a/k8-operator/packages/util/kubernetes.go +++ b/k8-operator/packages/util/kubernetes.go @@ -9,6 +9,9 @@ import ( corev1 "k8s.io/api/core/v1" k8Errors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -58,3 +61,32 @@ func GetInfisicalUniversalAuthFromKubeSecret(ctx context.Context, reconcilerClie return model.MachineIdentityDetails{ClientId: string(clientIdFromSecret), ClientSecret: string(clientSecretFromSecret)}, nil } + +func getKubeClusterConfig() (*rest.Config, error) { + config, err := rest.InClusterConfig() + if err != nil { + + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + return kubeConfig.ClientConfig() + } + + return config, nil +} + +func GetRestClientFromClient() (rest.Interface, error) { + + config, err := getKubeClusterConfig() + if err != nil { + return nil, err + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + return clientset.CoreV1().RESTClient(), nil + +}