From 153c4e44bdb09326347e8da71041cc9f0f69ab3f Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 20 Feb 2025 14:22:51 +0800 Subject: [PATCH] External vault - AWS secret manager support [INS-4623] (#3) * add basic framework for external vault * add aws secret manager integration * fix issues from comment --- package-lock.json | 169 ++++++++++-------- packages/insomnia/package.json | 1 + packages/insomnia/src/common/settings.ts | 1 + .../src/main/ipc/__tests__/vaultCache.test.ts | 22 +++ .../cloud-service-integration/aws-service.ts | 62 ++++++- .../cloud-service.ts | 40 ++++- .../ipc/cloud-service-integration/types.ts | 2 + .../cloud-service-integration/vault-cache.ts | 97 ++++++++++ packages/insomnia/src/main/ipc/electron.ts | 7 +- packages/insomnia/src/models/settings.ts | 1 + packages/insomnia/src/preload.ts | 3 + packages/insomnia/src/templating/utils.ts | 2 + .../components/codemirror/one-line-editor.tsx | 2 +- .../ui/components/modals/nunjucks-modal.tsx | 1 + .../components/templating/external-vault.ts | 52 ++++++ .../aws-secret-manager-form.tsx | 130 ++++++++++++++ .../external-vault/external-vault-form.tsx | 63 +++++++ .../templating/local-template-tags.ts | 63 +++++++ .../templating/tag-editor-arg-sub-form.tsx | 37 ++++ .../ui/components/templating/tag-editor.tsx | 45 ++++- .../src/ui/context/nunjucks/use-nunjucks.ts | 2 +- 21 files changed, 715 insertions(+), 87 deletions(-) create mode 100644 packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts create mode 100644 packages/insomnia/src/main/ipc/cloud-service-integration/vault-cache.ts create mode 100644 packages/insomnia/src/ui/components/templating/external-vault.ts create mode 100644 packages/insomnia/src/ui/components/templating/external-vault/aws-secret-manager-form.tsx create mode 100644 packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx create mode 100644 packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx diff --git a/package-lock.json b/package-lock.json index 528bbf6650..375eb9dcdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3771,6 +3771,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.0.tgz", + "integrity": "sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@prisma/instrumentation": { "version": "5.17.0", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.17.0.tgz", @@ -13669,9 +13685,9 @@ } }, "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", "license": "Apache-2.0" }, "node_modules/express": { @@ -18447,6 +18463,15 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/node-gyp/node_modules/abbrev": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/node-gyp/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", @@ -18523,12 +18548,12 @@ } }, "node_modules/node-gyp/node_modules/nopt": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.0.0.tgz", - "integrity": "sha512-1L/fTJ4UmV/lUxT2Uf006pfZKTvAgCF+chz+0OgBHO8u2Z67pE7AaAUUj7CJy0lXqHmymUvGFt6NE9R3HER0yw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", "license": "ISC", "dependencies": { - "abbrev": "^2.0.0" + "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" @@ -19656,6 +19681,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -21611,9 +21683,9 @@ } }, "node_modules/socks": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.3.tgz", - "integrity": "sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", "license": "MIT", "dependencies": { "ip-address": "^9.0.5", @@ -24810,6 +24882,7 @@ "objectpath": "^2.0.0", "openapi-types": "^12.1.3", "postcss": "^8.4.38", + "quick-lru": "^7.0.0", "react": "^18.2.0", "react-aria": "3.32.1", "react-aria-components": "^1.1.1", @@ -24941,72 +25014,22 @@ "xvfb-maybe": "^0.2.1" } }, - "packages/insomnia-smoke-test/node_modules/@playwright/test": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.0.tgz", - "integrity": "sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.43.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "packages/insomnia-smoke-test/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "packages/insomnia-smoke-test/node_modules/playwright": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", - "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.43.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "packages/insomnia-smoke-test/node_modules/playwright-core": { - "version": "1.43.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", - "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, "packages/insomnia-testing": { "version": "11.0.0-beta.0", "license": "Apache-2.0" + }, + "packages/insomnia/node_modules/quick-lru": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.0.0.tgz", + "integrity": "sha512-MX8gB7cVYTrYcFfAnfLlhRd0+Toyl8yX8uBx1MrX7K0jegiz9TumwOK27ldXrgDlHRdVi+MqU9Ssw6dr4BNreg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 0624526738..7924eeec10 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -167,6 +167,7 @@ "objectpath": "^2.0.0", "openapi-types": "^12.1.3", "postcss": "^8.4.38", + "quick-lru": "^7.0.0", "react": "^18.2.0", "react-aria": "3.32.1", "react-aria-components": "^1.1.1", diff --git a/packages/insomnia/src/common/settings.ts b/packages/insomnia/src/common/settings.ts index 49ce6bc29c..f948afa90d 100644 --- a/packages/insomnia/src/common/settings.ts +++ b/packages/insomnia/src/common/settings.ts @@ -152,4 +152,5 @@ export interface Settings { saveVaultKeyLocally: boolean; enableVaultInScripts: boolean; saveVaultKeyToOSSecretManager: boolean; + vaultSecretCacheDuration: number; } diff --git a/packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts b/packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts new file mode 100644 index 0000000000..5eea4d46c5 --- /dev/null +++ b/packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { VaultCache } from '../cloud-service-integration/vault-cache'; + +describe('test cache', () => { + it('should get item after set', () => { + const cacheInstance = new VaultCache(); + cacheInstance.setItem('foo', 'bar'); + cacheInstance.setItem('number_key', Math.random() * 1000); + cacheInstance.setItem('boolean_key', true); + + expect(cacheInstance.has('foo')).toBe(true); + expect(cacheInstance.has('boolean_key')).toBe(true); + expect(cacheInstance.has('foo1')).toBe(false); + expect(cacheInstance.getItem('foo')).toBe('bar'); + + cacheInstance.clear(); + expect(Array.from(cacheInstance.entriesAscending()).length).toBe(0); + + }); + +}); diff --git a/packages/insomnia/src/main/ipc/cloud-service-integration/aws-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integration/aws-service.ts index 96adebc622..6ba70f0581 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integration/aws-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integration/aws-service.ts @@ -1,4 +1,6 @@ +import { DecryptionFailure, GetSecretValueCommand, type GetSecretValueCommandOutput, InternalServiceError, InvalidParameterException, InvalidRequestException, ResourceNotFoundException, SecretsManagerClient, SecretsManagerServiceException } from '@aws-sdk/client-secrets-manager'; import { GetCallerIdentityCommand, type GetCallerIdentityCommandOutput, STSClient, STSServiceException } from '@aws-sdk/client-sts'; +import crypto from 'crypto'; import type { AWSTemporaryCredential, CloudProviderName } from '../../../models/cloud-credential'; import type { AWSSecretConfig, CloudServiceResult, ICloudService } from './types'; @@ -6,7 +8,7 @@ import type { AWSSecretConfig, CloudServiceResult, ICloudService } from './types export type AWSGetSecretConfig = Omit; export const providerName: CloudProviderName = 'aws'; export class AWSService implements ICloudService { - _credential: AWSTemporaryCredential; + private _credential: AWSTemporaryCredential; constructor(credential: AWSTemporaryCredential) { this._credential = credential; @@ -43,4 +45,62 @@ export class AWSService implements ICloudService { }; } } + + getUniqueCacheKey(secretName: string, config?: AWSGetSecretConfig) { + const { + VersionId = '', + VersionStage = '', + } = config || {}; + const uniqueKey = `${providerName}:${secretName}:${VersionId}:${VersionStage}`; + const uniqueKeyHash = crypto.createHash('md5').update(uniqueKey).digest('hex'); + return uniqueKeyHash; + } + + async getSecret(secretNameOrARN: string, config: AWSGetSecretConfig = {}): Promise> { + const { region, accessKeyId, secretAccessKey, sessionToken } = this._credential; + const { VersionId, VersionStage } = config; + const secretClient = new SecretsManagerClient({ + region, + credentials: { + accessKeyId, secretAccessKey, sessionToken, + }, + }); + try { + const input = { + SecretId: secretNameOrARN, + ...(VersionId && { VersionId }), + ...(VersionStage && { VersionStage }), + }; + const response = await secretClient.send( + new GetSecretValueCommand(input) + ); + return { + success: true, + result: response, + }; + } catch (error) { + let errorCode = error.code || 'UnknownError'; + let errorMessage = error.message || 'Failed to get Secret. An unknown error occurred'; + if (error instanceof SecretsManagerServiceException) { + errorMessage = errorMessage || error.message; + errorCode = errorCode || error.name; + if (error instanceof DecryptionFailure) { + errorMessage = "Secrets Manager can't decrypt the protected secret text using the provided KMS key."; + } else if (error instanceof InternalServiceError) { + errorMessage = 'An error occurred on the server side.'; + } else if (error instanceof InvalidParameterException) { + errorMessage = 'The parameter name or value is invalid.'; + } else if (error instanceof InvalidRequestException) { + errorMessage = 'The request is invalid for the current state of the resource.'; + } else if (error instanceof ResourceNotFoundException) { + errorMessage = "Secrets Manager can't find the specified resource."; + }; + }; + return { + success: false, + result: null, + error: { errorCode, errorMessage }, + }; + } + } }; diff --git a/packages/insomnia/src/main/ipc/cloud-service-integration/cloud-service.ts b/packages/insomnia/src/main/ipc/cloud-service-integration/cloud-service.ts index f176d7eac1..39136ee48a 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integration/cloud-service.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integration/cloud-service.ts @@ -1,9 +1,18 @@ + +import * as models from '../../../models'; import type { AWSTemporaryCredential, BaseCloudCredential, CloudProviderName } from '../../../models/cloud-credential'; -import { ipcMainHandle } from '../electron'; +import { ipcMainHandle, ipcMainOn } from '../electron'; import { type AWSGetSecretConfig, AWSService } from './aws-service'; +import { type MaxAgeUnit, VaultCache } from './vault-cache'; + +// in-memory cache for fetched vault secrets +const vaultCache = new VaultCache(); export interface cloudServiceBridgeAPI { authenticate: typeof cloudServiceProviderAuthentication; + getSecret: typeof getSecret; + clearCache: typeof clearVaultCache; + setCacheMaxAge: typeof setCacheMaxAge; } export interface CloudServiceAuthOption { provider: CloudProviderName; @@ -17,6 +26,9 @@ export type CloudServiceGetSecretConfig = AWSGetSecretConfig; export function registerCloudServiceHandlers() { ipcMainHandle('cloudService.authenticate', (_event, options) => cloudServiceProviderAuthentication(options)); + ipcMainHandle('cloudService.getSecret', (_event, options) => getSecret(options)); + ipcMainOn('cloudService.clearCache', () => clearVaultCache()); + ipcMainOn('cloudService.setCacheMaxAge', (_event, { maxAge, unit }) => setCacheMaxAge(maxAge, unit)); } // factory pattern to create cloud service class based on its provider name @@ -31,9 +43,35 @@ class ServiceFactory { } }; +const clearVaultCache = () => { + return vaultCache.clear(); +}; + +const setCacheMaxAge = (newAge: number, unit: MaxAgeUnit = 'min') => { + return vaultCache.setMaxAge(newAge, unit); +}; + // authenticate with cloud service provider const cloudServiceProviderAuthentication = (options: CloudServiceAuthOption) => { const { provider, credentials } = options; const cloudService = ServiceFactory.createCloudService(provider, credentials); return cloudService.authenticate(); }; + +const getSecret = async (options: CloudServiceSecretOption) => { + const { provider, credentials, secretId, config } = options; + const cloudService = ServiceFactory.createCloudService(provider, credentials); + const uniqueSecretKey = cloudService.getUniqueCacheKey(secretId, config); + if (vaultCache.has(uniqueSecretKey)) { + // return cache value if exists + return vaultCache.getItem(uniqueSecretKey); + } + const secretResult = await cloudService.getSecret(secretId, config); + if (secretResult.success) { + const settings = await models.settings.get(); + const maxAge = Number(settings.vaultSecretCacheDuration) * 1000 * 60; + // set cached value after success + vaultCache.setItem(uniqueSecretKey, secretResult, { maxAge }); + } + return secretResult; +}; diff --git a/packages/insomnia/src/main/ipc/cloud-service-integration/types.ts b/packages/insomnia/src/main/ipc/cloud-service-integration/types.ts index 063751b6c9..52cfe99506 100644 --- a/packages/insomnia/src/main/ipc/cloud-service-integration/types.ts +++ b/packages/insomnia/src/main/ipc/cloud-service-integration/types.ts @@ -9,6 +9,8 @@ export interface CloudServiceResult> { } export interface ICloudService { authenticate(...args: any[]): Promise; + getSecret(secretName: string, config: any): Promise; + getUniqueCacheKey(secretName: string, config?: T): string; } export type AWSSecretType = 'kv' | 'plaintext'; diff --git a/packages/insomnia/src/main/ipc/cloud-service-integration/vault-cache.ts b/packages/insomnia/src/main/ipc/cloud-service-integration/vault-cache.ts new file mode 100644 index 0000000000..d472414a68 --- /dev/null +++ b/packages/insomnia/src/main/ipc/cloud-service-integration/vault-cache.ts @@ -0,0 +1,97 @@ +import QuickLRU from 'quick-lru'; + +export interface VaultCacheOptions { + maxSize?: number; + maxAge?: number; +} +export type MaxAgeUnit = 'ms' | 's' | 'min' | 'h'; + +// convert time unit to milliseconds +export const timeToMs = (time: number, unit: MaxAgeUnit = 'ms') => { + if (typeof time === 'number' && time > 0) { + switch (unit) { + case 'ms': + return time; + case 's': + return time * 1000; + case 'min': + return time * 1000 * 60; + case 'h': + return time * 1000 * 60 * 60; + default: + return time; + } + } + return 0; +}; + +export class VaultCache { + _cache: QuickLRU; + // The maximum number of milliseconds an item should remain in cache, default 30 mins + _maxAge: number = 30 * 60 * 1000; + + constructor(options?: VaultCacheOptions) { + const { maxSize = 1000, maxAge } = options || {}; + this._maxAge = maxAge || this._maxAge; + this._cache = new QuickLRU({ maxSize, maxAge: this._maxAge }); + } + + has(key: K) { + return this._cache.has(key); + } + + setItem(key: K, value: T, options?: Pick, 'maxAge'>) { + const { maxAge = this._maxAge } = options || {}; + this._cache.set(key, value, { maxAge }); + } + + getItem(key: K) { + if (this._cache.has(key)) { + return this._cache.get(key); + } + return null; + } + + getKeys() { + return Array.from(this._cache.keys()); + } + + getValues() { + return Array.from(this._cache.values()); + } + + entriesAscending() { + return this._cache.entriesAscending(); + } + + entriesDescending() { + return this._cache.entriesDescending(); + } + + deleteItem(key: K) { + if (this._cache.has(key)) { + this._cache.delete(key); + } + } + + resize(newSize: number) { + if (newSize > 0) { + this._cache.resize(newSize); + } else { + throw Error('cache size must be positive number'); + } + } + + setMaxAge(maxAge: number, unit: MaxAgeUnit = 'ms') { + this._maxAge = timeToMs(maxAge, unit); + } + + clear() { + this._cache.clear(); + } + + getSize() { + return this._cache.size; + } + +}; diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 6ddea816aa..d33aab0fa2 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -35,6 +35,8 @@ export type HandleChannels = | 'secretStorage.encryptString' | 'secretStorage.decryptString' | 'cloudService.authenticate' + | 'cloudService.getSecret' + | 'cloudService.exchangeCode' | 'git.loadGitRepository' | 'git.getGitBranches' | 'git.gitFetchAction' @@ -105,7 +107,10 @@ export type MainOnChannels = | 'addExecutionStep' | 'completeExecutionStep' | 'updateLatestStepName' - | 'startExecution'; + | 'startExecution' + | 'cloudService.setCacheMaxAge' + | 'cloudService.clearCache' + | 'cloudService.openAuthUrl'; export type RendererOnChannels = 'clear-all-models' diff --git a/packages/insomnia/src/models/settings.ts b/packages/insomnia/src/models/settings.ts index 65f195df33..819657ac7d 100644 --- a/packages/insomnia/src/models/settings.ts +++ b/packages/insomnia/src/models/settings.ts @@ -75,6 +75,7 @@ export function init(): BaseSettings { saveVaultKeyLocally: true, enableVaultInScripts: false, saveVaultKeyToOSSecretManager: true, + vaultSecretCacheDuration: 30, }; } diff --git a/packages/insomnia/src/preload.ts b/packages/insomnia/src/preload.ts index 6bf0f60481..62e0815bee 100644 --- a/packages/insomnia/src/preload.ts +++ b/packages/insomnia/src/preload.ts @@ -86,6 +86,9 @@ const git: GitServiceAPI = { const cloudService: cloudServiceBridgeAPI = { authenticate: options => ipcRenderer.invoke('cloudService.authenticate', options), + getSecret: options => ipcRenderer.invoke('cloudService.getSecret', options), + setCacheMaxAge: options => ipcRenderer.send('cloudService.setCacheMaxAge', options), + clearCache: () => ipcRenderer.send('cloudService.clearCache'), }; const main: Window['main'] = { diff --git a/packages/insomnia/src/templating/utils.ts b/packages/insomnia/src/templating/utils.ts index d3436dce13..8bb18f26df 100644 --- a/packages/insomnia/src/templating/utils.ts +++ b/packages/insomnia/src/templating/utils.ts @@ -2,6 +2,7 @@ import type { EditorFromTextArea, MarkerRange } from 'codemirror'; import _ from 'lodash'; import type { RenderPurpose } from '../common/render'; +import type { BaseModel } from '../models'; import { userSession } from '../models'; import { decryptSecretValue, vaultEnvironmentMaskValue, vaultEnvironmentPath } from '../models/environment'; import { decryptVaultKeyFromSession } from '../utils/vault'; @@ -19,6 +20,7 @@ export interface NunjucksParsedTagArg { displayName?: DisplayName; quotedBy?: '"' | "'"; validate?: (value: any) => string; + modelFilter?: (model: BaseModel, tagArg: NunjucksParsedTagArg[]) => boolean; hide?: (arg0: NunjucksParsedTagArg[]) => boolean; model?: string; options?: PluginArgumentEnumOption[]; diff --git a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx index bf4dc07950..4a344c3cce 100644 --- a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx @@ -277,7 +277,7 @@ export const OneLineEditor = forwardRef isOwner, }); return; - }; + } if (nunjucksTag) { const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions; switch (type) { diff --git a/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx b/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx index 12c8794970..ff472ff6e4 100644 --- a/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/nunjucks-modal.tsx @@ -88,6 +88,7 @@ export const NunjucksModal = forwardRef event.preventDefault(); modalRef.current?.hide(); }} + className='px-2' >{editor} diff --git a/packages/insomnia/src/ui/components/templating/external-vault.ts b/packages/insomnia/src/ui/components/templating/external-vault.ts new file mode 100644 index 0000000000..a7bb7943b5 --- /dev/null +++ b/packages/insomnia/src/ui/components/templating/external-vault.ts @@ -0,0 +1,52 @@ +import type { AWSGetSecretConfig } from '../../../main/ipc/cloud-service-integration/aws-service'; +import type { CloudServiceSecretOption } from '../../../main/ipc/cloud-service-integration/cloud-service'; +import type { AWSSecretConfig, ExternalVaultConfig } from '../../../main/ipc/cloud-service-integration/types'; +import type { CloudProviderCredential, CloudProviderName } from '../../../models/cloud-credential'; + +export const getExternalVault = async (provider: CloudProviderName, providerCredential: CloudProviderCredential, secretConfig: ExternalVaultConfig) => { + switch (provider) { + case 'aws': + return getAWSSecret(secretConfig as AWSSecretConfig, providerCredential); + default: + return ''; + } +}; + +export const getAWSSecret = async (secretConfig: AWSSecretConfig, providerCredential: CloudProviderCredential) => { + const { + SecretId, VersionId, VersionStage, SecretKey, + SecretType = 'plaintext', + } = secretConfig; + if (!SecretId) { + throw new Error('Get secret from AWS failed: Secret Name or ARN is required'); + } + const getSecretOption: CloudServiceSecretOption = { + provider: 'aws', + secretId: SecretId, + config: { + VersionId, VersionStage, + }, + credentials: providerCredential.credentials, + }; + const secretResult = await window.main.cloudService.getSecret(getSecretOption); + const { success, error, result } = secretResult; + if (success && result) { + const { SecretString } = result!; + let parsedJSON; + if (SecretType === 'plaintext' || !SecretKey) { + return SecretString; + } else { + try { + parsedJSON = JSON.parse(SecretString || '{}'); + } catch (error) { + throw new Error(`Get secret from AWS failed: Secret value ${SecretString} can not parsed to key/value pair, please change Secret Type to plaintext`); + } + if (SecretKey in parsedJSON) { + return parsedJSON[SecretKey]; + } + throw new Error(`Get secret from AWS failed: Secret key ${SecretKey} does not exist in key/value secret ${SecretString}`); + } + } else { + throw new Error(`Get secret from AWS failed: ${error?.errorMessage}`); + } +}; diff --git a/packages/insomnia/src/ui/components/templating/external-vault/aws-secret-manager-form.tsx b/packages/insomnia/src/ui/components/templating/external-vault/aws-secret-manager-form.tsx new file mode 100644 index 0000000000..900e400550 --- /dev/null +++ b/packages/insomnia/src/ui/components/templating/external-vault/aws-secret-manager-form.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; + +import type { AWSSecretConfig } from '../../../../main/ipc/cloud-service-integration/types'; +import type { NunjucksParsedTag } from '../../../../templating/utils'; +import { HelpTooltip } from '../../help-tooltip'; + +export interface AWSSecretManagerFormProps { + formData: AWSSecretConfig; + onChange: (newConfig: AWSSecretConfig) => void; + activeTagData: NunjucksParsedTag; +} +const secretTypeOptions = [ + { + key: 'plaintext', + label: 'Plaintext', + }, + { + key: 'kv', + label: 'Key/Value', + }, +]; + +export const AWSSecretManagerForm = (props: AWSSecretManagerFormProps) => { + const { formData, onChange } = props; + const { + SecretId, + SecretType, + VersionId = '', + VersionStage = '', + SecretKey = '', + } = formData; + const [showSecretKeyInput, setShowSecretKeyInput] = useState(SecretType === 'kv'); + const handleOnChange = (name: keyof AWSSecretConfig, newValue: string) => { + const newConfig = { + ...formData, + [name]: newValue, + }; + if (name === 'SecretType') { + setShowSecretKeyInput(newValue === 'kv'); + if (newValue === 'plaintext') { + newConfig['SecretKey'] = ''; + } + }; + onChange(newConfig as unknown as AWSSecretConfig); + }; + return ( +
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ {showSecretKeyInput && +
+
+ +
+
+ } +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx b/packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx new file mode 100644 index 0000000000..1d836310a9 --- /dev/null +++ b/packages/insomnia/src/ui/components/templating/external-vault/external-vault-form.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { Button } from 'react-aria-components'; + +import { debounce } from '../../../../common/misc'; +import type { AWSSecretConfig, ExternalVaultConfig } from '../../../../main/ipc/cloud-service-integration/types'; +import { type CloudProviderCredential, type CloudProviderName, type } from '../../../../models/cloud-credential'; +import { Icon } from '../../icon'; +import { CloudCredentialModal } from '../../modals/cloud-credential-modal/cloud-credential-modal'; +import type { ArgConfigFormProps } from '../tag-editor-arg-sub-form'; +import { AWSSecretManagerForm } from './aws-secret-manager-form'; + +export const ExternalVaultForm = (props: ArgConfigFormProps) => { + const { onChange, configValue, activeTagData, docs } = props; + const [showModal, setShowModal] = useState(false); + const provider = activeTagData.args[0].value as CloudProviderName; + const formData = JSON.parse(configValue) as ExternalVaultConfig; + const selectedCredentialId = activeTagData.args[1].value; + const cloudCredentialDocs = docs[type] as CloudProviderCredential[] || []; + const selectedCredentialDoc = cloudCredentialDocs.find(d => d._id === selectedCredentialId); + + const handleFormChange = debounce((newConfig: ExternalVaultConfig) => { + const newFormValue = JSON.stringify(newConfig); + onChange(newFormValue); + }, 1000); + let SubForm; + + switch (provider) { + case 'aws': + SubForm = ( + + ); + break; + default: + SubForm = null; + }; + + return ( + <> + {selectedCredentialDoc && provider !== 'azure' && + + } + {SubForm} + {showModal && + setShowModal(false)} + onComplete={() => onChange(configValue)} + /> + } + + ); +}; diff --git a/packages/insomnia/src/ui/components/templating/local-template-tags.ts b/packages/insomnia/src/ui/components/templating/local-template-tags.ts index 70e9b79dba..3774fda878 100644 --- a/packages/insomnia/src/ui/components/templating/local-template-tags.ts +++ b/packages/insomnia/src/ui/components/templating/local-template-tags.ts @@ -7,15 +7,78 @@ import os from 'os'; import { CookieJar } from 'tough-cookie'; import * as uuid from 'uuid'; +import type { RenderPurpose } from '../../../common/render'; +import type { ExternalVaultConfig } from '../../../main/ipc/cloud-service-integration/types'; +import * as models from '../../../models'; +import type { CloudProviderCredential, CloudProviderName } from '../../../models/cloud-credential'; +import { vaultEnvironmentMaskValue } from '../../../models/environment'; import type { Request, RequestParameter } from '../../../models/request'; import type { Response } from '../../../models/response'; import type { TemplateTag } from '../../../plugins'; import type { PluginTemplateTag } from '../../../templating/extensions'; import { invariant } from '../../../utils/invariant'; import { buildQueryStringFromParams, joinUrlAndQueryString, smartEncodeUrl } from '../../../utils/url/querystring'; +import { getExternalVault } from './external-vault'; import { fakerFunctions } from './faker-functions'; const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ + { + templateTag: { + name: 'vault', + displayName: 'External Vault', + description: 'Link secret from external vault', + // external vault is an enterprise feature + needsEnterprisePlan: true, + args: [ + { + displayName: 'Vault Service Provider', + type: 'enum', + options: [ + { displayName: 'AWS Secrets Manager', value: 'aws' }, + ], + }, + { + displayName: 'Credential For Vault Service Provider', + type: 'model', + modelFilter: (credentialModel, args) => { + const providerNameFromArg = args[0].value; + const { provider } = credentialModel as CloudProviderCredential; + return providerNameFromArg === provider; + }, + model: 'CloudCredential', + }, + { + type: 'string', + defaultValue: '{}', + requireSubForm: true, + }, + ], + async run(context, provider: CloudProviderName, credentialId: string, configStr: string) { + if (!provider) { + throw new Error('Get secret from external vault failed: Vault service provider is required'); + } + if (!credentialId) { + throw new Error('Get secret from external vault failed: Credential is required'); + }; + const providerCredential = await models.cloudCredential.getById(credentialId); + if (!providerCredential) { + throw new Error('Get secret from external vault failed: No Cloud Credential found'); + } + const renderContext = context.renderPurpose as RenderPurpose; + // Get secret from external vaults when send request or in tag-preview, otherwise return defautl mask value + if (renderContext === 'preview' || renderContext === 'send') { + let secretConfig = {}; + try { + secretConfig = JSON.parse(configStr); + } catch (error) { + throw new Error('Get secret from external vault failed: Invalid vault secret config'); + } + return getExternalVault(provider, providerCredential, secretConfig as ExternalVaultConfig); + } + return vaultEnvironmentMaskValue; + }, + }, + }, { templateTag: { name: 'faker', diff --git a/packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx b/packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx new file mode 100644 index 0000000000..83c917c820 --- /dev/null +++ b/packages/insomnia/src/ui/components/templating/tag-editor-arg-sub-form.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import type { BaseModel } from '../../../models'; +import type { NunjucksParsedTag } from '../../../templating/utils'; +import { ExternalVaultForm } from './external-vault/external-vault-form'; + +export interface ArgConfigFormProps { + configValue: string; + activeTagDefinition: NunjucksParsedTag; + activeTagData: NunjucksParsedTag; + onChange: (newConfigValue: string) => void; + docs: Record; +} +const formTagNameMapping = { + 'vault': ExternalVaultForm, +}; +const isValidJSONString = (input: string) => { + try { + const parsedJson = JSON.parse(input); + // Check if the parsed JSON is an object and not an array or null + return typeof parsedJson === 'object' && parsedJson !== null && !Array.isArray(parsedJson); + } catch (error) { + return false; + } +}; +export const couldRenderForm = (name: string) => name in formTagNameMapping; + +export const ArgConfigSubForm = (props: ArgConfigFormProps) => { + const { configValue, activeTagDefinition } = props; + const tagName = activeTagDefinition.name as keyof typeof formTagNameMapping; + const ConfigForm = formTagNameMapping[tagName]; + + if (ConfigForm && isValidJSONString(configValue)) { + return ; + } + return configValue; +}; diff --git a/packages/insomnia/src/ui/components/templating/tag-editor.tsx b/packages/insomnia/src/ui/components/templating/tag-editor.tsx index 4e801a381e..675b8237f2 100644 --- a/packages/insomnia/src/ui/components/templating/tag-editor.tsx +++ b/packages/insomnia/src/ui/components/templating/tag-editor.tsx @@ -9,6 +9,7 @@ import { docsAfterResponseScript } from '../../../common/documentation'; import { delay, fnOrString } from '../../../common/misc'; import { metaSortKeySort } from '../../../common/sorting'; import * as models from '../../../models'; +import { type as cloudCredentialModelType } from '../../../models/cloud-credential'; import type { BaseModel } from '../../../models/index'; import { isRequest, type Request } from '../../../models/request'; import { isRequestGroup, type RequestGroup } from '../../../models/request-group'; @@ -28,6 +29,7 @@ import { FileInputButton } from '../base/file-input-button'; import { HelpTooltip } from '../help-tooltip'; import { Icon } from '../icon'; import { localTemplateTags } from './local-template-tags'; +import { ArgConfigSubForm, couldRenderForm } from './tag-editor-arg-sub-form'; interface Props { defaultValue: string; @@ -88,6 +90,8 @@ export const TagEditor: FC = props => { for (const doc of await db.withDescendants(props.workspace, models.request.type)) { allDocs[doc.type].push(doc); } + // add global Cloud Credential data + allDocs[cloudCredentialModelType] = await models.cloudCredential.all(); // @ts-expect-error -- type unsoundness allDocs[models.request.type] = sortRequests((allDocs[models.request.type] || []).concat(allDocs[models.requestGroup.type] || []), props.workspace._id); setState(state => ({ ...state, allDocs, loadingDocs: false })); @@ -335,19 +339,36 @@ export const TagEditor: FC = props => { const isVariable = argData.type === 'variable'; let argInput; - const isVariableAllowed = argDefinition.type !== 'model'; + let isVariableAllowed = argDefinition.type !== 'model'; if (!isVariable) { if (argDefinition.type === 'string') { + const tagDefinitionName = activeTagDefinition.name; const placeholder = typeof argDefinition.placeholder === 'string' ? argDefinition.placeholder : ''; const encoding = argDefinition.encoding || 'utf8'; - argInput = (); + const needToRenderSubForm = argDefinition.requireSubForm && couldRenderForm(tagDefinitionName); + if (needToRenderSubForm) { + argInput = ( + updateArg(newConfigValue, index)} + activeTagData={activeTagData} + activeTagDefinition={activeTagDefinition} + docs={state.allDocs} + /> + ); + isVariableAllowed = false; + } else { + argInput = ( + + ); + } } else if (argDefinition.type === 'enum') { argInput = ( @@ -378,7 +405,7 @@ export const TagEditor: FC = props => { ) : (