diff --git a/src/lib/secretsManager/USAGE_EXAMPLES.ts b/src/lib/secretsManager/USAGE_EXAMPLES.txt similarity index 100% rename from src/lib/secretsManager/USAGE_EXAMPLES.ts rename to src/lib/secretsManager/USAGE_EXAMPLES.txt diff --git a/src/lib/secretsManager/baseTypes.ts b/src/lib/secretsManager/baseTypes.ts index 01a8f015..68be3622 100644 --- a/src/lib/secretsManager/baseTypes.ts +++ b/src/lib/secretsManager/baseTypes.ts @@ -25,5 +25,7 @@ export interface ProviderConfig { * @template T - The provider type */ export interface SecretReference { + id: string; type: T; + alias: string; } diff --git a/src/lib/secretsManager/encryptedStorage/AbstractSecretsManagerStorage.ts b/src/lib/secretsManager/encryptedStorage/AbstractSecretsManagerStorage.ts index 67ba72ba..efeccef8 100644 --- a/src/lib/secretsManager/encryptedStorage/AbstractSecretsManagerStorage.ts +++ b/src/lib/secretsManager/encryptedStorage/AbstractSecretsManagerStorage.ts @@ -1,17 +1,35 @@ -import { SecretProviderConfig } from "../types"; +import { SecretProviderConfig, SecretValue } from "../types"; -export type StorageChangeCallback = ( - data: Record +export type ProviderStorageChangeCallback = ( + providers: Record +) => void; +export type SecretStorageChangeCallback = ( + secrets: Record ) => void; export abstract class AbstractSecretsManagerStorage { - abstract set(_key: string, _data: SecretProviderConfig): Promise; - - abstract get(_key: string): Promise; + abstract setProviderConfig( + _providerId: string, + _data: SecretProviderConfig + ): Promise; + abstract setSecretValue(_secretId: string, _data: SecretValue): Promise; + abstract setSecretValues( + _entries: Record + ): Promise; - abstract getAll(): Promise; + abstract getProviderConfig( + _providerId: string + ): Promise; + abstract getSecretValue(_secretId: string): Promise; - abstract delete(_key: string): Promise; + abstract getAllProviderConfigs(): Promise; + abstract getAllSecretValues(): Promise; + abstract deleteProviderConfig(_providerId: string): Promise; + abstract deleteSecretValue(_secretId: string): Promise; + abstract deleteSecretValues(_keys: string[]): Promise; - abstract onStorageChange(callback: StorageChangeCallback): () => void; + abstract onProvidersChange( + callback: ProviderStorageChangeCallback + ): () => void; + abstract onSecretsChange(callback: SecretStorageChangeCallback): () => void; } diff --git a/src/lib/secretsManager/encryptedStorage/SecretsManagerEncryptedStorage.ts b/src/lib/secretsManager/encryptedStorage/SecretsManagerEncryptedStorage.ts index d76e1db2..7d652b74 100644 --- a/src/lib/secretsManager/encryptedStorage/SecretsManagerEncryptedStorage.ts +++ b/src/lib/secretsManager/encryptedStorage/SecretsManagerEncryptedStorage.ts @@ -1,36 +1,96 @@ import { AbstractSecretsManagerStorage, - StorageChangeCallback, + ProviderStorageChangeCallback, + SecretStorageChangeCallback, } from "./AbstractSecretsManagerStorage"; import { EncryptedElectronStore } from "../../storage/EncryptedElectronStore"; -import { SecretProviderConfig } from "../types"; +import { SecretProviderConfig, SecretReference, SecretValue } from "../types"; export class SecretsManagerEncryptedStorage extends AbstractSecretsManagerStorage { private encryptedStore: EncryptedElectronStore; - constructor(storeName: string) { + constructor(storeName: string, userId: string) { super(); this.encryptedStore = new EncryptedElectronStore(storeName); + this.encryptedStore.set("userId", userId); } - async set(key: string, data: SecretProviderConfig): Promise { - return this.encryptedStore.set(key, data); + async setProviderConfig( + providerId: string, + data: SecretProviderConfig + ): Promise { + return this.encryptedStore.set( + `providers.${providerId}`, + data + ); } - async get(key: string): Promise { - return this.encryptedStore.get(key); + async setSecretValue(secretId: string, data: SecretValue): Promise { + return this.encryptedStore.set(`secrets.${secretId}`, data); } - async getAll(): Promise { - const allData = this.encryptedStore.getAll(); - return Object.values(allData); + async setSecretValues(entries: Record): Promise { + const current = + this.encryptedStore.get>("secrets") ?? {}; + this.encryptedStore.set>("secrets", { + ...current, + ...entries, + }); } - async delete(key: string): Promise { - return this.encryptedStore.delete(key); + async getProviderConfig( + providerId: string + ): Promise { + return this.encryptedStore.get( + `providers.${providerId}` + ); } - onStorageChange(callback: StorageChangeCallback): () => void { - return this.encryptedStore.onChange(callback); + async getSecretValue(secretId: string): Promise { + return this.encryptedStore.get(`secrets.${secretId}`); + } + + async getAllProviderConfigs(): Promise { + const allProviders = + this.encryptedStore.get>( + `providers` + ); + return Object.values(allProviders ?? {}); + } + + async getAllSecretValues(): Promise { + const allSecrets = + this.encryptedStore.get>(`secrets`); + return Object.values(allSecrets ?? {}); + } + + async deleteProviderConfig(providerId: string): Promise { + return this.encryptedStore.delete(`providers.${providerId}`); + } + + async deleteSecretValue(secretId: string): Promise { + return this.encryptedStore.delete(`secrets.${secretId}`); + } + + async deleteSecretValues(keys: string[]): Promise { + const current = + this.encryptedStore.get>("secrets") ?? {}; + for (const key of keys) { + delete current[key]; + } + this.encryptedStore.set>("secrets", current); + } + + onProvidersChange(callback: ProviderStorageChangeCallback): () => void { + return this.encryptedStore.onKeyChange< + Record + >("providers", callback); + } + + onSecretsChange(callback: SecretStorageChangeCallback): () => void { + return this.encryptedStore.onKeyChange>( + "secrets", + callback + ); } } diff --git a/src/lib/secretsManager/errors.ts b/src/lib/secretsManager/errors.ts new file mode 100644 index 00000000..4dee5edd --- /dev/null +++ b/src/lib/secretsManager/errors.ts @@ -0,0 +1,65 @@ +import { SecretReference, SecretValue } from "./types"; + +export enum SecretsErrorCode { + SAFE_STORAGE_ENCRYPTION_NOT_AVAILABLE = "safe_storage_encryption_not_available", + INVALID_USER_ID = "invalid_user_id", + + PROVIDER_NOT_FOUND = "provider_not_found", + + AUTH_FAILED = "auth_failed", + PERMISSION_DENIED = "permission_denied", + + SECRET_NOT_FOUND = "secret_not_found", + SECRET_FETCH_FAILED = "secret_fetch_failed", + + STORAGE_READ_FAILED = "storage_read_failed", + STORAGE_WRITE_FAILED = "storage_write_failed", + + UNKNOWN = "unknown", +} + +export interface SecretsError { + code: SecretsErrorCode; + message: string; + providerId?: string; + secretRef?: SecretReference; + cause?: Error; // Original error +} + +export type SecretsManagerError = { + type: "error"; + error: SecretsError; +}; + +export type SecretsSuccess = T extends void + ? { type: "success" } + : { type: "success"; data: T }; + +export type SecretsResult = SecretsSuccess | SecretsManagerError; + +export type SecretsResultPromise = Promise>; + +export interface SecretFetchError { + secretRefId: string; + message: string; +} + +export interface FetchSecretsResultData { + secrets: SecretValue[]; + errors: SecretFetchError[]; +} + +export function createSecretsError( + code: SecretsErrorCode, + message: string, + context?: Omit +): SecretsManagerError { + return { + type: "error", + error: { + code, + message, + ...context, + }, + }; +} diff --git a/src/lib/secretsManager/index.ts b/src/lib/secretsManager/index.ts new file mode 100644 index 00000000..08786939 --- /dev/null +++ b/src/lib/secretsManager/index.ts @@ -0,0 +1,194 @@ +import { SecretsManagerEncryptedStorage } from "./encryptedStorage/SecretsManagerEncryptedStorage"; +import { + AbstractSecretsManagerStorage, + ProviderStorageChangeCallback, + SecretStorageChangeCallback, +} from "./encryptedStorage/AbstractSecretsManagerStorage"; +import { FileBasedProviderRegistry } from "./providerRegistry/FileBasedProviderRegistry"; +import { ProviderChangeCallback } from "./providerRegistry/AbstractProviderRegistry"; +import { SecretsManager } from "./secretsManager"; +import { + SecretProviderConfig, + SecretProviderMetadata, + SecretReference, + SecretValue, +} from "./types"; +import { + createSecretsError, + SecretsErrorCode, + SecretsResultPromise, + FetchSecretsResultData, +} from "./errors"; +import { createProviderInstance } from "./providerService/providerFactory"; + +export class NoopSecretsManagerStorage extends AbstractSecretsManagerStorage { + async setProviderConfig(): Promise {} + async setSecretValue(): Promise {} + async setSecretValues(): Promise {} + async deleteSecretValues(): Promise {} + async getProviderConfig(): Promise { + return null; + } + async getSecretValue(): Promise { + return null; + } + async getAllProviderConfigs(): Promise<[]> { + return []; + } + async getAllSecretValues(): Promise<[]> { + return []; + } + async deleteProviderConfig(): Promise {} + async deleteSecretValue(): Promise {} + onProvidersChange(_callback: ProviderStorageChangeCallback): () => void { + return () => {}; + } + onSecretsChange(_callback: SecretStorageChangeCallback): () => void { + return () => {}; + } +} + +const getSecretsManager = (): SecretsManager => { + if (!SecretsManager.isInitialized()) { + return null as any; + } + return SecretsManager.getInstance(); +}; + +export const initSecretsManager = async ( + userId: string +): SecretsResultPromise => { + try { + if(!userId){ + return createSecretsError( + SecretsErrorCode.INVALID_USER_ID, + "Invalid user ID provided for SecretsManager initialization." + ); + } + const storeName = `sm-${userId}`; + const secretsStorage = new SecretsManagerEncryptedStorage( + storeName, + userId + ); + const registry = new FileBasedProviderRegistry(secretsStorage); + + SecretsManager.reset(); + await SecretsManager.initialize(registry); + + return { + type: "success", + }; + } catch (error) { + if ((error as Error).name === "SafeStorageEncryptionNotAvailable") { + return createSecretsError( + SecretsErrorCode.SAFE_STORAGE_ENCRYPTION_NOT_AVAILABLE, + "Safe storage encryption is not available.", // UI to show OS specific message here + { + cause: error as Error, + } + ); + } + + return createSecretsError( + SecretsErrorCode.UNKNOWN, + "Failed to initialize SecretsManager.", + { + cause: error as Error, + } + ); + } +}; + +export const subscribeToProvidersChange = ( + callback: ProviderChangeCallback +): (() => void) => { + return getSecretsManager().onProvidersChange(callback); +}; + +export const setSecretProviderConfig = async ( + config: SecretProviderConfig +): SecretsResultPromise => { + return getSecretsManager().setProviderConfig(config); +}; + +export const removeSecretProviderConfig = async ( + providerId: string +): SecretsResultPromise => { + return getSecretsManager().removeProviderConfig(providerId); +}; + +export const getSecretProviderConfig = async ( + providerId: string +): SecretsResultPromise => { + return getSecretsManager().getProviderConfig(providerId); +}; + +export const testSecretProviderConnection = async ( + providerId: string +): SecretsResultPromise => { + return getSecretsManager().testProviderConnection(providerId); +}; + +export const getSecretValue = async ( + providerId: string, + ref: SecretReference +): SecretsResultPromise => { + return getSecretsManager().getSecret(providerId, ref); +}; + +export const getSecretValues = async ( + secrets: Array<{ providerId: string; ref: SecretReference }> +): SecretsResultPromise => { + return getSecretsManager().getSecrets(secrets); +}; + +export const fetchAndSaveSecrets = async ( + providerId: string, + secretRefs: SecretReference[] +): SecretsResultPromise => { + return getSecretsManager().fetchAndSaveSecrets(providerId, secretRefs); +}; + +export const listSecretProviders = async (): SecretsResultPromise< + SecretProviderMetadata[] +> => { + return getSecretsManager().listProviders(); +}; + +export const removeSecretValue = async ( + providerId: string, + ref: SecretReference +): SecretsResultPromise => { + return getSecretsManager().removeSecret(providerId, ref); +}; + +export const removeSecretValues = async ( + secrets: Array<{ providerId: string; ref: SecretReference }> +): SecretsResultPromise => { + return getSecretsManager().removeSecrets(secrets); +}; + +export const testSecretProviderConnectionWithConfig = async ( + config: SecretProviderConfig +): SecretsResultPromise => { + try { + const provider = createProviderInstance( + config, + new NoopSecretsManagerStorage() + ); + const isConnected = await provider.testConnection(); + return { type: "success", data: isConnected ?? false }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.AUTH_FAILED, + error instanceof Error ? error.message : "Connection test failed", + { providerId: config.id, cause: error as Error } + ); + } +}; + +export const listSecrets = async ( + providerId: string +): SecretsResultPromise => { + return getSecretsManager().listSecrets(providerId); +}; diff --git a/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts index 0d8bb10f..8525d7b5 100644 --- a/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts +++ b/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts @@ -1,9 +1,13 @@ -import { SecretProviderConfig, SecretProviderType } from "../types"; +import { + SecretProviderConfig, + SecretProviderMetadata, + SecretProviderType, +} from "../types"; import { AbstractSecretsManagerStorage } from "../encryptedStorage/AbstractSecretsManagerStorage"; import { AbstractSecretProvider } from "../providerService/AbstractSecretProvider"; export type ProviderChangeCallback = ( - configs: Omit[] + configs: SecretProviderMetadata[] ) => void; export abstract class AbstractProviderRegistry { diff --git a/src/lib/secretsManager/providerRegistry/FileBasedProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/FileBasedProviderRegistry.ts index 703582e3..72f3f6eb 100644 --- a/src/lib/secretsManager/providerRegistry/FileBasedProviderRegistry.ts +++ b/src/lib/secretsManager/providerRegistry/FileBasedProviderRegistry.ts @@ -17,45 +17,32 @@ export class FileBasedProviderRegistry extends AbstractProviderRegistry { private async initProvidersFromStorage(): Promise { const configs = await this.getAllProviderConfigs(); configs.forEach((config) => { - try { - this.providers.set(config.id, createProviderInstance(config)); - } catch (error) { - // TODO error to be propagated - console.log( - "!!!debug", - `Failed to initialize provider for config id: ${config.id}`, - error - ); - } + this.providers.set(config.id, createProviderInstance(config, this.store)); }); } async getAllProviderConfigs(): Promise { - const allConfigs = this.store.getAll(); - return allConfigs; + return this.store.getAllProviderConfigs(); } async getProviderConfig(id: string): Promise { - try { - return await this.store.get(id); - } catch (error) { - console.error(`Failed to load provider config for id: ${id}`, error); - return null; - } + return this.store.getProviderConfig(id); } async setProviderConfig(config: SecretProviderConfig): Promise { - const provider = createProviderInstance(config); - await this.store.set(config.id, config); + const provider = createProviderInstance(config, this.store); + await this.store.setProviderConfig(config.id, config); this.providers.set(config.id, provider); } async deleteProviderConfig(id: string): Promise { - await this.store.delete(id); + await this.store.deleteProviderConfig(id); this.providers.delete(id); } - getProvider(providerId: string): AbstractSecretProvider | null { + getProvider( + providerId: string + ): AbstractSecretProvider | null { return this.providers.get(providerId) ?? null; } @@ -68,9 +55,9 @@ export class FileBasedProviderRegistry extends AbstractProviderRegistry { } private setupStorageListener(): void { - this.store.onStorageChange((data) => { - this.syncProvidersFromStorageData(data); - this.notifyChangeCallbacks(data); + this.store.onProvidersChange((providers) => { + this.syncProvidersFromStorageData(providers); + this.notifyChangeCallbacks(providers); }); } @@ -88,16 +75,7 @@ export class FileBasedProviderRegistry extends AbstractProviderRegistry { } for (const [id, config] of Object.entries(data)) { - try { - // recreate provider instance - this.providers.set(id, createProviderInstance(config)); - } catch (error) { - console.log( - "!!!debug", - `Failed to sync provider for config id: ${id}`, - error - ); - } + this.providers.set(id, createProviderInstance(config, this.store)); } } @@ -106,7 +84,7 @@ export class FileBasedProviderRegistry extends AbstractProviderRegistry { ): void { this.changeCallbacks.forEach((callback) => { const configsMetadata = Object.values(data).map((config) => { - const { config: _, ...metadata } = config; + const { credentials: _, ...metadata } = config; return metadata; }); callback(configsMetadata); diff --git a/src/lib/secretsManager/providerService/AbstractSecretProvider.ts b/src/lib/secretsManager/providerService/AbstractSecretProvider.ts index ae5149ff..5935c362 100644 --- a/src/lib/secretsManager/providerService/AbstractSecretProvider.ts +++ b/src/lib/secretsManager/providerService/AbstractSecretProvider.ts @@ -4,9 +4,12 @@ import { ReferenceForProvider, ValueForProvider, } from "../types"; +import { AbstractSecretsManagerStorage } from "../encryptedStorage/AbstractSecretsManagerStorage"; -const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour -const DEFAULT_MAX_CACHE_SIZE = 100; +export interface GetSecretValuesResult { + results: (ValueForProvider | null)[]; + errors: Array<{ ref: ReferenceForProvider; message: string }>; +} /** * Generic abstract base class for secret providers. @@ -14,11 +17,7 @@ const DEFAULT_MAX_CACHE_SIZE = 100; * @template T - The provider type */ export abstract class AbstractSecretProvider { - protected cache: Map> = new Map(); - - protected cacheTtlMs: number = DEFAULT_CACHE_TTL_MS; - - protected maxCacheSize: number = DEFAULT_MAX_CACHE_SIZE; + protected store: AbstractSecretsManagerStorage; abstract readonly type: T; @@ -26,27 +25,31 @@ export abstract class AbstractSecretProvider { protected abstract config: CredentialsForProvider; - protected abstract getCacheKey(_ref: ReferenceForProvider): string; + /** + * Returns the key used to persist/retrieve this secret in the store. + * Must be unique across all secrets for this provider. + */ + protected abstract getStorageKey(_ref: ReferenceForProvider): string; + + constructor(store: AbstractSecretsManagerStorage) { + this.store = store; + } abstract testConnection(): Promise; - abstract getSecret( + abstract getSecretValue( _ref: ReferenceForProvider ): Promise | null>; - abstract getSecrets( + abstract getSecretValues( _refs: ReferenceForProvider[] - ): Promise<(ValueForProvider | null)[]>; + ): Promise>; - abstract setSecret( - _ref: ReferenceForProvider, - _value: string | Record - ): Promise; + abstract setSecret(_value: ValueForProvider): Promise; abstract setSecrets( _entries: Array<{ - ref: ReferenceForProvider; - value: string | Record; + value: ValueForProvider; }> ): Promise; @@ -54,7 +57,7 @@ export abstract class AbstractSecretProvider { abstract removeSecrets(_refs: ReferenceForProvider[]): Promise; - abstract refreshSecrets(): Promise<(ValueForProvider | null)[]>; + abstract listAllSecrets(): Promise<(ValueForProvider)[]>; static validateConfig(config: any): boolean { // Base implementation rejects all configs as a fail-safe. @@ -65,47 +68,4 @@ export abstract class AbstractSecretProvider { return false; } - - protected invalidateCache(): void { - this.cache.clear(); - } - - protected getCachedSecret(key: string): ValueForProvider | null { - const cached = this.cache.get(key); - if (cached && cached.fetchedAt + this.cacheTtlMs > Date.now()) { - return cached; - } - return null; - } - - protected setCacheEntry(key: string, value: ValueForProvider): void { - if (this.maxCacheSize <= 0) { - return; - } - - this.evictExpiredEntries(); - - while (this.cache.size >= this.maxCacheSize) { - const oldestKey = this.cache.keys().next().value; - if (!oldestKey) { - break; - } - this.cache.delete(oldestKey); - } - - this.cache.set(key, value); - } - - protected evictExpiredEntries(): void { - const now = Date.now(); - const keysToDelete: string[] = []; - - this.cache.forEach((value, key) => { - if (value.fetchedAt + this.cacheTtlMs <= now) { - keysToDelete.push(key); - } - }); - - keysToDelete.forEach((key) => this.cache.delete(key)); - } } diff --git a/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts b/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts index 0087af99..863e6b49 100644 --- a/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts +++ b/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts @@ -1,5 +1,10 @@ -import { SecretProviderType, ProviderConfig, SecretReference } from "../baseTypes"; +import { + SecretProviderType, + ProviderConfig, + SecretReference, +} from "../baseTypes"; import { AbstractSecretProvider } from "./AbstractSecretProvider"; +import { AbstractSecretsManagerStorage } from "../encryptedStorage/AbstractSecretsManagerStorage"; import { GetSecretValueCommand, GetSecretValueCommandOutput, @@ -19,7 +24,8 @@ export type AWSSecretProviderConfig = ProviderConfig< AWSSecretsManagerCredentials >; -export interface AwsSecretReference extends SecretReference { +export interface AwsSecretReference + extends SecretReference { identifier: string; version?: string; } @@ -44,8 +50,11 @@ export class AWSSecretsManagerProvider extends AbstractSecretProvider { @@ -67,39 +76,23 @@ export class AWSSecretsManagerProvider extends AbstractSecretProvider { + async getSecretValue( + ref: AwsSecretReference + ): Promise { if (!this.client) { throw new Error("AWS Secrets Manager client is not initialized."); } - const cacheKey = this.getCacheKey(ref); - const cachedSecret = this.getCachedSecret(cacheKey) as AwsSecretValue | null; - - if (cachedSecret) { - console.log("!!!debug", "returning from cache", cachedSecret); - return cachedSecret; - } - const getSecretCommand = new GetSecretValueCommand({ SecretId: ref.identifier, VersionId: ref.version, @@ -108,13 +101,7 @@ export class AWSSecretsManagerProvider extends AbstractSecretProvider { + async getSecretValues(refs: AwsSecretReference[]): Promise<{ + results: (AwsSecretValue | null)[]; + errors: Array<{ ref: AwsSecretReference; message: string }>; + }> { if (!this.client) { throw new Error("AWS Secrets Manager client is not initialized."); } - // Not using BatchGetSecretValueCommand as it would require additional permissions - return Promise.all(refs.map((ref) => this.getSecret(ref))); - } + const settled = await Promise.allSettled( + refs.map((ref) => this.getSecretValue(ref)) + ); + const results: (AwsSecretValue | null)[] = []; + const errors: Array<{ ref: AwsSecretReference; message: string }> = []; + + for (let i = 0; i < settled.length; i++) { + const r = settled[i]; + if (r.status === "fulfilled") { + results.push(r.value); + } else { + results.push(null); + errors.push({ + ref: refs[i], + message: + r.reason instanceof Error ? r.reason.message : String(r.reason), + }); + } + } - async setSecret(): Promise { - throw new Error("Method not implemented."); + return { results, errors }; } - async setSecrets(): Promise { - throw new Error("Method not implemented."); + // upserts a secret in the store + async setSecret(value: AwsSecretValue): Promise { + await this.store.setSecretValue( + this.getStorageKey(value.secretReference), + value + ); } - async removeSecret(): Promise { - throw new Error("Method not implemented."); + // removes and sets all the secrets for the provider + async setSecrets(entries: Array<{ value: AwsSecretValue }>): Promise { + const toSet: Record = {}; + for (const entry of entries) { + toSet[this.getStorageKey(entry.value.secretReference)] = entry.value; + } + + const allSecrets = await this.listAllSecrets(); + const newKeys = new Set(Object.keys(toSet)); + + const toRemove = allSecrets + .map((s) => this.getStorageKey(s.secretReference)) + .filter((key) => !newKeys.has(key)); + + await this.store.setSecretValues(toSet); + await this.store.deleteSecretValues(toRemove); } - async removeSecrets(): Promise { - throw new Error("Method not implemented."); + async removeSecret(ref: AwsSecretReference): Promise { + await this.store.deleteSecretValue(this.getStorageKey(ref)); } - async refreshSecrets(): Promise<(AwsSecretValue | null)[]> { - const allSecretRefs = Array.from(this.cache.values()).map( - (secret) => secret.secretReference + async removeSecrets(refs: AwsSecretReference[]): Promise { + await this.store.deleteSecretValues( + refs.map((ref) => this.getStorageKey(ref)) ); + } - this.invalidateCache(); + async listAllSecrets(): Promise { + const allSecrets = await this.store.getAllSecretValues(); + const providerSecrets = allSecrets.filter( + (s) => s.providerId === this.id + ) as AwsSecretValue[]; - return this.getSecrets(allSecretRefs); + return providerSecrets; } static validateConfig(config: AWSSecretsManagerCredentials): boolean { diff --git a/src/lib/secretsManager/providerService/hashicorpVaultProvider.ts b/src/lib/secretsManager/providerService/hashicorpVaultProvider.ts index b7017cf6..ad5a8112 100644 --- a/src/lib/secretsManager/providerService/hashicorpVaultProvider.ts +++ b/src/lib/secretsManager/providerService/hashicorpVaultProvider.ts @@ -1,3 +1,4 @@ +import { NoopSecretsManagerStorage } from ".."; import { SecretProviderType, ProviderConfig, SecretReference } from "../baseTypes"; import { AbstractSecretProvider } from "./AbstractSecretProvider"; @@ -14,7 +15,7 @@ export type HashicorpVaultProviderConfig = ProviderConfig< >; export interface VaultSecretReference extends SecretReference { - path: string; + identifier: string; version?: number; } @@ -23,7 +24,7 @@ export interface VaultSecretValue { providerId: string; secretReference: VaultSecretReference; fetchedAt: number; - path: string; + identifier: string; data: Record; metadata?: { version: number; @@ -41,36 +42,35 @@ export class HashicorpVaultProvider extends AbstractSecretProvider { throw new Error("Method not implemented."); } - async getSecret(_ref: VaultSecretReference): Promise { + async getSecretValue(_ref: VaultSecretReference): Promise { throw new Error("Method not implemented."); } - async getSecrets(_refs: VaultSecretReference[]): Promise<(VaultSecretValue | null)[]> { + async getSecretValues(_refs: VaultSecretReference[]): Promise<{ results: (VaultSecretValue | null)[]; errors: Array<{ ref: VaultSecretReference; message: string }> }> { throw new Error("Method not implemented."); } async setSecret( - _ref: VaultSecretReference, - _value: string | Record + _value: VaultSecretValue ): Promise { throw new Error("Method not implemented."); } async setSecrets( - _entries: Array<{ ref: VaultSecretReference; value: string | Record }> + _entries: Array<{ value: string | Record }> ): Promise { throw new Error("Method not implemented."); } @@ -83,7 +83,7 @@ export class HashicorpVaultProvider extends AbstractSecretProvider { + async listAllSecrets(): Promise<(VaultSecretValue)[]> { throw new Error("Method not implemented."); } diff --git a/src/lib/secretsManager/providerService/providerFactory.ts b/src/lib/secretsManager/providerService/providerFactory.ts index 794c44fb..fc472eba 100644 --- a/src/lib/secretsManager/providerService/providerFactory.ts +++ b/src/lib/secretsManager/providerService/providerFactory.ts @@ -1,14 +1,15 @@ import { SecretProviderConfig, SecretProviderType } from "../types"; +import { AbstractSecretsManagerStorage } from "../encryptedStorage/AbstractSecretsManagerStorage"; import { AWSSecretsManagerProvider } from "./awsSecretManagerProvider"; import { AbstractSecretProvider } from "./AbstractSecretProvider"; export function createProviderInstance( - config: SecretProviderConfig + config: SecretProviderConfig, + store: AbstractSecretsManagerStorage ): AbstractSecretProvider { switch (config.type) { case SecretProviderType.AWS_SECRETS_MANAGER: { - // TypeScript knows config is AWSSecretProviderConfig here - return new AWSSecretsManagerProvider(config); + return new AWSSecretsManagerProvider(config, store); } default: { diff --git a/src/lib/secretsManager/secretsManager.ts b/src/lib/secretsManager/secretsManager.ts index e2f83ea7..69673c9e 100644 --- a/src/lib/secretsManager/secretsManager.ts +++ b/src/lib/secretsManager/secretsManager.ts @@ -1,5 +1,22 @@ -import { SecretProviderConfig, SecretReference, SecretValue } from "./types"; -import { AbstractProviderRegistry } from "./providerRegistry/AbstractProviderRegistry"; +import { + AwsSecretValue, + SecretProviderConfig, + SecretProviderMetadata, + SecretProviderType, + SecretReference, + SecretValue, +} from "./types"; +import { + AbstractProviderRegistry, + ProviderChangeCallback, +} from "./providerRegistry/AbstractProviderRegistry"; +import { + SecretsResultPromise, + createSecretsError, + SecretsErrorCode, + SecretFetchError, + FetchSecretsResultData, +} from "./errors"; export class SecretsManager { // eslint-disable-next-line no-use-before-define @@ -59,48 +76,112 @@ export class SecretsManager { this.initPromise = null; } - async setProviderConfig(config: SecretProviderConfig) { - console.log("!!!debug", "addconfig", config); - await this.registry.setProviderConfig(config); + async setProviderConfig( + config: SecretProviderConfig + ): SecretsResultPromise { + try { + await this.registry.setProviderConfig(config); + return { type: "success" }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_WRITE_FAILED, + error instanceof Error ? error.message : String(error), + { + providerId: config.id, + cause: error as Error, + } + ); + } } - async removeProviderConfig(id: string) { - await this.registry.deleteProviderConfig(id); + async removeProviderConfig(id: string): SecretsResultPromise { + try { + await this.registry.deleteProviderConfig(id); + return { type: "success" }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_WRITE_FAILED, + error instanceof Error ? error.message : String(error), + { + providerId: id, + cause: error as Error, + } + ); + } } - async getProviderConfig(id: string): Promise { - return this.registry.getProviderConfig(id); + async getProviderConfig( + id: string + ): SecretsResultPromise { + try { + const config = await this.registry.getProviderConfig(id); + return { type: "success", data: config }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_READ_FAILED, + error instanceof Error ? error.message : String(error), + { + providerId: id, + cause: error as Error, + } + ); + } } - async testProviderConnection(id: string): Promise { - const provider = this.registry.getProvider(id); + async testProviderConnection(id: string): SecretsResultPromise { + try { + const provider = this.registry.getProvider(id); - if (!provider) { - throw new Error(`Provider with id ${id} not found`); - } - - const isConnected = await provider.testConnection(); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${id} not found`, + { providerId: id } + ); + } - return isConnected ?? false; + const isConnected = await provider.testConnection(); + return { type: "success", data: isConnected ?? false }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.AUTH_FAILED, + error instanceof Error + ? error.message + : `Failed to test connection for provider ${id}`, + { providerId: id, cause: error as Error } + ); + } } async getSecret( providerId: string, ref: SecretReference - ): Promise { - const provider = this.registry.getProvider(providerId); - if (!provider) { - throw new Error(`Provider with id ${providerId} not found`); + ): SecretsResultPromise { + try { + const provider = this.registry.getProvider(providerId); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } + const secretValue = await provider.getSecretValue(ref); + return { type: "success", data: secretValue }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.SECRET_FETCH_FAILED, + error instanceof Error + ? error.message + : `Failed to fetch secret from provider`, + { providerId, secretRef: ref, cause: error as Error } + ); } - - const secretValue = await provider.getSecret(ref); - - return secretValue; } async getSecrets( secrets: Array<{ providerId: string; ref: SecretReference }> - ): Promise { + ): SecretsResultPromise { const providerMap: Map = new Map(); for (const s of secrets) { @@ -112,57 +193,185 @@ export class SecretsManager { const results: SecretValue[] = []; + // Handle partial failures appropriately for (const [providerId, refs] of providerMap.entries()) { - const provider = this.registry.getProvider(providerId); + try { + const provider = this.registry.getProvider(providerId); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } - if (!provider) { - // TODO: Error to be handled properly - continue; + const { results: secretValues } = await provider.getSecretValues(refs); + results.push( + ...secretValues.filter((sv): sv is SecretValue => sv !== null) + ); + } catch (error) { + return createSecretsError( + SecretsErrorCode.SECRET_FETCH_FAILED, + error instanceof Error + ? error.message + : `Failed to fetch secrets from provider ${providerId}`, + { providerId, cause: error as Error } + ); } + } - const secretValues = await provider.getSecrets(refs); + return { type: "success", data: results }; + } - results.push( - ...secretValues.filter((sv): sv is SecretValue => sv !== null) + async removeSecret( + providerId: string, + ref: SecretReference + ): SecretsResultPromise { + try { + const provider = this.registry.getProvider(providerId); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } + await provider.removeSecret(ref); + return { type: "success" }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_WRITE_FAILED, + error instanceof Error + ? error.message + : `Failed to remove secret from provider ${providerId}`, + { providerId, secretRef: ref, cause: error as Error } ); } - - return results; } - async refreshSecrets(providerId: string): Promise<(SecretValue | null)[]> { - const provider = this.registry.getProvider(providerId); - if (!provider) { - throw new Error(`Provider with id ${providerId} not found`); + async removeSecrets( + secrets: Array<{ providerId: string; ref: SecretReference }> + ): SecretsResultPromise { + const providerMap: Map = new Map(); + + for (const s of secrets) { + if (!providerMap.has(s.providerId)) { + providerMap.set(s.providerId, []); + } + providerMap.get(s.providerId)?.push(s.ref); } - return provider.refreshSecrets(); + for (const [providerId, refs] of providerMap.entries()) { + try { + const provider = this.registry.getProvider(providerId); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } + await provider.removeSecrets(refs); + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_WRITE_FAILED, + error instanceof Error + ? error.message + : `Failed to remove secrets from provider ${providerId}`, + { providerId, cause: error as Error } + ); + } + } + + return { type: "success" }; } - async listProviders(): Promise[]> { - const configs = await this.registry.getAllProviderConfigs(); + async fetchAndSaveSecrets( + providerId: string, + secretRefs: SecretReference[] + ): SecretsResultPromise { + try { + const provider = this.registry.getProvider(providerId); - const configMetadata: Omit[] = configs.map( - ({ config: _, ...rest }) => rest - ); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } - return configMetadata; + const { results, errors: fetchErrors } = await provider.getSecretValues(secretRefs); + + const perSecretErrors: SecretFetchError[] = fetchErrors.map((e) => ({ + secretRefId: e.ref.id, + message: e.message, + })); + + const successSecrets: SecretValue[] = []; + for (const s of results) { + if (!s) continue; + const hasEmptyValue = + (s.type === SecretProviderType.AWS_SECRETS_MANAGER && !s.value); + if (hasEmptyValue) { + perSecretErrors.push({ secretRefId: s.secretReference.id, message: "Secret value is empty" }); + continue; + } + successSecrets.push(s); + } + + await provider.setSecrets(successSecrets.map((s) => ({ value: s }))); + + return { type: "success", data: { secrets: successSecrets, errors: perSecretErrors } }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.SECRET_FETCH_FAILED, + error instanceof Error + ? error.message + : `Failed to refresh secrets for provider ${providerId}`, + { providerId, cause: error as Error } + ); + } + } + + async listProviders(): SecretsResultPromise { + try { + const configs = await this.registry.getAllProviderConfigs(); + const configMetadata: SecretProviderMetadata[] = configs.map( + ({ credentials: _, ...rest }) => rest + ); + return { type: "success", data: configMetadata }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_READ_FAILED, + error instanceof Error ? error.message : "Failed to list providers", + { cause: error as Error } + ); + } } onProvidersChange(callback: ProviderChangeCallback): () => void { return this.registry.onProvidersChange(callback); } -} -/** - * // At app startup (once): - * await SecretsManager.initialize(registry); - * - * // Everywhere else: - * import { getSecretsManager } from "./secretsManager"; - * const secretsManager = getSecretsManager(); - * await secretsManager.getSecret(...); - */ -export function getSecretsManager(): SecretsManager { - return SecretsManager.getInstance(); + async listSecrets(providerId: string): SecretsResultPromise { + try { + const provider = this.registry.getProvider(providerId); + if (!provider) { + return createSecretsError( + SecretsErrorCode.PROVIDER_NOT_FOUND, + `Provider with id ${providerId} not found`, + { providerId } + ); + } + const secrets = await provider.listAllSecrets(); + return { type: "success", data: secrets }; + } catch (error) { + return createSecretsError( + SecretsErrorCode.STORAGE_READ_FAILED, + error instanceof Error ? error.message : "Failed to list secrets", + { providerId, cause: error as Error } + ); + } + } } diff --git a/src/lib/secretsManager/types.ts b/src/lib/secretsManager/types.ts index 8664ab17..1fd1a5bf 100644 --- a/src/lib/secretsManager/types.ts +++ b/src/lib/secretsManager/types.ts @@ -27,6 +27,8 @@ export type SecretProviderConfig = | AWSSecretProviderConfig | HashicorpVaultProviderConfig; +export type SecretProviderMetadata = Omit; + export type SecretReference = AwsSecretReference | VaultSecretReference; export type SecretValue = AwsSecretValue | VaultSecretValue; diff --git a/src/lib/storage/EncryptedElectronStore.ts b/src/lib/storage/EncryptedElectronStore.ts index 5922c0be..6610c6a1 100644 --- a/src/lib/storage/EncryptedElectronStore.ts +++ b/src/lib/storage/EncryptedElectronStore.ts @@ -20,9 +20,11 @@ export class EncryptedElectronStore { constructor(storeName: string) { if (!safeStorage.isEncryptionAvailable()) { - throw new Error( + const error = new Error( "Encryption is not available on this system. Please ensure your operating system's secure storage is properly configured." ); + error.name = "SafeStorageEncryptionNotAvailable"; + throw error; } const storeOptions: Store.Options = { @@ -97,6 +99,14 @@ export class EncryptedElectronStore { }); } + onKeyChange(subKey: string, callback: (_data: T) => void): () => void { + return this.store.onDidChange(`data.${subKey}` as keyof EncryptedStoreSchema, (newValue) => { + if (newValue !== undefined) { + callback(newValue as T); + } + }); + } + getStore(): Store { return this.store; } diff --git a/src/main/events.js b/src/main/events.js index f9d2c646..98c7f147 100644 --- a/src/main/events.js +++ b/src/main/events.js @@ -23,12 +23,22 @@ import { createOrUpdateAxiosInstance } from "./actions/getProxiedAxios"; // and then build these utilites elsewhere // eslint-disable-next-line import/no-cycle import createTrayMenu, { loadWebAppUrl } from "./main"; -import { SecretsManagerEncryptedStorage } from "../lib/secretsManager/encryptedStorage/SecretsManagerEncryptedStorage"; -import { FileBasedProviderRegistry } from "../lib/secretsManager/providerRegistry/FileBasedProviderRegistry"; import { - getSecretsManager, - SecretsManager, -} from "../lib/secretsManager/secretsManager"; + fetchAndSaveSecrets, + getSecretProviderConfig, + getSecretValue, + getSecretValues, + initSecretsManager, + listSecretProviders, + listSecrets, + removeSecretProviderConfig, + removeSecretValue, + removeSecretValues, + setSecretProviderConfig, + subscribeToProvidersChange, + testSecretProviderConnection, + testSecretProviderConnectionWithConfig, +} from "../lib/secretsManager"; const getFileCategory = (fileExtension) => { switch (fileExtension) { @@ -294,60 +304,87 @@ export const registerMainProcessEventsForWebAppWindow = (webAppWindow) => { webAppWindow?.send("helper-server-hit"); }); - let secretsManager = null; - - ipcMain.handle("init-secretsManager", async () => { - const secretsStorage = new SecretsManagerEncryptedStorage("providers"); - const registry = new FileBasedProviderRegistry(secretsStorage); + ipcMain.handle("secretsManager:init", (event, { userId }) => { + return initSecretsManager(userId); + }); - await SecretsManager.initialize(registry); - secretsManager = getSecretsManager(); - return true; + ipcMain.handle("secretsManager:subscribeToProvidersChange", () => { + subscribeToProvidersChange((providers) => { + webAppWindow?.webContents.send( + "secretsManager:providersChanged", + providers + ); + }); }); ipcMain.handle( - "secretsManager:addProviderConfig", - async (event, { config }) => { - await secretsManager.setProviderConfig(config); + "secretsManager:setSecretProviderConfig", + (event, { config }) => { + return setSecretProviderConfig(config); } ); - ipcMain.handle("secretsManager:getProviderConfig", async (event, { id }) => { - const providerConfig = await secretsManager.getProviderConfig(id); - console.log("!!!debug", "getConfig", providerConfig); - return providerConfig; - }); + ipcMain.handle( + "secretsManager:getSecretProviderConfig", + (event, { providerId }) => { + return getSecretProviderConfig(providerId); + } + ); ipcMain.handle( - "secretsManager:removeProviderConfig", - async (event, { id }) => { - await secretsManager.removeProviderConfig(id); + "secretsManager:removeSecretProviderConfig", + (event, { providerId }) => { + return removeSecretProviderConfig(providerId); } ); ipcMain.handle( - "secretsManager:testConnection", - async (event, { providerId }) => { - const providerConnection = await secretsManager.testProviderConnection( - providerId - ); + "secretsManager:testProviderConnection", + (event, { providerId }) => { + return testSecretProviderConnection(providerId); + } + ); - return providerConnection; + ipcMain.handle( + "secretsManager:testProviderConnectionWithConfig", + (event, { config }) => { + return testSecretProviderConnectionWithConfig(config); } ); ipcMain.handle( - "secretsManager:resolveSecret", - async (event, { providerId, ref }) => { - console.log("!!!debug", "resolve", { - providerId, - ref, - }); - const secretValue = await secretsManager.getSecret(providerId, ref); - console.log("!!!debug", "resolveSecret value", secretValue); - return secretValue; + "secretsManager:getSecretValue", + (event, { providerId, secretReference }) => { + return getSecretValue(providerId, secretReference); + } + ); + + ipcMain.handle("secretsManager:getSecretValues", (event, { secrets }) => { + return getSecretValues(secrets); + }); + + ipcMain.handle("secretsManager:fetchAndSaveSecrets", (event, { providerId, secretRefs }) => { + return fetchAndSaveSecrets(providerId, secretRefs); + }); + + ipcMain.handle("secretsManager:listSecretProviders", () => { + return listSecretProviders(); + }); + + ipcMain.handle( + "secretsManager:removeSecretValue", + (event, { providerId, secretReference }) => { + return removeSecretValue(providerId, secretReference); } ); + + ipcMain.handle("secretsManager:removeSecretValues", (event, { secrets }) => { + return removeSecretValues(secrets); + }); + + ipcMain.handle("secretsManager:listSecrets", (event, { providerId }) => { + return listSecrets(providerId); + }); }; export const registerMainProcessCommonEvents = () => {