-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
1.add basic framework for external vault
2.add aws secret manager integration
- Loading branch information
Showing
41 changed files
with
6,575 additions
and
3,388 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
packages/insomnia/src/main/ipc/__tests__/vaultCache.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
106
packages/insomnia/src/main/ipc/cloud-service-integraion/aws-service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }, | ||
}; | ||
} | ||
} | ||
}; |
77 changes: 77 additions & 0 deletions
77
packages/insomnia/src/main/ipc/cloud-service-integraion/cloud-service.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
25
packages/insomnia/src/main/ipc/cloud-service-integraion/types.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
}; |
97 changes: 97 additions & 0 deletions
97
packages/insomnia/src/main/ipc/cloud-service-integraion/vault-cache.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
|
||
}; |
Oops, something went wrong.