diff --git a/.github/workflows/release_build_infisical_cli.yml b/.github/workflows/release_build_infisical_cli.yml index d01d56198..40137a455 100644 --- a/.github/workflows/release_build_infisical_cli.yml +++ b/.github/workflows/release_build_infisical_cli.yml @@ -1,60 +1,64 @@ name: Build and release CLI on: - push: - # run only against tags - tags: - - "infisical-cli/v*.*.*" + push: + # run only against tags + tags: + - "infisical-cli/v*.*.*" permissions: - contents: write - # packages: write - # issues: write - + contents: write + # packages: write + # issues: write jobs: - goreleaser: - runs-on: ubuntu-20.04 - 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: libssl1.1 => libssl1.0-dev for OSXCross - run: | - echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list - sudo apt update && apt-cache policy libssl1.0-dev - sudo apt-get install 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: latest - 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 }} + cli-integration-tests: + name: Run tests before deployment + uses: ./.github/workflows/run-cli-tests.yml + + goreleaser: + runs-on: ubuntu-20.04 + 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: libssl1.1 => libssl1.0-dev for OSXCross + run: | + echo 'deb http://security.ubuntu.com/ubuntu bionic-security main' | sudo tee -a /etc/apt/sources.list + sudo apt update && apt-cache policy libssl1.0-dev + sudo apt-get install 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: latest + 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 }} diff --git a/.github/workflows/run-cli-tests.yml b/.github/workflows/run-cli-tests.yml new file mode 100644 index 000000000..846998528 --- /dev/null +++ b/.github/workflows/run-cli-tests.yml @@ -0,0 +1,34 @@ +name: Go CLI Tests + +on: + pull_request: + types: [opened, synchronize] + paths: + - "cli/**" + + workflow_call: + +jobs: + test: + defaults: + run: + working-directory: ./cli + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: "1.21.x" + - name: Install dependencies + run: go get . + - name: Test with the Go CLI + env: + 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 }} + + run: go test -v -count=1 ./test diff --git a/.gitignore b/.gitignore index 4a12c15f2..b04860071 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ yarn-error.log* frontend-build *.tgz +cli/infisical-merge +cli/test/infisical-merge diff --git a/backend/package-lock.json b/backend/package-lock.json index 1ef6c19e2..98b15e5f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "bullmq": "^5.3.3", + "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", @@ -4565,6 +4566,15 @@ "@types/lodash": "*" } }, + "node_modules/@types/long": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/long/-/long-5.0.0.tgz", + "integrity": "sha512-eQs9RsucA/LNjnMoJvWG/nXa7Pot/RbBzilF/QRIU/xRl+0ApxrSUFsV5lmf01SvSlqMzJ7Zwxe440wmz2SJGA==", + "deprecated": "This is a stub types definition. long provides its own type definitions, so you do not need this installed.", + "dependencies": { + "long": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5313,6 +5323,14 @@ "node": ">=0.4.0" } }, + "node_modules/adm-zip": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.12.tgz", + "integrity": "sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ==", + "engines": { + "node": ">=6.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6190,6 +6208,20 @@ "node": ">=6" } }, + "node_modules/cassandra-driver": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/cassandra-driver/-/cassandra-driver-4.7.2.tgz", + "integrity": "sha512-gwl1DeYvL8Wy3i1GDMzFtpUg5G473fU7EnHFZj7BUtdLB7loAfgZgB3zBhROc9fbaDSUDs6YwOPPojS5E1kbSA==", + "dependencies": { + "@types/long": "~5.0.0", + "@types/node": ">=8", + "adm-zip": "~0.5.10", + "long": "~5.2.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/chai": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", diff --git a/backend/package.json b/backend/package.json index f53ec9329..0f7b5a590 100644 --- a/backend/package.json +++ b/backend/package.json @@ -96,6 +96,7 @@ "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "bullmq": "^5.3.3", + "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts new file mode 100644 index 000000000..aea0b9c99 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -0,0 +1,125 @@ +import cassandra from "cassandra-driver"; +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const CassandraProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs); + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { + throw new BadRequestError({ message: "Invalid db host" }); + } + + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const client = new cassandra.Client({ + sslOptions, + protocolOptions: { + port: providerInputs.port + }, + credentials: { + username: providerInputs.username, + password: providerInputs.password + }, + keyspace: providerInputs.keyspace, + localDataCenter: providerInputs?.localDataCenter, + contactPoints: providerInputs.host.split(",").filter(Boolean) + }); + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true); + await client.shutdown(); + return isConnected; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + const { keyspace } = providerInputs; + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + keyspace + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const { keyspace } = providerInputs; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + const { keyspace } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace, expiration }); + const queries = renewStatement.toString().split(";").filter(Boolean); + for (const query of queries) { + // eslint-disable-next-line + await client.execute(query); + } + await client.shutdown(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index d66e60802..34c049553 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,6 +1,8 @@ +import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ - [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider() + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), + [DynamicSecretProviders.Cassandra]: CassandraProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index d3510d583..edb60d4b2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -19,12 +19,27 @@ export const DynamicSecretSqlDBSchema = z.object({ ca: z.string().optional() }); +export const DynamicSecretCassandraSchema = z.object({ + host: z.string().toLowerCase(), + port: z.number(), + localDataCenter: z.string().min(1), + keyspace: z.string().optional(), + username: z.string(), + password: z.string(), + creationStatement: z.string(), + revocationStatement: z.string(), + renewStatement: z.string().optional(), + ca: z.string().optional() +}); + export enum DynamicSecretProviders { - SqlDatabase = "sql-database" + SqlDatabase = "sql-database", + Cassandra = "cassandra" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }) + z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 4c1d5438b..6745f573b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -30,19 +30,24 @@ const generateUsername = (provider: SqlProviders) => { export const SqlDatabaseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const appCfg = getConfig(); + const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); if ( + isCloud && // localhost + // internal ips + (providerInputs.host === "host.docker.internal" || + providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || + providerInputs.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Invalid db host" }); + if ( providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || // database infisical uses - dbHost === providerInputs.host || - // internal ips - providerInputs.host === "host.docker.internal" || - providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || - providerInputs.host.match(/^192\.168\.\d+\.\d+/) + dbHost === providerInputs.host ) throw new BadRequestError({ message: "Invalid db host" }); return providerInputs; @@ -93,15 +98,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { database }); - await db.transaction(async (tx) => - Promise.all( - creationStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + const queries = creationStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); await db.destroy(); return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; @@ -114,15 +117,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const { database } = providerInputs; const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); - await db.transaction(async (tx) => - Promise.all( - revokeStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + const queries = revokeStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); await db.destroy(); return { entityId: username }; @@ -137,16 +138,15 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const { database } = providerInputs; const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database }); - if (renewStatement) - await db.transaction(async (tx) => - Promise.all( - renewStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } await db.destroy(); return { entityId: username }; diff --git a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts index 8eade1626..93f63a685 100644 --- a/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts +++ b/backend/src/ee/services/secret-rotation/secret-rotation-queue/secret-rotation-queue-fn.ts @@ -90,16 +90,20 @@ export const secretRotationDbFn = async ({ const appCfg = getConfig(); const ssl = ca ? { rejectUnauthorized: false, ca } : undefined; + const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + if ( + isCloud && + // internal ips + (host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new Error("Invalid db host"); if ( host === "localhost" || host === "127.0.0.1" || // database infisical uses - dbHost === host || - // internal ips - host === "host.docker.internal" || - host.match(/^10\.\d+\.\d+\.\d+/) || - host.match(/^192\.168\.\d+\.\d+/) + dbHost === host ) throw new Error("Invalid db host"); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index c83234c1f..d890fcc35 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -585,12 +585,13 @@ export const INTEGRATION = { region: "AWS region to sync secrets to.", scope: "Scope of the provider. Used by Github, Qovery", metadata: { - secretPrefix: "The prefix for the saved secret. Used by GCP", - secretSuffix: "The suffix for the saved secret. Used by GCP", - initialSyncBehavoir: "Type of syncing behavoir with the integration", - shouldAutoRedeploy: "Used by Render to trigger auto deploy", - secretGCPLabel: "The label for the GCP secrets", - secretAWSTag: "The tag for the AWS secrets" + secretPrefix: "The prefix for the saved secret. Used by GCP.", + secretSuffix: "The suffix for the saved secret. Used by GCP.", + initialSyncBehavoir: "Type of syncing behavoir with the integration.", + shouldAutoRedeploy: "Used by Render to trigger auto deploy.", + secretGCPLabel: "The label for GCP secrets.", + secretAWSTag: "The tags for AWS secrets.", + kmsKeyId: "The ID of the encryption key from AWS KMS." } }, UPDATE: { diff --git a/backend/src/server/routes/v1/integration-auth-router.ts b/backend/src/server/routes/v1/integration-auth-router.ts index 604ac0bc2..d9db7404e 100644 --- a/backend/src/server/routes/v1/integration-auth-router.ts +++ b/backend/src/server/routes/v1/integration-auth-router.ts @@ -511,6 +511,39 @@ export const registerIntegrationAuthRouter = async (server: FastifyZodProvider) } }); + server.route({ + method: "GET", + url: "/:integrationAuthId/aws-secrets-manager/kms-keys", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + params: z.object({ + integrationAuthId: z.string().trim() + }), + querystring: z.object({ + region: z.string().trim() + }), + response: { + 200: z.object({ + kmsKeys: z.object({ id: z.string(), alias: z.string() }).array() + }) + } + }, + handler: async (req) => { + const kmsKeys = await server.services.integrationAuth.getAwsKmsKeys({ + actorId: req.permission.id, + actor: req.permission.type, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.params.integrationAuthId, + region: req.query.region + }); + return { kmsKeys }; + } + }); + server.route({ method: "GET", url: "/:integrationAuthId/qovery/projects", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 58a8917c5..f908aa1fc 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -58,14 +58,17 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { .optional() .describe(INTEGRATION.CREATE.metadata.secretGCPLabel), secretAWSTag: z - .object({ - key: z.string(), - value: z.string() - }) + .array( + z.object({ + key: z.string(), + value: z.string() + }) + ) .optional() - .describe(INTEGRATION.CREATE.metadata.secretAWSTag) + .describe(INTEGRATION.CREATE.metadata.secretAWSTag), + kmsKeyId: z.string().optional().describe(INTEGRATION.CREATE.metadata.kmsKeyId) }) - .optional() + .default({}) }), response: { 200: z.object({ diff --git a/backend/src/services/integration-auth/integration-auth-service.ts b/backend/src/services/integration-auth/integration-auth-service.ts index c8551df7d..3d42943a6 100644 --- a/backend/src/services/integration-auth/integration-auth-service.ts +++ b/backend/src/services/integration-auth/integration-auth-service.ts @@ -1,5 +1,6 @@ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/rest"; +import AWS from "aws-sdk"; import { SecretEncryptionAlgo, SecretKeyEncoding, TIntegrationAuths, TIntegrationAuthsInsert } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; @@ -23,6 +24,7 @@ import { TGetIntegrationAuthTeamCityBuildConfigDTO, THerokuPipelineCoupling, TIntegrationAuthAppsDTO, + TIntegrationAuthAwsKmsKeyDTO, TIntegrationAuthBitbucketWorkspaceDTO, TIntegrationAuthChecklyGroupsDTO, TIntegrationAuthGithubEnvsDTO, @@ -534,6 +536,52 @@ export const integrationAuthServiceFactory = ({ return data.results.map(({ name, id: orgId }) => ({ name, orgId })); }; + const getAwsKmsKeys = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + id, + region + }: TIntegrationAuthAwsKmsKeyDTO) => { + const integrationAuth = await integrationAuthDAL.findById(id); + if (!integrationAuth) throw new BadRequestError({ message: "Failed to find integration" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + integrationAuth.projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Integrations); + const botKey = await projectBotService.getBotKey(integrationAuth.projectId); + const { accessId, accessToken } = await getIntegrationAccessToken(integrationAuth, botKey); + + AWS.config.update({ + region, + credentials: { + accessKeyId: String(accessId), + secretAccessKey: accessToken + } + }); + const kms = new AWS.KMS(); + + const aliases = await kms.listAliases({}).promise(); + const keys = await kms.listKeys({}).promise(); + const response = keys + .Keys!.map((key) => { + const keyAlias = aliases.Aliases!.find((alias) => key.KeyId === alias.TargetKeyId); + if (!keyAlias?.AliasName?.includes("alias/aws/") || keyAlias?.AliasName?.includes("alias/aws/secretsmanager")) { + return { id: String(key.KeyId), alias: String(keyAlias?.AliasName || key.KeyId) }; + } + return { id: "null", alias: "null" }; + }) + .filter((elem) => elem.id !== "null"); + + return response; + }; + const getQoveryProjects = async ({ actorId, actor, @@ -1133,6 +1181,7 @@ export const integrationAuthServiceFactory = ({ getIntegrationApps, getVercelBranches, getApps, + getAwsKmsKeys, getGithubOrgs, getGithubEnvs, getChecklyGroups, diff --git a/backend/src/services/integration-auth/integration-auth-types.ts b/backend/src/services/integration-auth/integration-auth-types.ts index 0c8671fbf..0a816035c 100644 --- a/backend/src/services/integration-auth/integration-auth-types.ts +++ b/backend/src/services/integration-auth/integration-auth-types.ts @@ -63,6 +63,11 @@ export type TIntegrationAuthQoveryProjectDTO = { orgId: string; } & Omit; +export type TIntegrationAuthAwsKmsKeyDTO = { + id: string; + region: string; +} & Omit; + export type TIntegrationAuthQoveryEnvironmentsDTO = { id: string; } & TProjectPermission; diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 48226bb59..bc880c8c4 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-argument */ @@ -489,7 +490,9 @@ const syncSecretsAWSParameterStore = async ({ Type: "SecureString", Value: secrets[key].value, // Overwrite: true, - Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] }) .promise(); // case: secret exists in AWS parameter store @@ -579,7 +582,10 @@ const syncSecretsAWSSecretManager = async ({ new CreateSecretCommand({ Name: integration.app as string, SecretString: JSON.stringify(secKeyVal), - Tags: metadata.secretAWSTag ? [{ Key: metadata.secretAWSTag.key, Value: metadata.secretAWSTag.value }] : [] + KmsKeyId: metadata.kmsKeyId ? metadata.kmsKeyId : null, + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] }) ); } @@ -2151,16 +2157,29 @@ const syncSecretsQovery = async ({ * @param {String} obj.accessToken - access token for Terraform Cloud API */ const syncSecretsTerraformCloud = async ({ + createManySecretsRawFn, + updateManySecretsRawFn, integration, secrets, - accessToken + accessToken, + integrationDAL }: { - integration: TIntegrations; - secrets: Record; + createManySecretsRawFn: (params: TCreateManySecretsRawFn) => Promise>; + updateManySecretsRawFn: (params: TUpdateManySecretsRawFn) => Promise>; + integration: TIntegrations & { + projectId: string; + environment: { + id: string; + name: string; + slug: string; + }; + }; + secrets: Record; accessToken: string; + integrationDAL: Pick; }) => { // get secrets from Terraform Cloud - const getSecretsRes = ( + const terraformSecrets = ( await request.get<{ data: { attributes: { key: string; value: string }; id: string }[] }>( `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars`, { @@ -2178,9 +2197,74 @@ const syncSecretsTerraformCloud = async ({ {} as Record ); + const secretsToAdd: { [key: string]: string } = {}; + const secretsToUpdate: { [key: string]: string } = {}; + + const metadata = z.record(z.any()).parse(integration.metadata); + + Object.keys(terraformSecrets).forEach((key) => { + if (!integration.lastUsed) { + // first time using integration + // -> apply initial sync behavior + switch (metadata.initialSyncBehavior) { + case IntegrationInitialSyncBehavior.PREFER_TARGET: { + if (!(key in secrets)) { + secretsToAdd[key] = terraformSecrets[key].attributes.value; + } else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) { + secretsToUpdate[key] = terraformSecrets[key].attributes.value; + } + secrets[key] = { + value: terraformSecrets[key].attributes.value + }; + break; + } + case IntegrationInitialSyncBehavior.PREFER_SOURCE: { + if (!(key in secrets)) { + secrets[key] = { + value: terraformSecrets[key].attributes.value + }; + secretsToAdd[key] = terraformSecrets[key].attributes.value; + } + break; + } + default: { + break; + } + } + } else if (!(key in secrets)) secrets[key] = null; + }); + + if (Object.keys(secretsToAdd).length) { + await createManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToAdd).map((key) => ({ + secretName: key, + secretValue: secretsToAdd[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + + if (Object.keys(secretsToUpdate).length) { + await updateManySecretsRawFn({ + projectId: integration.projectId, + environment: integration.environment.slug, + path: integration.secretPath, + secrets: Object.keys(secretsToUpdate).map((key) => ({ + secretName: key, + secretValue: secretsToUpdate[key], + type: SecretType.Shared, + secretComment: "" + })) + }); + } + // create or update secrets on Terraform Cloud for await (const key of Object.keys(secrets)) { - if (!(key in getSecretsRes)) { + if (!(key in terraformSecrets)) { // case: secret does not exist in Terraform Cloud // -> add secret await request.post( @@ -2190,7 +2274,7 @@ const syncSecretsTerraformCloud = async ({ type: "vars", attributes: { key, - value: secrets[key].value, + value: secrets[key]?.value, category: integration.targetService } } @@ -2204,17 +2288,17 @@ const syncSecretsTerraformCloud = async ({ } ); // case: secret exists in Terraform Cloud - } else if (secrets[key].value !== getSecretsRes[key].attributes.value) { + } else if (secrets[key]?.value !== terraformSecrets[key].attributes.value) { // -> update secret await request.patch( - `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`, { data: { type: "vars", - id: getSecretsRes[key].id, + id: terraformSecrets[key].id, attributes: { - ...getSecretsRes[key], - value: secrets[key].value + ...terraformSecrets[key], + value: secrets[key]?.value } } }, @@ -2229,11 +2313,11 @@ const syncSecretsTerraformCloud = async ({ } } - for await (const key of Object.keys(getSecretsRes)) { + for await (const key of Object.keys(terraformSecrets)) { if (!(key in secrets)) { // case: delete secret await request.delete( - `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${getSecretsRes[key].id}`, + `${IntegrationUrls.TERRAFORM_CLOUD_API_URL}/api/v2/workspaces/${integration.appId}/vars/${terraformSecrets[key].id}`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -2244,6 +2328,10 @@ const syncSecretsTerraformCloud = async ({ ); } } + + await integrationDAL.updateById(integration.id, { + lastUsed: new Date() + }); }; /** @@ -3285,9 +3373,12 @@ export const syncIntegrationSecrets = async ({ break; case Integrations.TERRAFORM_CLOUD: await syncSecretsTerraformCloud({ + createManySecretsRawFn, + updateManySecretsRawFn, integration, secrets, - accessToken + accessToken, + integrationDAL }); break; case Integrations.HASHICORP_VAULT: diff --git a/backend/src/services/integration/integration-types.ts b/backend/src/services/integration/integration-types.ts index 93c36cd8e..56ea46350 100644 --- a/backend/src/services/integration/integration-types.ts +++ b/backend/src/services/integration/integration-types.ts @@ -25,7 +25,8 @@ export type TCreateIntegrationDTO = { secretAWSTag?: { key: string; value: string; - }; + }[]; + kmsKeyId?: string; }; } & Omit; diff --git a/cli/go.mod b/cli/go.mod index d3b8eff0c..833745eff 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -29,6 +29,7 @@ require ( require ( github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect + github.com/bradleyjkemp/cupaloy/v2 v2.8.0 // indirect github.com/chzyer/readline v1.5.1 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect diff --git a/cli/go.sum b/cli/go.sum index a73ac5185..353579136 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -51,6 +51,8 @@ github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGL github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= @@ -324,6 +326,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44= github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/cli/packages/api/api.go b/cli/packages/api/api.go index d45a42db4..01f29a03a 100644 --- a/cli/packages/api/api.go +++ b/cli/packages/api/api.go @@ -512,16 +512,23 @@ func CallUniversalAuthRefreshAccessToken(httpClient *resty.Client, request Unive func CallGetRawSecretsV3(httpClient *resty.Client, request GetRawSecretsV3Request) (GetRawSecretsV3Response, error) { var getRawSecretsV3Response GetRawSecretsV3Response - response, err := httpClient. + req := httpClient. R(). SetResult(&getRawSecretsV3Response). SetHeader("User-Agent", USER_AGENT). SetBody(request). SetQueryParam("workspaceId", request.WorkspaceId). SetQueryParam("environment", request.Environment). - SetQueryParam("secretPath", request.SecretPath). - SetQueryParam("include_imports", "false"). - Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) + SetQueryParam("secretPath", request.SecretPath) + + if request.IncludeImport { + req.SetQueryParam("include_imports", "true") + } + if request.Recursive { + req.SetQueryParam("recursive", "true") + } + + response, err := req.Get(fmt.Sprintf("%v/v3/secrets/raw", config.INFISICAL_URL)) if err != nil { return GetRawSecretsV3Response{}, fmt.Errorf("CallGetRawSecretsV3: Unable to complete api request [err=%w]", err) diff --git a/cli/packages/api/model.go b/cli/packages/api/model.go index 0a5bfee6d..56b9807f7 100644 --- a/cli/packages/api/model.go +++ b/cli/packages/api/model.go @@ -371,6 +371,22 @@ type ImportedSecretV3 struct { Secrets []EncryptedSecretV3 `json:"secrets"` } +type ImportedRawSecretV3 struct { + SecretPath string `json:"secretPath"` + Environment string `json:"environment"` + FolderId string `json:"folderId"` + Secrets []struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + Environment string `json:"environment"` + Version int `json:"version"` + Type string `json:"type"` + SecretKey string `json:"secretKey"` + SecretValue string `json:"secretValue"` + SecretComment string `json:"secretComment"` + } `json:"secrets"` +} + type GetEncryptedSecretsV3Response struct { Secrets []EncryptedSecretV3 `json:"secrets"` ImportedSecrets []ImportedSecretV3 `json:"imports,omitempty"` @@ -542,6 +558,6 @@ type GetRawSecretsV3Response struct { SecretValue string `json:"secretValue"` SecretComment string `json:"secretComment"` } `json:"secrets"` - Imports []any `json:"imports"` + Imports []ImportedRawSecretV3 `json:"imports"` ETag string } diff --git a/cli/packages/cmd/export.go b/cli/packages/cmd/export.go index 84015cc02..983c19255 100644 --- a/cli/packages/cmd/export.go +++ b/cli/packages/cmd/export.go @@ -149,6 +149,8 @@ var exportCmd = &cobra.Command{ secrets = util.ExpandSecrets(secrets, authParams, "") } secrets = util.FilterSecretsByTag(secrets, tagSlugs) + secrets = util.SortSecretsByKeys(secrets) + output, err = formatEnvs(secrets, format) if err != nil { util.HandleError(err) diff --git a/cli/packages/cmd/secrets.go b/cli/packages/cmd/secrets.go index 7ba6e5411..f2f694075 100644 --- a/cli/packages/cmd/secrets.go +++ b/cli/packages/cmd/secrets.go @@ -116,6 +116,9 @@ var secretsCmd = &cobra.Command{ secrets = util.ExpandSecrets(secrets, authParams, "") } + // Sort the secrets by key so we can create a consistent output + secrets = util.SortSecretsByKeys(secrets) + visualize.PrintAllSecretDetails(secrets) Telemetry.CaptureEvent("cli-command:secrets", posthog.NewProperties().Set("secretCount", len(secrets)).Set("version", util.CLI_VERSION)) }, diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index e5b8ab6d9..9a4d960db 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path" + "sort" "strings" "time" @@ -53,6 +54,14 @@ func GetBase64DecodedSymmetricEncryptionDetails(key string, cipher string, IV st }, nil } +// Helper function to sort the secrets by key so we can create a consistent output +func SortSecretsByKeys(secrets []models.SingleEnvironmentVariable) []models.SingleEnvironmentVariable { + sort.Slice(secrets, func(i, j int) bool { + return secrets[i].Key < secrets[j].Key + }) + return secrets +} + func IsSecretEnvironmentValid(env string) bool { if env == "prod" || env == "dev" || env == "test" || env == "staging" { return true diff --git a/cli/packages/util/secrets.go b/cli/packages/util/secrets.go index 59d80ea77..bedd77572 100644 --- a/cli/packages/util/secrets.go +++ b/cli/packages/util/secrets.go @@ -186,12 +186,12 @@ func GetPlainTextSecretsViaMachineIdentity(accessToken string, workspaceId strin plainTextSecrets = append(plainTextSecrets, models.SingleEnvironmentVariable{Key: secret.SecretKey, Value: secret.SecretValue, Type: secret.Type, WorkspaceId: secret.Workspace}) } - // if includeImports { - // plainTextSecrets, err = InjectImportedSecret(plainTextWorkspaceKey, plainTextSecrets, encryptedSecrets.ImportedSecrets) - // if err != nil { - // return nil, err - // } - // } + if includeImports { + plainTextSecrets, err = InjectRawImportedSecret(plainTextSecrets, rawSecrets.Imports) + if err != nil { + return models.PlaintextSecretResult{}, err + } + } return models.PlaintextSecretResult{ Secrets: plainTextSecrets, @@ -252,6 +252,36 @@ func InjectImportedSecret(plainTextWorkspaceKey []byte, secrets []models.SingleE return secrets, nil } +func InjectRawImportedSecret(secrets []models.SingleEnvironmentVariable, importedSecrets []api.ImportedRawSecretV3) ([]models.SingleEnvironmentVariable, error) { + if importedSecrets == nil { + return secrets, nil + } + + hasOverriden := make(map[string]bool) + for _, sec := range secrets { + hasOverriden[sec.Key] = true + } + + for i := len(importedSecrets) - 1; i >= 0; i-- { + importSec := importedSecrets[i] + plainTextImportedSecrets := importSec.Secrets + + for _, sec := range plainTextImportedSecrets { + if _, ok := hasOverriden[sec.SecretKey]; !ok { + secrets = append(secrets, models.SingleEnvironmentVariable{ + Key: sec.SecretKey, + WorkspaceId: sec.Workspace, + Value: sec.SecretValue, + Type: sec.Type, + ID: sec.ID, + }) + hasOverriden[sec.SecretKey] = true + } + } + } + return secrets, nil +} + func FilterSecretsByTag(plainTextSecrets []models.SingleEnvironmentVariable, tagSlugs string) []models.SingleEnvironmentVariable { if tagSlugs == "" { return plainTextSecrets diff --git a/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports new file mode 100644 index 000000000..679cd91ad --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithImports @@ -0,0 +1,5 @@ +STAGING-SECRET-1='staging-value-1' +STAGING-SECRET-2='staging-value-2' +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports new file mode 100644 index 000000000..c803e591e --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_ExportSecretsWithoutImports @@ -0,0 +1,3 @@ +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive new file mode 100644 index 000000000..f2f26ae19 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameRecursive @@ -0,0 +1,7 @@ +┌─────────────────┬────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├─────────────────┼────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +└─────────────────┴────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports new file mode 100644 index 000000000..ff488466d --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithImports @@ -0,0 +1,7 @@ +┌──────────────────┬─────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├──────────────────┼─────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ STAGING-SECRET-2 │ staging-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +└──────────────────┴─────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret new file mode 100644 index 000000000..afe3bffa8 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_GetSecretsByNameWithNotFoundSecret @@ -0,0 +1,8 @@ +┌─────────────────┬────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├─────────────────┼────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +│ DOES-NOT-EXIST │ *not found* │ *not found* │ +└─────────────────┴────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports new file mode 100644 index 000000000..4106549cd --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdRecursiveAndImports @@ -0,0 +1,2 @@ + Injecting 6 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports new file mode 100644 index 000000000..86475d3e5 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithImports @@ -0,0 +1,2 @@ + Injecting 5 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports new file mode 100644 index 000000000..dfe56064a --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_RunCmdWithoutImports @@ -0,0 +1,2 @@ + Injecting 3 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd new file mode 100644 index 000000000..dd2bc317c --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithImportsAndRecursiveCmd @@ -0,0 +1,10 @@ +┌──────────────────┬─────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├──────────────────┼─────────────────┼─────────────┤ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +│ STAGING-SECRET-1 │ staging-value-1 │ shared │ +│ STAGING-SECRET-2 │ staging-value-2 │ shared │ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└──────────────────┴─────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd new file mode 100644 index 000000000..260607e97 --- /dev/null +++ b/cli/test/.snapshots/test-TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd @@ -0,0 +1,7 @@ +┌───────────────┬──────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├───────────────┼──────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└───────────────┴──────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports new file mode 100644 index 000000000..679cd91ad --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithImports @@ -0,0 +1,5 @@ +STAGING-SECRET-1='staging-value-1' +STAGING-SECRET-2='staging-value-2' +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports new file mode 100644 index 000000000..c803e591e --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_ExportSecretsWithoutImports @@ -0,0 +1,3 @@ +TEST-SECRET-1='test-value-1' +TEST-SECRET-2='test-value-2' +TEST-SECRET-3='test-value-3' diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive new file mode 100644 index 000000000..f2f26ae19 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameRecursive @@ -0,0 +1,7 @@ +┌─────────────────┬────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├─────────────────┼────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +└─────────────────┴────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports new file mode 100644 index 000000000..ff488466d --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithImports @@ -0,0 +1,7 @@ +┌──────────────────┬─────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├──────────────────┼─────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ STAGING-SECRET-2 │ staging-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +└──────────────────┴─────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret new file mode 100644 index 000000000..afe3bffa8 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_GetSecretsByNameWithNotFoundSecret @@ -0,0 +1,8 @@ +┌─────────────────┬────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├─────────────────┼────────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +│ DOES-NOT-EXIST │ *not found* │ *not found* │ +└─────────────────┴────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports new file mode 100644 index 000000000..4106549cd --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdRecursiveAndImports @@ -0,0 +1,2 @@ + Injecting 6 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports new file mode 100644 index 000000000..86475d3e5 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithImports @@ -0,0 +1,2 @@ + Injecting 5 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports new file mode 100644 index 000000000..dfe56064a --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_RunCmdWithoutImports @@ -0,0 +1,2 @@ + Injecting 3 Infisical secrets into your application process +hello world diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd new file mode 100644 index 000000000..dd2bc317c --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd @@ -0,0 +1,10 @@ +┌──────────────────┬─────────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├──────────────────┼─────────────────┼─────────────┤ +│ FOLDER-SECRET-1 │ folder-value-1 │ shared │ +│ STAGING-SECRET-1 │ staging-value-1 │ shared │ +│ STAGING-SECRET-2 │ staging-value-2 │ shared │ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└──────────────────┴─────────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd new file mode 100644 index 000000000..260607e97 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd @@ -0,0 +1,7 @@ +┌───────────────┬──────────────┬─────────────┐ +│ SECRET NAME │ SECRET VALUE │ SECRET TYPE │ +├───────────────┼──────────────┼─────────────┤ +│ TEST-SECRET-1 │ test-value-1 │ shared │ +│ TEST-SECRET-2 │ test-value-2 │ shared │ +│ TEST-SECRET-3 │ test-value-3 │ shared │ +└───────────────┴──────────────┴─────────────┘ diff --git a/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment new file mode 100644 index 000000000..c3811bd22 --- /dev/null +++ b/cli/test/.snapshots/test-TestUniversalAuth_SecretsGetWrongEnvironment @@ -0,0 +1,4 @@ +error: CallGetRawSecretsV3: Unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?environment=invalid-env&include_imports=true&recursive=true&secretPath=%2F&workspaceId=bef697d4-849b-4a75-b284-0922f87f8ba2] [status-code=500] [response={"statusCode":500,"error":"Internal Server Error","message":"'invalid-env' environment not found in project with ID bef697d4-849b-4a75-b284-0922f87f8ba2"}] + + +If this issue continues, get support at https://infisical.com/slack diff --git a/cli/test/export_test.go b/cli/test/export_test.go new file mode 100644 index 000000000..9a936871d --- /dev/null +++ b/cli/test/export_test.go @@ -0,0 +1,73 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestUniversalAuth_ExportSecretsWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_ExportSecretsWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_ExportSecretsWithoutImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_ExportSecretsWithoutImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "export", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/helper.go b/cli/test/helper.go new file mode 100644 index 000000000..995367c4b --- /dev/null +++ b/cli/test/helper.go @@ -0,0 +1,64 @@ +package tests + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" +) + +const ( + CLI_NAME = "infisical-merge" +) + +var ( + FORMATTED_CLI_NAME = fmt.Sprintf("./%s", CLI_NAME) +) + +type Credentials struct { + ClientID string + ClientSecret string + UAAccessToken string + ServiceToken string + ProjectID string + EnvSlug string +} + +var creds = Credentials{ + UAAccessToken: "", + ClientID: os.Getenv("CLI_TESTS_UA_CLIENT_ID"), + ClientSecret: os.Getenv("CLI_TESTS_UA_CLIENT_SECRET"), + ServiceToken: os.Getenv("CLI_TESTS_SERVICE_TOKEN"), + ProjectID: os.Getenv("CLI_TESTS_PROJECT_ID"), + EnvSlug: os.Getenv("CLI_TESTS_ENV_SLUG"), +} + +func ExecuteCliCommand(command string, args ...string) (string, error) { + cmd := exec.Command(command, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return strings.TrimSpace(string(output)), err + } + return strings.TrimSpace(string(output)), nil +} + +func SetupCli(t *testing.T) { + + if creds.ClientID == "" || creds.ClientSecret == "" || creds.ServiceToken == "" || creds.ProjectID == "" || creds.EnvSlug == "" { + panic("Missing required environment variables") + } + + // check if the CLI is already built, if not build it + alreadyBuilt := false + if _, err := os.Stat(FORMATTED_CLI_NAME); err == nil { + alreadyBuilt = true + } + + if !alreadyBuilt { + if err := exec.Command("go", "build", "../.").Run(); err != nil { + t.Fatal(err) + } + } + +} diff --git a/cli/test/login_test.go b/cli/test/login_test.go new file mode 100644 index 000000000..0f4591413 --- /dev/null +++ b/cli/test/login_test.go @@ -0,0 +1,29 @@ +package tests + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func MachineIdentityLoginCmd(t *testing.T) { + SetupCli(t) + + if creds.UAAccessToken != "" { + return + } + + jwtPattern := `^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$` + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "login", "--method=universal-auth", "--client-id", creds.ClientID, "--client-secret", creds.ClientSecret, "--plain", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + assert.Regexp(t, jwtPattern, output) + + creds.UAAccessToken = output + + // We can't use snapshot testing here because the output will be different every time +} diff --git a/cli/test/run_test.go b/cli/test/run_test.go new file mode 100644 index 000000000..808f4f14f --- /dev/null +++ b/cli/test/run_test.go @@ -0,0 +1,120 @@ +package tests + +import ( + "bytes" + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_RunCmdRecursiveAndImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} +func TestServiceToken_RunCmdWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdRecursiveAndImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // remove the first few characters from the output because we don't care about the time, and it will change every time + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_RunCmdWithoutImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_RunCmdWithoutImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "run", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--silent", "--include-imports=false", "--", "echo", "hello world") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Remove everything before "INF" because it's not relevant to the test + output = string(bytes.Split([]byte(output), []byte("INF"))[1]) + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/secrets_by_name_test.go b/cli/test/secrets_by_name_test.go new file mode 100644 index 000000000..440324e1a --- /dev/null +++ b/cli/test/secrets_by_name_test.go @@ -0,0 +1,106 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_GetSecretsByNameRecursive(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_GetSecretsByNameWithNotFoundSecret(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_GetSecretsByNameWithImports(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameRecursive(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameWithNotFoundSecret(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "TEST-SECRET-2", "FOLDER-SECRET-1", "DOES-NOT-EXIST", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_GetSecretsByNameWithImports(t *testing.T) { + MachineIdentityLoginCmd(t) + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "get", "TEST-SECRET-1", "STAGING-SECRET-2", "FOLDER-SECRET-1", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} diff --git a/cli/test/secrets_test.go b/cli/test/secrets_test.go new file mode 100644 index 000000000..453666406 --- /dev/null +++ b/cli/test/secrets_test.go @@ -0,0 +1,87 @@ +package tests + +import ( + "testing" + + "github.com/bradleyjkemp/cupaloy/v2" +) + +func TestServiceToken_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestServiceToken_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { + SetupCli(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.ServiceToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWithImportsAndRecursiveCmd(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--recursive", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWithoutImportsAndWithoutRecursiveCmd(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, err := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", creds.EnvSlug, "--include-imports=false", "--silent") + + if err != nil { + t.Fatalf("error running CLI command: %v", err) + } + + // Use cupaloy to snapshot test the output + err = cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } +} + +func TestUniversalAuth_SecretsGetWrongEnvironment(t *testing.T) { + SetupCli(t) + MachineIdentityLoginCmd(t) + + output, _ := ExecuteCliCommand(FORMATTED_CLI_NAME, "secrets", "--token", creds.UAAccessToken, "--projectId", creds.ProjectID, "--env", "invalid-env", "--recursive", "--silent") + + // Use cupaloy to snapshot test the output + err := cupaloy.Snapshot(output) + if err != nil { + t.Fatalf("snapshot failed: %v", err) + } + +} diff --git a/docs/documentation/guides/microsoft-power-apps.mdx b/docs/documentation/guides/microsoft-power-apps.mdx new file mode 100644 index 000000000..64647c8e6 --- /dev/null +++ b/docs/documentation/guides/microsoft-power-apps.mdx @@ -0,0 +1,114 @@ +--- +title: "Microsoft Power Apps" +description: "Learn how to manage secrets in Microsoft Power Apps with Infisical." +--- +In recent years, there has been a shift towards so-called low-code and no-code platforms. These platforms are particularly appealing to businesses without internal development capabilities, yet teams often discover that some coding is necessary to fully satisfy their business needs. + +Low-code platforms have become increasingly sophisticated and useful, leading to a rise in their adoption by businesses. A prime example is Microsoft Power Apps, which offers a range of data sources and service integrations right out of the box. However, even with advanced tools, you might not always find a ready-made solution for every challenge. This means that low-code doesn't equate to no-code, as some coding and customization are still required to cater to specific needs. + +Consider the need for data integrations where an HTTP-based call to a web service might be necessary, typically requiring authentication through an API key or another type of secret. + +Importantly, it's crucial to avoid hardcoding these secrets, as they would then be accessible to anyone with collaboration rights to the code. This underscores the importance of using a secret management solution like Infisical. + +In this article, we'll demonstrate how to retrieve app secrets from Infisical for use in a Power Apps application. We'll create a simple application with a dedicated data connector to illustrate the ease of integrating Infisical with Power Apps. This tutorial assumes some prior programming experience in C#. + +Prerequisites: +- Created Microsoft Power App. + + + + First, let’s create a new Azure Function using the Azure Management Portal. Get the [Function App](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/Microsoft.FunctionApp?tab=Overview) from the [Azure Marketplace](https://azuremarketplace.microsoft.com/en-us/). + ![function app](../../images/guides/microsoft-power-apps/function-app.png) + + Place it in a subscription using any resource group. The name of the function is arbitrary. We'll use .NET as a runtime stack, but you can use whatever you're most comfortable with. The OS choice is also up to you. While Linux may look like a lightweight solution, Windows actually has more Azure Functions support. For instance, you cannot edit a Linux-based Azure Function within the Azure management portal. + + By using a consumption plan, we'll only pay for the resources we use when they are requested. This is the classic “serverless” approach, where you do not pay for running servers, only for interactivity. + + Once the new Azure Functions instance is ready, we add a function. In this case, we can do that already from the Azure Management Portal. Use the “HTTP trigger” template and choose the “function” authorization level. + + The code for the first function can be as simple as: + + ``` + using System.Net; + + public static async Task Run(HttpRequestMessage req, TraceWriter log) + { + log.Info("C# HTTP trigger function processed a request."); + return req.CreateResponse(HttpStatusCode.OK, "Hello World"); + } + ``` + + + The code above is written for the older runtime. As a result, you may need to change the runtime version to 1 for the Azure Power Apps integration to work. If we start at a newer version (for example, 3) this triggers a warning before the migration. + + + Finally, we also need to publish the Swagger (or API) definitions and enable cross-origin resource sharing (CORS). While the API definitions are rather easy to set up, the correct CORS value may be tricky. For now, we can use the wildcard option to allow all hosts. + + + + + Once we set all this up, it’s time to create the custom connector. + + You can create the custom connector via the data pane. When we use “Create from Azure Service (Preview)”, this yields a dialog similar to the following: + + ![custom-connector](../../images/guides/microsoft-power-apps/custom-connector.png) + + We can now fill out the fields using the information for our created function. The combination boxes are automatically filled in order. Once we select one of the reachable subscriptions (tied to the same account we’ve used to log in to create a Power App), the available services are displayed. Once we select our Azure Functions service, we select the function for retrieving the secret. + + + + + You can add Infisical in an Azure Function quite easily using the [Infisical SDK for .NET](https://infisical.com/docs/sdks/languages/csharp) (or other languages). This enables the function to communicate with Infisical to obtain secrets, among other things. + + In short, we can simply bring all the necessary classes over and start using the Client class. Essentially, this enables us to write code like this: + + ``` + var settings = new ClientSettings + { + ClientId = "CLIENT_ID", + ClientSecret = "CLIENT_SECRET", + // SiteUrl = "http://localhost:8080", <-- This line can be omitted if you're using Infisical Cloud. + }; + var infisical = new InfisicalClient(settings); + + var options = new GetSecretOptions + { + SecretName = "TEST", + ProjectId = "PROJECT_ID", + Environment = "dev", + }; + var secret = infisical.GetSecret(options); + ``` + + Knowing the URL of Infisical as well as the Client Id and Client Secret, we can now access the desired values. + + Now it’s time to actually use the secret within a Power App. There are two ways to request a desired target service with a secret retrieved from the function: + + 1. Call the function first, retrieve the secret, then call the target service, for example, via another custom connector with the secret as input. + + 2. Perform the final API request within the function call — not returning a secret at all, just the response from invoking the target service. + + While the first option is more flexible (and presumably cheaper!), the second option is definitely easier. In the end, you should mostly decide based on whether the function should be reused for other purposes. If the single Power App is the only consumer of the function, it may make more sense to go with the second option. Otherwise, you should use the first option. + + For our simple example, we don’t need to reuse the function. We also don’t want the additional complexity of maintaining two different custom connectors, where we only use one to pass data to the other one. + + Based on the previous snippet, we create the following code (for proxying a GET request from an API accessible via the URL specified in the apiEndpoint variable). + + ``` + using (var client = new HttpClient()) + { + client.DefaultRequestHeaders + .Accept + .Add(new MediaTypeWithQualityHeaderValue("application/json")); + + client.DefaultRequestHeaders.Add("X-API-KEY", secret); + + var result = await client.GetAsync(apiEndpoint); + var resultContent = await result.Content.ReadAsStringAsync(); + req.CreateResponse(HttpStatusCode.OK, resultContent); + } + ``` + This creates a request to the resource protected by an API key that is retrieved from Infisical. + + + \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx new file mode 100644 index 000000000..31e9658b7 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -0,0 +1,129 @@ +--- +title: "Cassandra" +description: "How to dynamically generate Cassandra database users" +--- + +The Infisical Cassandra dynamic secret allows you to generate Cassandra database credentials on demand based on configured role. + +## Prerequisite + +Infisical requires a Cassandra user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. +Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. + + +In your Cassandra configuration file `cassandra.yaml`, make sure you have the following settings: + +```yaml +authenticator: PasswordAuthenticator +authorizer: CassandraAuthorizer +``` + + +The above configuration allows user creation and granting permissions. + +## Set up Dynamic Secrets with Cassandra + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + Cassandra Host. You can specify multiple Cassandra hosts by separating them with commas. + + + + Cassandra port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + Specify the local data center in Cassandra that you want to use. This choice should align with your Cassandra cluster setup. + + + + Keyspace name where you want to create dynamic secrets. This ensures that the user is limited to that keyspace. + + + + A CA may be required if your cassandra requires it for incoming connections. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the CQL statement to your needs. This is useful if you want to only give access to a specific key-space(s). + + ![Modify CQL Statements Modal](../../../images/platform/dynamic-secrets/modify-cql-statements.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/identities/user-identities.mdx b/docs/documentation/platform/identities/user-identities.mdx index e88752d84..2d4791127 100644 --- a/docs/documentation/platform/identities/user-identities.mdx +++ b/docs/documentation/platform/identities/user-identities.mdx @@ -9,9 +9,9 @@ A **user identity** (also known as **user**) represents a developer, admin, or a Users can be added manually (through Web UI) or programmatically (e.g., API) to [organizations](../organization) and [projects](../projects). -Upon being added to an organizaztion and projects, users assume a certain set of roles and permissions that represents their identity. +Upon being added to an organization and projects, users assume a certain set of roles and permissions that represents their identity. -![organization members](../../images/platform/organization/organization-members.png) +![organization members](../../../images/platform/organization/organization-members.png) ## Authentication methods diff --git a/docs/images/guides/microsoft-power-apps/custom-connector.png b/docs/images/guides/microsoft-power-apps/custom-connector.png new file mode 100644 index 000000000..e74fe61f1 Binary files /dev/null and b/docs/images/guides/microsoft-power-apps/custom-connector.png differ diff --git a/docs/images/guides/microsoft-power-apps/function-app.png b/docs/images/guides/microsoft-power-apps/function-app.png new file mode 100644 index 000000000..9b92cdc97 Binary files /dev/null and b/docs/images/guides/microsoft-power-apps/function-app.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png index 619ec0a8e..21f2213ef 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png differ diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-options.png b/docs/images/integrations/aws/integrations-aws-secret-manager-options.png new file mode 100644 index 000000000..f8492cdfa Binary files /dev/null and b/docs/images/integrations/aws/integrations-aws-secret-manager-options.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png new file mode 100644 index 000000000..6956c44e4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-cassandra.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png new file mode 100644 index 000000000..b9ce04aef Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-cassandra.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-cql-statements.png b/docs/images/platform/dynamic-secrets/modify-cql-statements.png new file mode 100644 index 000000000..d1e1b9b98 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-cql-statements.png differ diff --git a/docs/integrations/cloud/aws-parameter-store.mdx b/docs/integrations/cloud/aws-parameter-store.mdx index 547387996..c872e39f0 100644 --- a/docs/integrations/cloud/aws-parameter-store.mdx +++ b/docs/integrations/cloud/aws-parameter-store.mdx @@ -30,7 +30,7 @@ Prerequisites: "ssm:DeleteParameter", "ssm:GetParametersByPath", "ssm:DeleteParameters", - "ssm:AddTagsToResource" + "ssm:AddTagsToResource" // if you need to add tags to secrets ], "Resource": "*" } diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index a0187644d..2ab45c620 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -29,13 +29,16 @@ Prerequisites: "secretsmanager:GetSecretValue", "secretsmanager:CreateSecret", "secretsmanager:UpdateSecret", - "secretsmanager:TagResource" + "secretsmanager:TagResource", // if you need to add tags to secrets + "kms:ListKeys", // if you need to specify the KMS key + "kms:ListAliases" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + Obtain a AWS access key ID and secret access key for your IAM user in IAM > Users > User > Security credentials > Access keys @@ -43,7 +46,7 @@ Prerequisites: ![access key 1](../../images/integrations/aws/integrations-aws-access-key-1.png) ![access key 2](../../images/integrations/aws/integrations-aws-access-key-2.png) ![access key 3](../../images/integrations/aws/integrations-aws-access-key-3.png) - + Navigate to your project's integrations tab in Infisical. ![integrations](../../images/integrations.png) @@ -52,12 +55,6 @@ Prerequisites: ![integration auth](../../images/integrations/aws/integrations-aws-secret-manager-auth.png) - - If this is your project's first cloud integration, then you'll have to grant - Infisical access to your project's environment variables. Although this step - breaks E2EE, it's necessary for Infisical to sync the environment variables to - the cloud platform. - Select how you want to integration to work by specifying a number of parameters: @@ -72,13 +69,23 @@ Prerequisites: The region that you want to integrate with in AWS Secrets Manager. - The secret name/path in AWS into which you want to sync the secrets from Infisical. + The secret name/path in AWS into which you want to sync the secrets from Infisical. - Then, press `Create Integration` to start syncing secrets to AWS Secrets Manager. - ![integration create](../../images/integrations/aws/integrations-aws-secret-manager-create.png) + Optionally, you can add tags or specify the encryption key of all the secrets created via this integration: + + + The Key/Value of a tag that will be added to secrets in AWS. Please note that it is possible to add multiple tags via API. + + + The alias/ID of the AWS KMS key used for encryption. Please note that key should be enabled in order to work and the IAM user should have access to it. + + ![integration options](../../images/integrations/aws/integrations-aws-secret-manager-options.png) + + Then, press `Create Integration` to start syncing secrets to AWS Secrets Manager. + Infisical currently syncs environment variables to AWS Secrets Manager as key-value pairs under one secret. We're actively exploring ways to help users @@ -88,5 +95,6 @@ Prerequisites: Please note that upon deleting secrets in Infisical, AWS Secrets Manager immediately makes the secrets inaccessible but only schedules them for deletion after at least 7 days. + diff --git a/docs/mint.json b/docs/mint.json index 97a262920..b38b5fa52 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,7 +32,10 @@ "thumbsRating": true }, "api": { - "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + "baseUrl": [ + "https://app.infisical.com", + "http://localhost:8080" + ] }, "topbarLinks": [ { @@ -85,7 +88,8 @@ "documentation/guides/introduction", "documentation/guides/node", "documentation/guides/python", - "documentation/guides/nextjs-vercel" + "documentation/guides/nextjs-vercel", + "documentation/guides/microsoft-power-apps" ] } ] @@ -141,7 +145,8 @@ "documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/postgresql", "documentation/platform/dynamic-secrets/mysql", - "documentation/platform/dynamic-secrets/oracle" + "documentation/platform/dynamic-secrets/oracle", + "documentation/platform/dynamic-secrets/cassandra" ] }, "documentation/platform/groups" @@ -369,11 +374,15 @@ }, { "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] + "pages": [ + "integrations/build-tools/gradle" + ] }, { "group": "", - "pages": ["sdks/overview"] + "pages": [ + "sdks/overview" + ] }, { "group": "SDK's", @@ -391,7 +400,9 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": ["api-reference/overview/examples/integration"] + "pages": [ + "api-reference/overview/examples/integration" + ] } ] }, @@ -520,11 +531,15 @@ }, { "group": "Service Tokens", - "pages": ["api-reference/endpoints/service-tokens/get"] + "pages": [ + "api-reference/endpoints/service-tokens/get" + ] }, { "group": "Audit Logs", - "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + "pages": [ + "api-reference/endpoints/audit-logs/export-audit-log" + ] } ] }, @@ -540,7 +555,9 @@ }, { "group": "", - "pages": ["changelog/overview"] + "pages": [ + "changelog/overview" + ] }, { "group": "Contributing", @@ -564,7 +581,9 @@ }, { "group": "Contributing to SDK", - "pages": ["contributing/sdk/developing"] + "pages": [ + "contributing/sdk/developing" + ] } ] } diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 4b2a736d3..27c4c5ddf 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -16,7 +16,8 @@ export type TDynamicSecret = { }; export enum DynamicSecretProviders { - SqlDatabase = "sql-database" + SqlDatabase = "sql-database", + Cassandra = "cassandra" } export enum SqlProviders { @@ -25,21 +26,37 @@ export enum SqlProviders { Oracle = "oracledb" } -export type TDynamicSecretProvider = { - type: DynamicSecretProviders; - inputs: { - client: SqlProviders; - host: string; - port: number; - database: string; - username: string; - password: string; - creationStatement: string; - revocationStatement: string; - renewStatement?: string; - ca?: string | undefined; +export type TDynamicSecretProvider = + | { + type: DynamicSecretProviders.SqlDatabase; + inputs: { + client: SqlProviders; + host: string; + port: number; + database: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string | undefined; + }; + } + | { + type: DynamicSecretProviders.Cassandra; + inputs: { + host: string; + port: number; + keyspace?: string; + localDataCenter: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string | undefined; + }; }; -}; export type TCreateDynamicSecretDTO = { projectSlug: string; diff --git a/frontend/src/hooks/api/integrationAuth/queries.tsx b/frontend/src/hooks/api/integrationAuth/queries.tsx index 073fde34b..e66dd5700 100644 --- a/frontend/src/hooks/api/integrationAuth/queries.tsx +++ b/frontend/src/hooks/api/integrationAuth/queries.tsx @@ -10,6 +10,7 @@ import { Environment, HerokuPipelineCoupling, IntegrationAuth, + KmsKey, NorthflankSecretGroup, Org, Project, @@ -43,6 +44,14 @@ const integrationAuthKeys = { [{ integrationAuthId }, "integrationAuthGithubOrgs"] as const, getIntegrationAuthGithubEnvs: (integrationAuthId: string, repoName: string, repoOwner: string) => [{ integrationAuthId, repoName, repoOwner }, "integrationAuthGithubOrgs"] as const, + getIntegrationAuthAwsKmsKeys: ({ + integrationAuthId, + region + }: { + integrationAuthId: string, + region: string + }) => + [{ integrationAuthId, region }, "integrationAuthAwsKmsKeyIds"] as const, getIntegrationAuthQoveryOrgs: (integrationAuthId: string) => [{ integrationAuthId }, "integrationAuthQoveryOrgs"] as const, getIntegrationAuthQoveryProjects: ({ @@ -217,6 +226,27 @@ const fetchIntegrationAuthQoveryOrgs = async (integrationAuthId: string) => { return orgs; }; +const fetchIntegrationAuthAwsKmsKeys = async ({ + integrationAuthId, + region +}: { + integrationAuthId: string; + region: string; +}) => { + const { + data: { kmsKeys } + } = await apiRequest.get<{ kmsKeys: KmsKey[] }>( + `/api/v1/integration-auth/${integrationAuthId}/aws-secrets-manager/kms-keys`, + { + params: { + region + } + } + ); + + return kmsKeys; +}; + const fetchIntegrationAuthQoveryProjects = async ({ integrationAuthId, orgId @@ -544,6 +574,27 @@ export const useGetIntegrationAuthQoveryOrgs = (integrationAuthId: string) => { }); }; +export const useGetIntegrationAuthAwsKmsKeys = ({ + integrationAuthId, + region +}: { + integrationAuthId: string; + region: string; +}) => { + return useQuery({ + queryKey: integrationAuthKeys.getIntegrationAuthAwsKmsKeys({ + integrationAuthId, + region + }), + queryFn: () => + fetchIntegrationAuthAwsKmsKeys({ + integrationAuthId, + region + }), + enabled: true + }); +}; + export const useGetIntegrationAuthQoveryProjects = ({ integrationAuthId, orgId diff --git a/frontend/src/hooks/api/integrationAuth/types.ts b/frontend/src/hooks/api/integrationAuth/types.ts index cfd25df00..b0e1dd9f5 100644 --- a/frontend/src/hooks/api/integrationAuth/types.ts +++ b/frontend/src/hooks/api/integrationAuth/types.ts @@ -58,6 +58,11 @@ export type Project = { projectId: string; }; +export type KmsKey = { + id: string; + alias: string; +}; + export type Service = { name: string; serviceId: string; diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 7804f0951..b855dbf73 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -66,7 +66,8 @@ export const useCreateIntegration = () => { secretAWSTag?: { key: string; value: string; - }; + }[]; + kmsKeyId?: string; }; }) => { const { diff --git a/frontend/src/pages/integrations/aws-parameter-store/create.tsx b/frontend/src/pages/integrations/aws-parameter-store/create.tsx index dd643023c..92e4662a9 100644 --- a/frontend/src/pages/integrations/aws-parameter-store/create.tsx +++ b/frontend/src/pages/integrations/aws-parameter-store/create.tsx @@ -128,10 +128,10 @@ export default function AWSParameterStoreCreateIntegrationPage() { metadata: { ...(shouldTag ? { - secretAWSTag: { + secretAWSTag: [{ key: tagKey, value: tagValue - } + }] } : {}) } @@ -279,7 +279,7 @@ export default function AWSParameterStoreCreateIntegrationPage() { label="Tag Value" > setTagValue(e.target.value)} /> diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index ee06fe6c1..b48296259 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -14,6 +14,7 @@ import { motion } from "framer-motion"; import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; +import { useGetIntegrationAuthAwsKmsKeys } from "@app/hooks/api/integrationAuth/queries"; import { Button, @@ -87,6 +88,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { const [targetSecretNameErrorText, setTargetSecretNameErrorText] = useState(""); const [tagKey, setTagKey] = useState(""); const [tagValue, setTagValue] = useState(""); + const [kmsKeyId, setKmsKeyId] = useState(""); // const [path, setPath] = useState(''); // const [pathErrorText, setPathErrorText] = useState(''); @@ -94,6 +96,19 @@ export default function AWSSecretManagerCreateIntegrationPage() { const [isLoading, setIsLoading] = useState(false); const [shouldTag, setShouldTag] = useState(false); + + const { data: integrationAuthAwsKmsKeys, isLoading: isIntegrationAuthAwsKmsKeysLoading } = + useGetIntegrationAuthAwsKmsKeys({ + integrationAuthId: String(integrationAuthId), + region: selectedAWSRegion + }); + + useEffect(() => { + if (integrationAuthAwsKmsKeys) { + setKmsKeyId(String(integrationAuthAwsKmsKeys?.filter(key => key.alias === "alias/aws/secretsmanager")[0]?.id)) + } + }, [integrationAuthAwsKmsKeys]) + useEffect(() => { if (workspace) { setSelectedSourceEnvironment(workspace.environments[0].slug); @@ -127,12 +142,16 @@ export default function AWSSecretManagerCreateIntegrationPage() { metadata: { ...(shouldTag ? { - secretAWSTag: { + secretAWSTag: [{ key: tagKey, value: tagValue - } + }] } - : {}) + : {}), + ...((kmsKeyId && integrationAuthAwsKmsKeys?.filter(key => key.id === kmsKeyId)[0]?.alias !== "alias/aws/secretsmanager") ? + { + kmsKeyId + }: {}) } }); @@ -145,7 +164,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { } }; - return integrationAuth && workspace && selectedSourceEnvironment ? ( + return (integrationAuth && workspace && selectedSourceEnvironment && !isIntegrationAuthAwsKmsKeysLoading) ? (
Set Up AWS Secrets Manager Integration @@ -278,13 +297,38 @@ export default function AWSSecretManagerCreateIntegrationPage() { label="Tag Value" > setTagValue(e.target.value)} />
)} + + + @@ -317,7 +361,7 @@ export default function AWSSecretManagerCreateIntegrationPage() { Set Up AWS Secrets Manager Integration - {isintegrationAuthLoading ? ( + {(isintegrationAuthLoading || isIntegrationAuthAwsKmsKeysLoading) ? ( - - Terraform Cloud Integration + + Authorize Terraform Cloud Integration + + + + +
+
+ Terraform logo +
+ Terraform Cloud Integration + + +
+ + Docs + +
+
+ +
+
+ + setWorkSpacesId(e.target.value)} + /> + setApiKey(e.target.value)} /> - - setWorkSpacesId(e.target.value)} - /> -