External vault - AWS secret manager support [INS-4623] (#3)

* add basic framework for external vault
* add aws secret manager integration
* fix issues from comment
This commit is contained in:
Kent Wang
2025-02-20 14:22:51 +08:00
committed by Jay Wu
parent e08a9a06b3
commit 153c4e44bd
21 changed files with 715 additions and 87 deletions

169
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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",

View File

@@ -152,4 +152,5 @@ export interface Settings {
saveVaultKeyLocally: boolean;
enableVaultInScripts: boolean;
saveVaultKeyToOSSecretManager: boolean;
vaultSecretCacheDuration: number;
}

View File

@@ -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);
});
});

View File

@@ -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<AWSSecretConfig, 'SecretId' | 'SecretType' | 'SecretKey'>;
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<CloudServiceResult<GetSecretValueCommandOutput>> {
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 },
};
}
}
};

View File

@@ -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<CloudServiceGetSecretConfig>) => {
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;
};

View File

@@ -9,6 +9,8 @@ export interface CloudServiceResult<T extends Record<string, any>> {
}
export interface ICloudService {
authenticate(...args: any[]): Promise<any>;
getSecret(secretName: string, config: any): Promise<any>;
getUniqueCacheKey<T extends {} = {}>(secretName: string, config?: T): string;
}
export type AWSSecretType = 'kv' | 'plaintext';

View File

@@ -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<K = string, T = any> {
_cache: QuickLRU<K, T>;
// 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<Required<VaultCacheOptions>, '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;
}
};

View File

@@ -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'

View File

@@ -75,6 +75,7 @@ export function init(): BaseSettings {
saveVaultKeyLocally: true,
enableVaultInScripts: false,
saveVaultKeyToOSSecretManager: true,
vaultSecretCacheDuration: 30,
};
}

View File

@@ -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'] = {

View File

@@ -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[];

View File

@@ -277,7 +277,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
isOwner,
});
return;
};
}
if (nunjucksTag) {
const { type, template, range } = nunjucksTag as nunjucksTagContextMenuOptions;
switch (type) {

View File

@@ -88,6 +88,7 @@ export const NunjucksModal = forwardRef<NunjucksModalHandle, ModalProps & Props>
event.preventDefault();
modalRef.current?.hide();
}}
className='px-2'
>{editor}</form>
</ModalBody>
<ModalFooter>

View File

@@ -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<AWSGetSecretConfig> = {
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}`);
}
};

View File

@@ -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 (
<form id='aws-secret-manager-form'>
<div className="form-row">
<div className="form-control">
<label>
Secret Name Or ARN
<HelpTooltip className="space-left">
The ARN or name of the secret to retrieve. To retrieve a secret from another account, you must use an ARN.
</HelpTooltip>
<input
name='SecretId'
defaultValue={SecretId}
onChange={e => handleOnChange('SecretId', e.target.value)}
/>
</label>
</div>
</div>
<div className="form-row">
<div className="form-control">
<label>
Version Id
<HelpTooltip className="space-left">
Optional unique identifier of the version of the secret to retrieve.
</HelpTooltip>
<input
name='VersionId'
defaultValue={VersionId}
onChange={e => handleOnChange('VersionId', e.target.value)}
/>
</label>
</div>
</div>
<div className="form-row">
<div className="form-control">
<label>
Version Stage
<HelpTooltip className="space-left">
Optional staging label of the version of the secret to retrieve.
</HelpTooltip>
<input
name='VersionStage'
defaultValue={VersionStage}
onChange={e => handleOnChange('VersionStage', e.target.value)}
/>
</label>
</div>
</div>
<div className="form-row">
<div className="form-control">
<label>
Secret Type
<select
name='SecretType'
defaultValue={SecretType || 'plaintext'}
onChange={e => handleOnChange('SecretType', e.target.value)}
>
{secretTypeOptions.map(option => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</label>
</div>
</div>
{showSecretKeyInput &&
<div className="form-row">
<div className="form-control">
<label>
Secret Key
<HelpTooltip className="space-left">
The Secret Key of the retrived key/value secrets.
</HelpTooltip>
<input
name='SecretKey'
defaultValue={SecretKey}
onChange={e => handleOnChange('SecretKey', e.target.value)}
/>
</label>
</div>
</div>
}
</form>
);
};

View File

@@ -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 = (
<AWSSecretManagerForm
formData={formData as AWSSecretConfig}
onChange={handleFormChange}
activeTagData={activeTagData}
/>
);
break;
default:
SubForm = null;
};
return (
<>
{selectedCredentialDoc && provider !== 'azure' &&
<Button
className="px-2 py-1 mb-[--padding-sm] h-full flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] text-[--color-info] text-xs hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all"
style={{ marginTop: 'calc(var(--padding-sm) * -1)' }}
onPress={() => setShowModal(true)}
>
<Icon icon="edit" /> Edit Credential
</Button>
}
{SubForm}
{showModal &&
<CloudCredentialModal
provider={provider}
providerCredential={selectedCredentialDoc}
onClose={() => setShowModal(false)}
onComplete={() => onChange(configValue)}
/>
}
</>
);
};

View File

@@ -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',

View File

@@ -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<string, BaseModel[]>;
}
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 <ConfigForm {...props} />;
}
return configValue;
};

View File

@@ -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> = 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> = 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 = (<input
type="text"
defaultValue={sanitizeStrForWin32(strValue)}
placeholder={placeholder}
onChange={handleChange}
data-encoding={encoding}
/>);
const needToRenderSubForm = argDefinition.requireSubForm && couldRenderForm(tagDefinitionName);
if (needToRenderSubForm) {
argInput = (
<ArgConfigSubForm
configValue={sanitizeStrForWin32(strValue)}
onChange={(newConfigValue: string) => updateArg(newConfigValue, index)}
activeTagData={activeTagData}
activeTagDefinition={activeTagDefinition}
docs={state.allDocs}
/>
);
isVariableAllowed = false;
} else {
argInput = (
<input
type="text"
defaultValue={sanitizeStrForWin32(strValue)}
placeholder={placeholder}
onChange={handleChange}
data-encoding={encoding}
/>
);
}
} else if (argDefinition.type === 'enum') {
argInput = (
<select value={strValue} onChange={handleChange}>
@@ -371,6 +392,12 @@ export const TagEditor: FC<Props> = props => {
extensions={argDefinition.extensions}
/>);
} else if (argDefinition.type === 'model') {
const modelName = typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown';
let targetDoc = state.allDocs[modelName];
const modelFilterFunc = argDefinition.modelFilter;
if (modelFilterFunc && typeof modelFilterFunc === 'function') {
targetDoc = targetDoc.filter(doc => modelFilterFunc(doc, activeTagData.args));
}
argInput = state.loadingDocs ? (
<select disabled={state.loadingDocs}>
<option>Loading...</option>
@@ -378,7 +405,7 @@ export const TagEditor: FC<Props> = props => {
) : (
<select value={typeof strValue === 'string' ? strValue : 'unknown'} onChange={handleChange}>
<option value="n/a">-- Select Item --</option>
{state.allDocs[typeof argDefinition.model === 'string' ? argDefinition.model : 'unknown']?.map((doc: any) => {
{targetDoc.map((doc: any) => {
let namePrefix: string | null = null;
// Show parent folder with name if it's a request
if (isRequest(doc)) {

View File

@@ -7,10 +7,10 @@ import { getKeys } from '../../../templating/utils';
import type { RequestLoaderData } from '../../routes/request';
import type { WorkspaceLoaderData } from '../../routes/workspace';
let getRenderContextPromiseCache: any = {};
export interface UseNunjucksOptions {
renderContext: Pick<Partial<RenderContextOptions>, 'purpose' | 'extraInfo'>;
}
export const initializeNunjucksRenderPromiseCache = () => {
getRenderContextPromiseCache = {};
};