diff --git a/src/lib/secretsManager/cacheService.ts b/src/lib/secretsManager/cacheService.ts new file mode 100644 index 00000000..e0b47ffb --- /dev/null +++ b/src/lib/secretsManager/cacheService.ts @@ -0,0 +1,79 @@ +import { ICacheStorage } from "./cacheStorage/ICacheStorage"; +import { CachedSecret, SecretProviderType } from "./types"; + +// Function +// 1. get/set/invalidate cached secrets +// 2. cache storage is pluggable (in-memory, file-based, redis, etc.) + +export class SecretsCacheService { + private storage: ICacheStorage; + + private defaultTTL: number; + + constructor(storage: ICacheStorage) { + this.storage = storage; + } + + async get( + providerId: string, + identifier: string, + version?: string + ): Promise { + const secret = await this.storage.findByIdentifier( + providerId, + identifier, + version + ); + return secret?.value ?? null; + } + + async set( + providerId: string, + providerType: SecretProviderType, + identifier: string, + value: string, + version?: string, + ttl?: number + ): Promise { + const now = Date.now(); + const secret: CachedSecret = { + id: this.generateId(), + identifier, + value, + providerId, + providerType, + fetchedAt: now, + expiresAt: now + (ttl ?? this.defaultTTL), + version, + }; + await this.storage.set(secret); + } + + async invalidate( + providerId: string, + key: string, + version?: string + ): Promise { + const secret = await this.storage.findByIdentifier( + providerId, + key, + version + ); + if (secret) { + await this.storage.delete(secret.id); + } + } + + async invalidateByProvider(providerId: string): Promise { + await this.storage.deleteByProvider(providerId); + } + + async clear(): Promise { + await this.storage.clear(); + } + + // eslint-disable-next-line class-methods-use-this + private generateId(): string { + return uuidv4(); + } +} diff --git a/src/lib/secretsManager/cacheStorage/ICacheStorage.ts b/src/lib/secretsManager/cacheStorage/ICacheStorage.ts new file mode 100644 index 00000000..3891dd7c --- /dev/null +++ b/src/lib/secretsManager/cacheStorage/ICacheStorage.ts @@ -0,0 +1,56 @@ +import { CachedSecret } from "../types"; +import { ISecretsStorage } from "../encryptedStorage/IEncryptedStorage"; + +// export interface ICacheStorage { +/** + * Get a cached secret by its unique ID + */ +// get(id: string): Promise; + +// /** +// * Store a cached secret +// */ +// set(secret: CachedSecret): Promise; + +// /** +// * Delete a cached secret by its ID +// */ +// delete(id: string): Promise; + +// /** +// * Find a cached secret by provider ID, key, and optional version +// */ +// findByIdentifier( +// providerId: string, +// identifier: string, +// version?: string +// ): Promise; + +// /** +// * Delete all cached secrets for a specific provider +// */ +// deleteByProvider(providerId: string): Promise; + +// /** +// * Clear all cached secrets +// */ +// clear(): Promise; +// } + +export abstract class AbstractCacheStorage implements ISecretsStorage { + abstract initialize(): Promise; + + abstract save(key: string, data: CachedSecret): Promise; + + abstract load(key: string): Promise; + + abstract delete(key: string): Promise; + + abstract findByIdentifier( + providerId: string, + identifier: string, + version?: string + ): Promise; + + abstract clear(): Promise; +} diff --git a/src/lib/secretsManager/cacheStorage/InMemoryCacheStorage.ts b/src/lib/secretsManager/cacheStorage/InMemoryCacheStorage.ts new file mode 100644 index 00000000..04fd97aa --- /dev/null +++ b/src/lib/secretsManager/cacheStorage/InMemoryCacheStorage.ts @@ -0,0 +1,66 @@ +import { CachedSecret } from "../types"; +import { AbstractCacheStorage } from "./ICacheStorage"; + +export class InMemoryCacheStorage implements AbstractCacheStorage { + private cache: Map = new Map(); + + async load(key: string): Promise { + const secret = this.cache.get(key); + if (!secret) { + return null; + } + + if (Date.now() > secret.expiresAt) { + this.cache.delete(key); + return null; + } + return secret; + } + + async save(key: string, data: CachedSecret): Promise { + this.cache.set(key, data); + } + + async delete(id: string): Promise { + this.cache.delete(id); + } + + async findByIdentifier( + key: string, + identifier: string, + version?: string // Store multiple versions of the same secret or not? + ): Promise { + const now = Date.now(); + let found: CachedSecret | null = null; + + for (const secret of this.cache.values()) { + // Skip expired entries + if (now > secret.expiresAt) { + continue; + } + + if ( + secret.providerId === key && + secret.identifier === identifier && + secret.version === version + ) { + found = secret; + break; + } + } + + return found; + } + + async deleteByProvider(providerId: string): Promise { + for (const [id, secret] of this.cache.entries()) { + if (secret.providerId === providerId) { + this.cache.delete(id); + } + } + } + + async clear(): Promise { + this.cache.clear(); + } +} diff --git a/src/lib/secretsManager/encryptedStorage/IEncryptedStorage.ts b/src/lib/secretsManager/encryptedStorage/IEncryptedStorage.ts new file mode 100644 index 00000000..453ab7c5 --- /dev/null +++ b/src/lib/secretsManager/encryptedStorage/IEncryptedStorage.ts @@ -0,0 +1,27 @@ +// Can this be storage and not encrypted storage? +// export interface IEncryptedStorage { +// initialize(): Promise; +// save>(key: string, data: T): Promise; +// load>(key: string): Promise; +// delete(key: string): Promise; +// } + +export interface ISecretsStorage { + initialize(): Promise; + save>(key: string, data: T): Promise; + load>(key: string): Promise; + delete(key: string): Promise; +} + +export abstract class AbstractEncryptedStorage implements ISecretsStorage { + 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/FsProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/FsProviderRegistry.ts new file mode 100644 index 00000000..eafd6550 --- /dev/null +++ b/src/lib/secretsManager/providerRegistry/FsProviderRegistry.ts @@ -0,0 +1,88 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { + AbstractProviderRegistry, +} from "./IProviderRegistry"; +import { SecretProviderConfig, SecretProviderType } from "../types"; +import { IEncryptedStorage } from "../encryptedStorage/IEncryptedStorage"; + +const MANIFEST_FILENAME = "providers.json"; + +export interface ProvidersManifest { + version: string; + providers: { + id: string; + storagePath: string; + type: SecretProviderType; + }[]; +} + +// 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; + + constructor(encryptedStorage: IEncryptedStorage, configDir: string) { + super(encryptedStorage); + this.configDir = configDir; + this.manifestPath = path.join(configDir, MANIFEST_FILENAME); + } + + async initialize(): Promise { + await this.ensureConfigDir(); + } + + async listProviders(): Promise { + const manifest = await this.loadManifest(); + return manifest.providers.map((p) => p.id); + } + + async loadAllProviderConfigs(): Promise {} + + async saveProviderConfig(config: SecretProviderConfig): Promise { + const manifest = await this.loadManifest(); + const storageKey = config.id; + + await this.encryptedStorage.save(storageKey, config); + + // Update manifest + + await this.saveManifest(manifest); + } + + 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); + } + + 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); + } + } + + private async loadManifest(): Promise {} + + private async saveManifest(manifest: ProvidersManifest): Promise {} +} diff --git a/src/lib/secretsManager/providerRegistry/IProviderRegistry.ts b/src/lib/secretsManager/providerRegistry/IProviderRegistry.ts new file mode 100644 index 00000000..16d1a703 --- /dev/null +++ b/src/lib/secretsManager/providerRegistry/IProviderRegistry.ts @@ -0,0 +1,32 @@ +// filepath: /Users/nafeesn/requestly/requestly-desktop-app/src/lib/secretsManager/providerRegistry/IProviderRegistry.ts +import { SecretProviderConfig } from "../types"; +import { IEncryptedStorage } from "../encryptedStorage/IEncryptedStorage"; + +export interface IProviderRegistry { + initialize(): Promise; + listProviders(): Promise; + loadAllProviderConfigs(): Promise; + saveProviderConfig(config: SecretProviderConfig): Promise; + deleteProviderConfig(id: string): Promise; + getProviderConfig(id: string): Promise; +} + +export abstract class AbstractProviderRegistry implements IProviderRegistry { + protected encryptedStorage: IEncryptedStorage + + constructor(encryptedStorage: IEncryptedStorage) { + this.encryptedStorage = encryptedStorage; + } + + abstract initialize(): Promise; + + abstract listProviders(): Promise; + + abstract loadAllProviderConfigs(): Promise; + + abstract saveProviderConfig(config: SecretProviderConfig): Promise; + + abstract deleteProviderConfig(id: string): Promise; + + abstract getProviderConfig(id: string): Promise; +} diff --git a/src/lib/secretsManager/providerService/ISecretProvider.ts b/src/lib/secretsManager/providerService/ISecretProvider.ts new file mode 100644 index 00000000..f0605708 --- /dev/null +++ b/src/lib/secretsManager/providerService/ISecretProvider.ts @@ -0,0 +1,29 @@ +import { SecretProviderType, SecretReference } from "../types"; + +// evaluate if its bettter to have abstract class than interface +interface ISecretProvider { + type: SecretProviderType; + id: string; + + testConnection(): Promise; + fetchSecret(ref: SecretReference): Promise; + listSecrets(): Promise; +} + +export abstract class AbstractSecretProvider implements ISecretProvider { + abstract readonly type: SecretProviderType; + + abstract readonly id: string; + + protected config: any; + + abstract testConnection(): Promise; + + abstract fetchSecret(ref: SecretReference): Promise; + + abstract listSecrets(): 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..2a0a3940 --- /dev/null +++ b/src/lib/secretsManager/providerService/awsSecretManagerProvider.ts @@ -0,0 +1,45 @@ +import { AbstractSecretProvider } from "./ISecretProvider"; +import { + AWSSecretsManagerConfig, + SecretProviderConfig, + SecretProviderType, + SecretReference, +} from "../types"; + +// Functions +// 1. validate config +// 2. test connection +// 3. fetch secret +// 4. list secrets + +export class AWSSecretsManagerProvider extends AbstractSecretProvider { + readonly type = SecretProviderType.AWS_SECRETS_MANAGER; + + readonly id: string; + + protected config: AWSSecretsManagerConfig; + + constructor(providerConfig: SecretProviderConfig) { + super(); + this.id = providerConfig.id; + this.config = providerConfig.config as AWSSecretsManagerConfig; + } + + static validateConfig(config: AWSSecretsManagerConfig): boolean { + return Boolean( + config.accessKeyId && config.secretAccessKey && config.region + ); + } + + async testConnection(): Promise { + if (!AWSSecretsManagerProvider.validateConfig(this.config)) { + return false; + } + + return true; + } + + async fetchSecret(ref: SecretReference): Promise {} + + async listSecrets(): Promise {} +} diff --git a/src/lib/secretsManager/providerService/providerFactory.ts b/src/lib/secretsManager/providerService/providerFactory.ts new file mode 100644 index 00000000..70a68c8e --- /dev/null +++ b/src/lib/secretsManager/providerService/providerFactory.ts @@ -0,0 +1,13 @@ +import { SecretProviderConfig, SecretProviderType } from "../types"; +import { ISecretProvider } from "./ISecretProvider"; +import { AWSSecretsManagerProvider } from "./awsSecretManagerProvider"; + +export function createProvider(config: SecretProviderConfig): ISecretProvider { + 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..a5d2008e --- /dev/null +++ b/src/lib/secretsManager/secretsManager.ts @@ -0,0 +1,165 @@ +import { SecretsCacheService } from "./cacheService"; +import { IProviderRegistry } from "./providerRegistry/IProviderRegistry"; +import { createProvider } from "./providerService/providerFactory"; +import { SecretProviderConfig, SecretReference } from "./types"; +import { AbstractSecretProvider } from "./providerService/ISecretProvider"; + +// Questions +// 1. store multiple versions of the same secret or not? +// 2. use secretReference or do it like mp with union types? +// 3. cache invalidation strategy? +// 4. Need a new method for refreshing all the cached secrets +// --5. reuse the storage interface for encrypted storage and cache storage? + +// 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 +// + +export class SecretsManager { + private registry: IProviderRegistry; + + private cacheService: SecretsCacheService; + + private providers: Map = new Map(); + + constructor(registry: IProviderRegistry, cacheService: SecretsCacheService) { + this.registry = registry; + this.cacheService = cacheService; + } + + async initialize(): Promise { + this.registry.initialize(); + this.initProvidersFromManifest(); + } + + private async initProvidersFromManifest() { + const configs = await this.registry.loadAllProviderConfigs(); + configs.forEach((config) => { + this.providers.set(config.id, this.createProviderInstance(config)); + }); + } + + async addProviderConfig(config: SecretProviderConfig) { + this.providers.set(config.id, this.createProviderInstance(config)); + this.registry.saveProviderConfig(config); + } + + async removeProviderConfig(id: string) { + this.providers.delete(id); + this.registry.deleteProviderConfig(id); + } + + async getProviderConfig(id: string): Promise { + return this.registry.getProviderConfig(id); + } + + async testProviderConnection(id: string): Promise { + const provider = this.providers.get(id); + return provider?.testConnection() ?? false; + } + + async fetchSecret(providerId: string, ref: SecretReference): Promise { + const cached = await this.cacheService.get( + providerId, + ref.identifier, + ref.version + ); + + if (cached) { + return cached; + } + + const provider = this.providers.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + + const secret = await provider.fetchSecret(ref); + + await this.cacheService.set( + providerId, + provider.type, + ref.identifier, + secret, + ref.version + ); + + return secret; + } + + 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); + } + } + + // Do we need this method? + async fetchSecretFresh( + providerId: string, + ref: SecretReference + ): Promise { + await this.cacheService.invalidate(providerId, ref.identifier, ref.version); + + const provider = this.providers.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + + const secret = await provider.fetchSecret(ref); + + await this.cacheService.set( + providerId, + provider.type, + ref.identifier, + secret, + ref.version + ); + + return secret; + } + + async listSecrets(providerId: string): Promise { + const provider = this.providers.get(providerId); + if (!provider) { + throw new Error(`Provider not found: ${providerId}`); + } + + return provider.listSecrets(); + } + + async invalidateCache(providerId?: string): Promise { + if (providerId) { + await this.cacheService.invalidateByProvider(providerId); + } else { + await this.cacheService.clear(); + } + } + + // eslint-disable-next-line class-methods-use-this + private createProviderInstance( + config: SecretProviderConfig + ): AbstractSecretProvider { + return createProvider(config); + } +} diff --git a/src/lib/secretsManager/types.ts b/src/lib/secretsManager/types.ts new file mode 100644 index 00000000..1a1a962a --- /dev/null +++ b/src/lib/secretsManager/types.ts @@ -0,0 +1,46 @@ +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 interface SecretReference { + identifier: string; + version?: string; + key?: string; +} + +/** + * + * type SecretReference = + * | { type: "aws"; nameOrArn: string; versionId?: string; versionStage?: string } + * | { type: "vault"; path: string; version?: number; key?: string }; + */ + + +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; +}