diff --git a/src/lib/secretsManager/encryptedStorage/AbstractEncryptedStorage.ts b/src/lib/secretsManager/encryptedStorage/AbstractEncryptedStorage.ts new file mode 100644 index 00000000..d3a43082 --- /dev/null +++ b/src/lib/secretsManager/encryptedStorage/AbstractEncryptedStorage.ts @@ -0,0 +1,12 @@ +export abstract class AbstractEncryptedStorage { + abstract initialize(): Promise; + + abstract save>( + key: string, + data: T + ): Promise; + + abstract load>(key: string): Promise; + + abstract delete(key: string): Promise; +} diff --git a/src/lib/secretsManager/encryptedStorage/encryptedFsStorageService.ts b/src/lib/secretsManager/encryptedStorage/encryptedFsStorageService.ts new file mode 100644 index 00000000..ad54941a --- /dev/null +++ b/src/lib/secretsManager/encryptedStorage/encryptedFsStorageService.ts @@ -0,0 +1,26 @@ +import { safeStorage } from "electron"; +import { IEncryptedStorage } from "./IEncryptedStorage"; + +export class EncryptedFsStorageService implements IEncryptedStorage { + constructor(private readonly baseFolderPath: string) {} + + async initialize(): Promise { + if (!safeStorage.isEncryptionAvailable()) { + // Show trouble shooting steps to user + throw new Error("Encryption is not available on this system. "); + } + + // initialize directories + } + + async save>( + key: string, + data: T + ): Promise { + // encrypted + } + + async load>(key: string): Promise {} + + async delete(key: string): Promise {} +} diff --git a/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts new file mode 100644 index 00000000..8b5fbcfc --- /dev/null +++ b/src/lib/secretsManager/providerRegistry/AbstractProviderRegistry.ts @@ -0,0 +1,42 @@ +import { SecretProviderConfig, SecretProviderType } from "../types"; +import { AbstractEncryptedStorage } from "../encryptedStorage/AbstractEncryptedStorage"; +import { AbstractSecretProvider } from "../providerService/AbstractSecretProvider"; + +export interface ProvidersManifest { + version: string; + providers: { + id: string; + storagePath: string; + type: SecretProviderType; + }[]; +} + +export abstract class AbstractProviderRegistry { + protected encryptedStorage: AbstractEncryptedStorage; + + protected providers: Map = new Map(); + + constructor(encryptedStorage: AbstractEncryptedStorage) { + this.encryptedStorage = encryptedStorage; + } + + abstract initialize(): Promise; + + protected abstract createProviderInstance( + config: SecretProviderConfig + ): AbstractSecretProvider; + + protected abstract loadManifest(): Promise; + + protected abstract saveManifest(manifest: ProvidersManifest): Promise; + + abstract getAllProviderConfigs(): Promise; + + abstract getProviderConfig(id: string): Promise; + + abstract setProviderConfig(config: SecretProviderConfig): Promise; + + abstract deleteProviderConfig(id: string): Promise; + + abstract getProvider(providerId: string): AbstractSecretProvider | null; +} diff --git a/src/lib/secretsManager/providerRegistry/FsProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/FsProviderRegistry.ts new file mode 100644 index 00000000..7631509c --- /dev/null +++ b/src/lib/secretsManager/providerRegistry/FsProviderRegistry.ts @@ -0,0 +1,112 @@ +import * as fs from "fs"; +import * as path from "path"; +import { SecretProviderConfig, SecretProviderType } from "../types"; +import { createProvider } from "../providerService/providerFactory"; +import { AbstractProviderRegistry } from "./AbstractProviderRegistry"; +import { AbstractEncryptedStorage } from "../encryptedStorage/AbstractEncryptedStorage"; +import { AbstractSecretProvider } from "../providerService/AbstractSecretProvider"; + +const MANIFEST_FILENAME = "providers.json"; + + + +// Functions +// 1. initialize registry (create config dir if not exists) +// 2. list providers +// 3. + +export class FileBasedProviderRegistry extends AbstractProviderRegistry { + private manifestPath: string; + + private configDir: string; + + protected providers: Map = new Map(); + + constructor(encryptedStorage: AbstractEncryptedStorage, configDir: string) { + super(encryptedStorage); + this.configDir = configDir; + this.manifestPath = path.join(configDir, MANIFEST_FILENAME); + } + + getProvider(providerId: string): AbstractSecretProvider | null { + return this.providers.get(providerId) || null; + } + + async initialize(): Promise { + await this.ensureConfigDir(); + this.initProvidersFromManifest(); + } + + private async initProvidersFromManifest() { + const configs = await this.getAllProviderConfigs(); + configs.forEach((config) => { + this.providers.set(config.id, this.createProviderInstance(config)); + }); + } + + async getAllProviderConfigs(): Promise { + const manifest = await this.loadManifest(); + const configs: SecretProviderConfig[] = []; + + for (const entry of manifest.providers) { + const config = await this.encryptedStorage.load( + entry.id + ); + configs.push(config); + } + + return configs; + } + + async setProviderConfig(config: SecretProviderConfig): Promise { + const manifest = await this.loadManifest(); + const storageKey = config.id; + + // do atomic write + await this.encryptedStorage.save(storageKey, config); + + // Update manifest + + await this.saveManifest(manifest); + this.providers.set(config.id, this.createProviderInstance(config)); + } + + async deleteProviderConfig(id: string): Promise { + const manifest = await this.loadManifest(); + const entry = manifest.providers.find((p) => p.id === id); + if (!entry) return; + + await this.encryptedStorage.delete(id); + + manifest.providers = manifest.providers.filter((p) => p.id !== id); + await this.saveManifest(manifest); + this.providers.delete(id); + } + + async getProviderConfig(id: string): Promise { + const manifest = await this.loadManifest(); + const entry = manifest.providers.find((p) => p.id === id); + if (!entry) return null; + + return this.encryptedStorage.load(id); + } + + private async ensureConfigDir(): Promise { + try { + await fs.mkdir(this.configDir, { recursive: true }); + } catch (error) { + console.error("Failed to create config directory:", error); + } + } + + protected async loadManifest(): Promise {} + + protected async saveManifest(manifest: ProvidersManifest): Promise {} + + // eslint-disable-next-line class-methods-use-this + protected createProviderInstance( + config: SecretProviderConfig + ): AbstractSecretProvider { + return createProvider(config); + } +} diff --git a/src/lib/secretsManager/providerService/AbstractSecretProvider.ts b/src/lib/secretsManager/providerService/AbstractSecretProvider.ts new file mode 100644 index 00000000..6902918c --- /dev/null +++ b/src/lib/secretsManager/providerService/AbstractSecretProvider.ts @@ -0,0 +1,27 @@ +import { CachedSecret, SecretProviderType, SecretReference } from "../types"; + +export abstract class AbstractSecretProvider { + protected cache: Map = new Map(); + + abstract readonly type: SecretProviderType; + + abstract readonly id: string; + + protected config: any; + + protected abstract getSecretIdentfier(ref: SecretReference): string; + + abstract testConnection(): Promise; + + abstract getSecret(ref: SecretReference): Promise; + + abstract getSecrets(): Promise; + + abstract setSecret(): Promise; + + abstract setSecrets(): Promise; + + static validateConfig(config: any): boolean { + throw new Error("Not implemented"); + } +} diff --git a/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts b/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts new file mode 100644 index 00000000..68587403 --- /dev/null +++ b/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts @@ -0,0 +1,82 @@ +/* eslint-disable class-methods-use-this */ +import { + AwsSecretReference, + AWSSecretsManagerConfig, + CachedSecret, + SecretProviderConfig, + SecretProviderType, + SecretReference, +} from "../types"; +import { AbstractSecretProvider } from "./AbstractSecretProvider"; + +// Functions +// 1. validate config +// 2. test connection +// 3. fetch secret +// 4. list secrets + +const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +export class AWSSecretsManagerProvider extends AbstractSecretProvider { + readonly type = SecretProviderType.AWS_SECRETS_MANAGER; + + readonly id: string; + + protected config: AWSSecretsManagerConfig; + + protected cache: Map = new Map(); + + protected getSecretIdentfier(ref: AwsSecretReference): string { + return `name=${ref.nameOrArn};version:${ref.version}`; + } + + constructor(providerConfig: SecretProviderConfig) { + super(); + this.id = providerConfig.id; + this.config = providerConfig.config as AWSSecretsManagerConfig; + } + + async testConnection(): Promise { + if (!AWSSecretsManagerProvider.validateConfig(this.config)) { + return false; + } + + return true; + } + + async getSecret(ref: AwsSecretReference): Promise { + const secretKey = this.getSecretIdentfier(ref); + const cachedSecret = this.cache.get(secretKey); + const now = Date.now(); + + if (cachedSecret && cachedSecret.expiry > now) { + return cachedSecret.value; + } + + // Fetch from AWS Secrets Manager + const secretValue = "fetched-secret-value"; // Placeholder for actual fetch logic + + this.cache.set(secretKey, { + value: secretValue, + expiry: now + DEFAULT_CACHE_TTL_MS, + }); + + return secretValue; + } + + async getSecrets(): Promise {} + + async setSecret(): Promise { + throw new Error("Method not implemented."); + } + + async setSecrets(): Promise { + throw new Error("Method not implemented."); + } + + static validateConfig(config: AWSSecretsManagerConfig): boolean { + return Boolean( + config.accessKeyId && config.secretAccessKey && config.region + ); + } +} diff --git a/src/lib/secretsManager/providerService/providerFactory.ts b/src/lib/secretsManager/providerService/providerFactory.ts new file mode 100644 index 00000000..15b4bfb5 --- /dev/null +++ b/src/lib/secretsManager/providerService/providerFactory.ts @@ -0,0 +1,14 @@ +import { SecretProviderConfig, SecretProviderType } from "../types"; +import { AWSSecretsManagerProvider } from "./awsSecretManagerProvider"; +import { AbstractSecretProvider } from "./AbstractSecretProvider"; + +export function createProvider( + config: SecretProviderConfig +): AbstractSecretProvider { + switch (config.type) { + case SecretProviderType.AWS_SECRETS_MANAGER: + return new AWSSecretsManagerProvider(config); + default: + throw new Error(`Unknown provider type: ${config.type}`); + } +} diff --git a/src/lib/secretsManager/secretsManager.ts b/src/lib/secretsManager/secretsManager.ts new file mode 100644 index 00000000..5a299e94 --- /dev/null +++ b/src/lib/secretsManager/secretsManager.ts @@ -0,0 +1,90 @@ +import { SecretProviderConfig, SecretReference } from "./types"; +import { EncryptedFsStorageService } from "./encryptedStorage/encryptedFsStorageService"; +import { FileBasedProviderRegistry } from "./providerRegistry/FsProviderRegistry"; +import { AbstractProviderRegistry } from "./providerRegistry/AbstractProviderRegistry"; + +// Questions +// 6. // providerId in fetchByIdentifier ?? would be confusing and then I can name it key but without using "key" word. How can I make it generic? AI suggested using composite key. + +// Functions +// 1. initialize registry and cache service +// 2. add/remove provider configs +// 3. fetch secret (with caching) +// 4. list secrets +// 5. invalidate cache + +// FLows +// 1. INIT - load all provider configs from the registry and create provider instances. +// 2. ADD/REMOVE PROVIDER CONFIG - update the registry and provider instances map. +// 3. FETCH SECRET - check cache first, if not found or expired, fetch from provider, store in cache, return secret. +// 4. Refresh Secrets - bulk fetch and update all secrets from their providers and update the cache +// + +const encryptedStorage = new EncryptedFsStorageService(""); +const providerRegistry = new FileBasedProviderRegistry(encryptedStorage, ""); + +const secretsManager = new SecretsManager(providerRegistry); + +// createProviderInstance should have cache Storage as dependency. +// providerRegistry cannot be exposed because it would have storage specific code like fs methods etc. + +// Why need to change cache storage at provider lever? +// Different providers may have different cache storage requirements but why? +// Different providers can have different source of truth but caching should be common. WHy not? +// But agreed provider should be the one interacting with cache storage and repo layer and not secretmanager + +// Changes +// 1. cacheService associated with provider instance instead of secretManager. +// 2. registry manages the providers map +// 3. All the methods from secretManager delegated to registry and provider instances. +// 4. provider instance creation is moved to registry. + +export class SecretsManager { + private registry: AbstractProviderRegistry; + + constructor(registry: AbstractProviderRegistry) { + this.registry = registry; + } + + async initialize(): Promise { + this.registry.initialize(); + } + + async addProviderConfig(config: SecretProviderConfig) { + this.registry.setProviderConfig(config); + } + + async removeProviderConfig(id: string) { + this.registry.deleteProviderConfig(id); + } + + async getProviderConfig(id: string): Promise { + return this.registry.getProviderConfig(id); + } + + async testProviderConnection(id: string): Promise { + const provider = this.registry.getProvider(id); + return provider?.testConnection() ?? false; + } + + async fetchSecret(providerId: string, ref: SecretReference): Promise { + this.registry.getProvider(providerId)?.getSecret(ref); + } + + async refreshSecrets( + secrets: Array<{ providerId: string; ref: SecretReference }> + ): Promise { + for (const s of secrets) { + // Invalidate cache + // Fetch fresh secret and update cache + } + } + + async fetchSecrets( + secrets: Array<{ providerId: string; ref: SecretReference }> + ): Promise> { + for (const s of secrets) { + const secret = await this.fetchSecret(s.providerId, s.ref); + } + } +} diff --git a/src/lib/secretsManager/types.ts b/src/lib/secretsManager/types.ts new file mode 100644 index 00000000..de87cae7 --- /dev/null +++ b/src/lib/secretsManager/types.ts @@ -0,0 +1,40 @@ +export enum SecretProviderType { + AWS_SECRETS_MANAGER = "aws", +} + +export interface AWSSecretsManagerConfig { + accessKeyId: string; + secretAccessKey: string; + region: string; + sessionToken?: string; +} + +export type ProviderSpecificConfig = AWSSecretsManagerConfig; // | HashicorpVaultConfig | OtherProviderConfig; + +export interface SecretProviderConfig { + id: string; + type: SecretProviderType; + name: string; + createdAt: number; + updatedAt: number; + config: ProviderSpecificConfig; +} + +export type AwsSecretReference = { + type: SecretProviderType.AWS_SECRETS_MANAGER; + nameOrArn: string; + version?: string; +}; + +export type SecretReference = AwsSecretReference; // | VaultSecretReference; // | OtherProviderSecretReference; + +export interface CachedSecret { + id: string; // Unique identifier + identifier: string; // Secret identifier (name, ARN, or path) + value: string; // The actual secret value + providerId: string; + providerType: SecretProviderType; + fetchedAt: number; + expiresAt: number; + version?: string; +}