Skip to content

Commit

Permalink
1.add basic framework for external vault
Browse files Browse the repository at this point in the history
2.add aws secret manager integration
  • Loading branch information
cwangsmv committed Nov 21, 2024
1 parent 73b03d0 commit 5a67fa3
Show file tree
Hide file tree
Showing 41 changed files with 6,575 additions and 3,388 deletions.
8,383 changes: 5,022 additions & 3,361 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/insomnia/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"dependencies": {
"@apideck/better-ajv-errors": "^0.3.6",
"@apidevtools/swagger-parser": "10.1.0",
"@aws-sdk/client-secrets-manager": "^3.686.0",
"@aws-sdk/client-sts": "^3.686.0",
"@bufbuild/protobuf": "^1.8.0",
"@connectrpc/connect": "^1.4.0",
"@connectrpc/connect-node": "^1.4.0",
Expand Down Expand Up @@ -162,6 +164,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",
Expand Down
4 changes: 2 additions & 2 deletions packages/insomnia/src/common/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { database as db } from './database';

export const KEEP_ON_ERROR = 'keep';
export const THROW_ON_ERROR = 'throw';
export type RenderPurpose = 'send' | 'general' | 'no-render';
export type RenderPurpose = 'send' | 'general' | 'preview' | 'no-render';
export const RENDER_PURPOSE_SEND: RenderPurpose = 'send';
export const RENDER_PURPOSE_GENERAL: RenderPurpose = 'general';
export const RENDER_PURPOSE_NO_RENDER: RenderPurpose = 'no-render';
Expand Down Expand Up @@ -372,7 +372,7 @@ interface BaseRenderContextOptions {
ignoreUndefinedEnvVariable?: boolean;
}

interface RenderContextOptions extends BaseRenderContextOptions, Partial<RenderRequest<Request | GrpcRequest | WebSocketRequest>> {
export interface RenderContextOptions extends BaseRenderContextOptions, Partial<RenderRequest<Request | GrpcRequest | WebSocketRequest>> {
ancestors?: RenderContextAncestor[];
}
export async function getRenderContext(
Expand Down
1 change: 1 addition & 0 deletions packages/insomnia/src/common/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,5 @@ export interface Settings {
useBulkParametersEditor: boolean;
validateAuthSSL: boolean;
validateSSL: boolean;
vaultSecretCacheDuration: number;
}
2 changes: 2 additions & 0 deletions packages/insomnia/src/main.development.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import log, { initializeLogging } from './common/log';
import { SegmentEvent, trackSegmentEvent } from './main/analytics';
import { registerInsomniaProtocols } from './main/api.protocol';
import { backupIfNewerVersionAvailable } from './main/backup';
import { registerCloudServiceHandlers } from './main/ipc/cloud-service-integraion/cloud-service';
import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron';
import { registergRPCHandlers } from './main/ipc/grpc';
import { registerMainHandlers } from './main/ipc/main';
Expand Down Expand Up @@ -64,6 +65,7 @@ app.on('ready', async () => {
registergRPCHandlers();
registerWebSocketHandlers();
registerCurlHandlers();
registerCloudServiceHandlers();

/**
* There's no option that prevents Electron from fetching spellcheck dictionaries from Chromium's CDN and passing a non-resolving URL is the only known way to prevent it from fetching.
Expand Down
22 changes: 22 additions & 0 deletions packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts
Original file line number Diff line number Diff line change
@@ -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);

});

});
106 changes: 106 additions & 0 deletions packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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';

export type AWSGetSecretConfig = Omit<AWSSecretConfig, 'SecretId' | 'SecretType' | 'SecretKey'>;
export const providerName: CloudProviderName = 'aws';
export class AWSService implements ICloudService {
_credential: AWSTemporaryCredential;

constructor(credential: AWSTemporaryCredential) {
this._credential = credential;
}

async authorize(): Promise<CloudServiceResult<GetCallerIdentityCommandOutput>> {
const { region, accessKeyId, secretAccessKey, sessionToken } = this._credential;
const stsClient = new STSClient({
region,
credentials: {
accessKeyId, secretAccessKey, sessionToken,
},
});

try {
const response = await stsClient.send(new GetCallerIdentityCommand({}));
return {
success: true,
result: response,
};
} catch (error) {
const errorDetail = {
errorCode: error.code || 'UnknownError',
errorMessage: error.message || 'Failed to authenticate with AWS. An unknown error occurred',
};
if (error instanceof STSServiceException) {
errorDetail.errorCode = error.name || errorDetail.errorCode;
errorDetail.errorMessage = error.message || errorDetail.errorMessage;
}
return {
success: false,
result: null,
error: errorDetail,
};
}
}

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 },
};
}
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as models from '../../../models';
import type { AWSTemporaryCredential, CloudeProviderCredentialType, CloudProviderName } from '../../../models/cloud-credential';
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 cspAuthentication;
getSecret: typeof getSecret;
clearCache: typeof clearVaultCache;
setCacheMaxAge: typeof setCacheMaxAge;
}
export interface CloudServiceAuthOption {
provider: CloudProviderName;
credentials: CloudeProviderCredentialType;
}
export interface CloudServiceSecretOption<T extends {}> extends CloudServiceAuthOption {
secretId: string;
config?: T;
}
export type CloudServiceGetSecretConfig = AWSGetSecretConfig;

export function registerCloudServiceHandlers() {
ipcMainHandle('cloudService.authenticate', (_event, options) => cspAuthentication(options));
ipcMainHandle('cloudService.getSecret', (_event, options) => getSecret(options));
ipcMainOn('cloudService.clearCache', () => clearVaultCache());
ipcMainOn('cloudService.setCacheMaxAge', (_event, { maxAge, unit }) => setCacheMaxAge(maxAge, unit));
}

type CredentialType = AWSTemporaryCredential;
// factory pattern to create cloud service class based on its provider name
class ServiceFactory {
static createCloudService(name: CloudProviderName, credential: CredentialType) {
switch (name) {
case 'aws':
return new AWSService(credential as AWSTemporaryCredential);
default:
throw new Error('Invalid cloud service provider name');
}
}
};

const clearVaultCache = () => {
return vaultCache.clear();
};

const setCacheMaxAge = (newAge: number, unit: MaxAgeUnit = 'min') => {
return vaultCache.setMaxAge(newAge, unit);
};

// authenticate with cloud service provider
const cspAuthentication = (options: CloudServiceAuthOption) => {
const { provider, credentials } = options;
const cloudService = ServiceFactory.createCloudService(provider, credentials);
return cloudService.authorize();
};

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;
};
25 changes: 25 additions & 0 deletions packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export interface CloudServiceError {
errorCode: string;
errorMessage: string;
}
export interface CloudServiceResult<T> {
success: boolean;
result: T | null;
error?: CloudServiceError;
}

export interface ICloudService {
authorize(): Promise<any>;
getSecret<T extends {}>(secretName: string, config?: T): Promise<any>;
getSecret(secretName: string): Promise<any>;
getUniqueCacheKey<T extends {} = {}>(secretName: string, config?: T): string;
}

export type AWSSecretType = 'kv' | 'plaintext';
export interface AWSSecretConfig {
SecretId: string;
VersionId?: string;
VersionStage?: string;
SecretType: AWSSecretType;
SecretKey?: string;
};
Original file line number Diff line number Diff line change
@@ -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;
}

};
Loading

0 comments on commit 5a67fa3

Please sign in to comment.