import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import type * as interfaces from '../../ts_interfaces/index.ts'; import type { StorageManager } from '../storage/index.ts'; import type { ConnectionManager } from './connectionmanager.ts'; const MANAGED_SECRETS_PREFIX = '/managed-secrets/'; const KEYCHAIN_PREFIX = 'keychain:'; const KEYCHAIN_ID_PREFIX = 'gitops-msecret-'; const SECRET_KEY_PREFIX = 'GITOPS_'; export class ManagedSecretsManager { private secrets: interfaces.data.IManagedSecretStored[] = []; constructor( private storageManager: StorageManager, private smartSecret: plugins.smartsecret.SmartSecret, private connectionManager: ConnectionManager, ) {} async init(): Promise { await this.loadSecrets(); } // ---- Storage helpers ---- private keychainId(secretId: string): string { return `${KEYCHAIN_ID_PREFIX}${secretId}`; } private prefixedKey(key: string): string { return `${SECRET_KEY_PREFIX}${key}`; } private async loadSecrets(): Promise { const keys = await this.storageManager.list(MANAGED_SECRETS_PREFIX); this.secrets = []; for (const key of keys) { const stored = await this.storageManager.getJSON(key); if (stored) { this.secrets.push(stored); } } if (this.secrets.length > 0) { logger.info(`Loaded ${this.secrets.length} managed secret(s)`); } } private async persistSecret(stored: interfaces.data.IManagedSecretStored, realValue: string): Promise { // Store real value in keychain await this.smartSecret.setSecret(this.keychainId(stored.id), realValue); // Save JSON with sentinel const jsonStored = { ...stored, value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}` }; await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, jsonStored); // Update in-memory sentinel too stored.value = jsonStored.value; } private async removeFromStorage(id: string): Promise { await this.smartSecret.deleteSecret(this.keychainId(id)); await this.storageManager.delete(`${MANAGED_SECRETS_PREFIX}${id}.json`); } private async getSecretValue(id: string): Promise { return await this.smartSecret.getSecret(this.keychainId(id)); } private toApiModel(stored: interfaces.data.IManagedSecretStored): interfaces.data.IManagedSecret { return { id: stored.id, key: stored.key, description: stored.description, targets: stored.targets, targetStatuses: stored.targetStatuses, createdAt: stored.createdAt, updatedAt: stored.updatedAt, lastPushedAt: stored.lastPushedAt, }; } // ---- Push logic ---- private async pushToTargets( stored: interfaces.data.IManagedSecretStored, mode: 'upsert' | 'delete', targetsOverride?: interfaces.data.IManagedSecretTarget[], ): Promise { const targets = targetsOverride || stored.targets; const value = mode === 'upsert' ? await this.getSecretValue(stored.id) : null; const prefixedKey = this.prefixedKey(stored.key); const results: interfaces.data.IManagedSecretTargetStatus[] = []; for (const target of targets) { const status: interfaces.data.IManagedSecretTargetStatus = { connectionId: target.connectionId, scope: target.scope, scopeId: target.scopeId, scopeName: target.scopeName, status: 'pending', }; try { const provider = this.connectionManager.getProvider(target.connectionId); if (mode === 'upsert') { // Try update first; if it fails, create try { if (target.scope === 'project') { await provider.updateProjectSecret(target.scopeId, prefixedKey, value!); } else { await provider.updateGroupSecret(target.scopeId, prefixedKey, value!); } } catch { // Secret doesn't exist yet — create it if (target.scope === 'project') { await provider.createProjectSecret(target.scopeId, prefixedKey, value!); } else { await provider.createGroupSecret(target.scopeId, prefixedKey, value!); } } } else { // mode === 'delete' try { if (target.scope === 'project') { await provider.deleteProjectSecret(target.scopeId, prefixedKey); } else { await provider.deleteGroupSecret(target.scopeId, prefixedKey); } } catch { // Secret may not exist on target — that's fine } } status.status = 'success'; status.lastPushedAt = Date.now(); } catch (err) { status.status = 'error'; status.error = err instanceof Error ? err.message : String(err); } results.push(status); } return results; } // ---- Public API ---- async getAll(): Promise { return this.secrets.map((s) => this.toApiModel(s)); } async getById(id: string): Promise { const stored = this.secrets.find((s) => s.id === id); return stored ? this.toApiModel(stored) : null; } async create( key: string, value: string, description: string | undefined, targets: interfaces.data.IManagedSecretTarget[], ): Promise<{ managedSecret: interfaces.data.IManagedSecret; pushResults: interfaces.data.IManagedSecretTargetStatus[]; }> { // Validate key if (key.toUpperCase().startsWith(SECRET_KEY_PREFIX)) { throw new Error(`Key must not start with ${SECRET_KEY_PREFIX} — the prefix is added automatically`); } if (this.secrets.some((s) => s.key === key)) { throw new Error(`A managed secret with key "${key}" already exists`); } const now = Date.now(); const stored: interfaces.data.IManagedSecretStored = { id: crypto.randomUUID(), key, description, value: '', // will be set by persistSecret targets, targetStatuses: [], createdAt: now, updatedAt: now, }; this.secrets.push(stored); await this.persistSecret(stored, value); // Push to all targets const pushResults = await this.pushToTargets(stored, 'upsert'); stored.targetStatuses = pushResults; stored.lastPushedAt = now; stored.updatedAt = now; await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, { ...stored, value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`, }); logger.info(`Created managed secret "${key}" with ${targets.length} target(s)`); return { managedSecret: this.toApiModel(stored), pushResults }; } async update( id: string, updates: { value?: string; description?: string; targets?: interfaces.data.IManagedSecretTarget[]; }, ): Promise<{ managedSecret: interfaces.data.IManagedSecret; pushResults: interfaces.data.IManagedSecretTargetStatus[]; }> { const stored = this.secrets.find((s) => s.id === id); if (!stored) throw new Error(`Managed secret not found: ${id}`); const now = Date.now(); // Update value in keychain if provided if (updates.value !== undefined) { await this.smartSecret.setSecret(this.keychainId(id), updates.value); } if (updates.description !== undefined) { stored.description = updates.description; } // Handle target changes — delete from removed targets let removedTargets: interfaces.data.IManagedSecretTarget[] = []; if (updates.targets !== undefined) { const oldTargets = stored.targets; const newTargetKeys = new Set( updates.targets.map((t) => `${t.connectionId}:${t.scope}:${t.scopeId}`), ); removedTargets = oldTargets.filter( (t) => !newTargetKeys.has(`${t.connectionId}:${t.scope}:${t.scopeId}`), ); stored.targets = updates.targets; } stored.updatedAt = now; // Delete from removed targets if (removedTargets.length > 0) { await this.pushToTargets(stored, 'delete', removedTargets); } // Push to current targets const pushResults = await this.pushToTargets(stored, 'upsert'); stored.targetStatuses = pushResults; stored.lastPushedAt = now; await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, { ...stored, value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`, }); logger.info(`Updated managed secret "${stored.key}"`); return { managedSecret: this.toApiModel(stored), pushResults }; } async delete(id: string): Promise<{ ok: boolean; deleteResults: interfaces.data.IManagedSecretTargetStatus[]; }> { const stored = this.secrets.find((s) => s.id === id); if (!stored) throw new Error(`Managed secret not found: ${id}`); // Best-effort: remove from all targets const deleteResults = await this.pushToTargets(stored, 'delete'); // Remove from local storage regardless const idx = this.secrets.indexOf(stored); this.secrets.splice(idx, 1); await this.removeFromStorage(id); logger.info(`Deleted managed secret "${stored.key}"`); return { ok: true, deleteResults }; } async pushOne(id: string): Promise<{ managedSecret: interfaces.data.IManagedSecret; pushResults: interfaces.data.IManagedSecretTargetStatus[]; }> { const stored = this.secrets.find((s) => s.id === id); if (!stored) throw new Error(`Managed secret not found: ${id}`); const now = Date.now(); const pushResults = await this.pushToTargets(stored, 'upsert'); stored.targetStatuses = pushResults; stored.lastPushedAt = now; stored.updatedAt = now; await this.storageManager.setJSON(`${MANAGED_SECRETS_PREFIX}${stored.id}.json`, { ...stored, value: `${KEYCHAIN_PREFIX}${this.keychainId(stored.id)}`, }); return { managedSecret: this.toApiModel(stored), pushResults }; } async pushAll(): Promise< Array<{ managedSecretId: string; key: string; pushResults: interfaces.data.IManagedSecretTargetStatus[]; }> > { const results: Array<{ managedSecretId: string; key: string; pushResults: interfaces.data.IManagedSecretTargetStatus[]; }> = []; for (const stored of this.secrets) { const { pushResults } = await this.pushOne(stored.id); results.push({ managedSecretId: stored.id, key: stored.key, pushResults, }); } return results; } }