n8n/packages/cli/src/external-secrets/external-secrets-manager.ee.ts
Iván Ovejero 3a9c65e1cb
Some checks are pending
Test Master / install-and-build (push) Waiting to run
Test Master / Unit tests (18.x) (push) Blocked by required conditions
Test Master / Unit tests (20.x) (push) Blocked by required conditions
Test Master / Unit tests (22.4) (push) Blocked by required conditions
Test Master / Lint (push) Blocked by required conditions
Test Master / Notify Slack on failure (push) Blocked by required conditions
Benchmark Docker Image CI / build (push) Waiting to run
refactor(core): Modernize logger service (#11031)
2024-10-01 12:16:09 +02:00

390 lines
11 KiB
TypeScript

import { Cipher } from 'n8n-core';
import { jsonParse, type IDataObject, ApplicationError } from 'n8n-workflow';
import Container, { Service } from 'typedi';
import { SettingsRepository } from '@/databases/repositories/settings.repository';
import { EventService } from '@/events/event.service';
import type {
ExternalSecretsSettings,
SecretsProvider,
SecretsProviderSettings,
} from '@/interfaces';
import { License } from '@/license';
import { Logger } from '@/logging/logger.service';
import { OrchestrationService } from '@/services/orchestration.service';
import { EXTERNAL_SECRETS_INITIAL_BACKOFF, EXTERNAL_SECRETS_MAX_BACKOFF } from './constants';
import { updateIntervalTime } from './external-secrets-helper.ee';
import { ExternalSecretsProviders } from './external-secrets-providers.ee';
@Service()
export class ExternalSecretsManager {
private providers: Record<string, SecretsProvider> = {};
private initializingPromise?: Promise<void>;
private cachedSettings: ExternalSecretsSettings = {};
initialized = false;
updateInterval: NodeJS.Timer;
initRetryTimeouts: Record<string, NodeJS.Timer> = {};
constructor(
private readonly logger: Logger,
private readonly settingsRepo: SettingsRepository,
private readonly license: License,
private readonly secretsProviders: ExternalSecretsProviders,
private readonly cipher: Cipher,
private readonly eventService: EventService,
) {}
async init(): Promise<void> {
if (!this.initialized) {
if (!this.initializingPromise) {
this.initializingPromise = new Promise<void>(async (resolve) => {
await this.internalInit();
this.initialized = true;
resolve();
this.initializingPromise = undefined;
this.updateInterval = setInterval(
async () => await this.updateSecrets(),
updateIntervalTime(),
);
});
}
return await this.initializingPromise;
}
}
shutdown() {
clearInterval(this.updateInterval);
Object.values(this.providers).forEach((p) => {
// Disregard any errors as we're shutting down anyway
void p.disconnect().catch(() => {});
});
Object.values(this.initRetryTimeouts).forEach((v) => clearTimeout(v));
}
async reloadAllProviders(backoff?: number) {
this.logger.debug('Reloading all external secrets providers');
const providers = this.getProviderNames();
if (!providers) {
return;
}
for (const provider of providers) {
await this.reloadProvider(provider, backoff);
}
}
async broadcastReloadExternalSecretsProviders() {
await Container.get(OrchestrationService).publish('reload-external-secrets-providers');
}
private decryptSecretsSettings(value: string): ExternalSecretsSettings {
const decryptedData = this.cipher.decrypt(value);
try {
return jsonParse(decryptedData);
} catch (e) {
throw new ApplicationError(
'External Secrets Settings could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.',
);
}
}
private async getDecryptedSettings(
settingsRepo: SettingsRepository,
): Promise<ExternalSecretsSettings | null> {
const encryptedSettings = await settingsRepo.getEncryptedSecretsProviderSettings();
if (encryptedSettings === null) {
return null;
}
return this.decryptSecretsSettings(encryptedSettings);
}
private async internalInit() {
const settings = await this.getDecryptedSettings(this.settingsRepo);
if (!settings) {
return;
}
const providers: Array<SecretsProvider | null> = (
await Promise.allSettled(
Object.entries(settings).map(
async ([name, providerSettings]) => await this.initProvider(name, providerSettings),
),
)
).map((i) => (i.status === 'rejected' ? null : i.value));
this.providers = Object.fromEntries(
providers.filter((p): p is SecretsProvider => p !== null).map((s) => [s.name, s]),
);
this.cachedSettings = settings;
await this.updateSecrets();
}
private async initProvider(
name: string,
providerSettings: SecretsProviderSettings,
currentBackoff = EXTERNAL_SECRETS_INITIAL_BACKOFF,
) {
const providerClass = this.secretsProviders.getProvider(name);
if (!providerClass) {
return null;
}
const provider: SecretsProvider = new providerClass();
try {
await provider.init(providerSettings);
} catch (e) {
this.logger.error(
`Error initializing secrets provider ${provider.displayName} (${provider.name}).`,
);
this.retryInitWithBackoff(name, currentBackoff);
return provider;
}
try {
if (providerSettings.connected) {
await provider.connect();
}
} catch (e) {
try {
await provider.disconnect();
} catch {}
this.logger.error(
`Error initializing secrets provider ${provider.displayName} (${provider.name}).`,
);
this.retryInitWithBackoff(name, currentBackoff);
return provider;
}
return provider;
}
private retryInitWithBackoff(name: string, currentBackoff: number) {
if (name in this.initRetryTimeouts) {
clearTimeout(this.initRetryTimeouts[name]);
delete this.initRetryTimeouts[name];
}
this.initRetryTimeouts[name] = setTimeout(() => {
delete this.initRetryTimeouts[name];
if (this.providers[name] && this.providers[name].state !== 'error') {
return;
}
void this.reloadProvider(name, Math.min(currentBackoff * 2, EXTERNAL_SECRETS_MAX_BACKOFF));
}, currentBackoff);
}
async updateSecrets() {
if (!this.license.isExternalSecretsEnabled()) {
return;
}
await Promise.allSettled(
Object.entries(this.providers).map(async ([k, p]) => {
try {
if (this.cachedSettings[k].connected && p.state === 'connected') {
await p.update();
}
} catch {
this.logger.error(`Error updating secrets provider ${p.displayName} (${p.name}).`);
}
}),
);
}
getProvider(provider: string): SecretsProvider | undefined {
return this.providers[provider];
}
hasProvider(provider: string): boolean {
return provider in this.providers;
}
getProviderNames(): string[] | undefined {
return Object.keys(this.providers);
}
getSecret(provider: string, name: string) {
return this.getProvider(provider)?.getSecret(name);
}
hasSecret(provider: string, name: string): boolean {
return this.getProvider(provider)?.hasSecret(name) ?? false;
}
getSecretNames(provider: string): string[] | undefined {
return this.getProvider(provider)?.getSecretNames();
}
getAllSecretNames(): Record<string, string[]> {
return Object.fromEntries(
Object.keys(this.providers).map((provider) => [
provider,
this.getSecretNames(provider) ?? [],
]),
);
}
getProvidersWithSettings(): Array<{
provider: SecretsProvider;
settings: SecretsProviderSettings;
}> {
return Object.entries(this.secretsProviders.getAllProviders()).map(([k, c]) => ({
provider: this.getProvider(k) ?? new c(),
settings: this.cachedSettings[k] ?? {},
}));
}
getProviderWithSettings(provider: string):
| {
provider: SecretsProvider;
settings: SecretsProviderSettings;
}
| undefined {
const providerConstructor = this.secretsProviders.getProvider(provider);
if (!providerConstructor) {
return undefined;
}
return {
provider: this.getProvider(provider) ?? new providerConstructor(),
settings: this.cachedSettings[provider] ?? {},
};
}
async reloadProvider(provider: string, backoff = EXTERNAL_SECRETS_INITIAL_BACKOFF) {
if (provider in this.providers) {
await this.providers[provider].disconnect();
delete this.providers[provider];
}
const newProvider = await this.initProvider(provider, this.cachedSettings[provider], backoff);
if (newProvider) {
this.providers[provider] = newProvider;
}
}
async setProviderSettings(provider: string, data: IDataObject, userId?: string) {
let isNewProvider = false;
let settings = await this.getDecryptedSettings(this.settingsRepo);
if (!settings) {
settings = {};
}
if (!(provider in settings)) {
isNewProvider = true;
}
settings[provider] = {
connected: settings[provider]?.connected ?? false,
connectedAt: settings[provider]?.connectedAt ?? new Date(),
settings: data,
};
await this.saveAndSetSettings(settings, this.settingsRepo);
this.cachedSettings = settings;
await this.reloadProvider(provider);
await this.broadcastReloadExternalSecretsProviders();
void this.trackProviderSave(provider, isNewProvider, userId);
}
async setProviderConnected(provider: string, connected: boolean) {
let settings = await this.getDecryptedSettings(this.settingsRepo);
if (!settings) {
settings = {};
}
settings[provider] = {
connected,
connectedAt: connected ? new Date() : (settings[provider]?.connectedAt ?? null),
settings: settings[provider]?.settings ?? {},
};
await this.saveAndSetSettings(settings, this.settingsRepo);
this.cachedSettings = settings;
await this.reloadProvider(provider);
await this.updateSecrets();
await this.broadcastReloadExternalSecretsProviders();
}
private async trackProviderSave(vaultType: string, isNew: boolean, userId?: string) {
let testResult: [boolean] | [boolean, string] | undefined;
try {
testResult = await this.getProvider(vaultType)?.test();
} catch {}
this.eventService.emit('external-secrets-provider-settings-saved', {
userId,
vaultType,
isNew,
isValid: testResult?.[0] ?? false,
errorMessage: testResult?.[1],
});
}
private encryptSecretsSettings(settings: ExternalSecretsSettings): string {
return this.cipher.encrypt(settings);
}
async saveAndSetSettings(settings: ExternalSecretsSettings, settingsRepo: SettingsRepository) {
const encryptedSettings = this.encryptSecretsSettings(settings);
await settingsRepo.saveEncryptedSecretsProviderSettings(encryptedSettings);
}
async testProviderSettings(
provider: string,
data: IDataObject,
): Promise<{
success: boolean;
testState: 'connected' | 'tested' | 'error';
error?: string;
}> {
let testProvider: SecretsProvider | null = null;
try {
testProvider = await this.initProvider(provider, {
connected: true,
connectedAt: new Date(),
settings: data,
});
if (!testProvider) {
return {
success: false,
testState: 'error',
};
}
const [success, error] = await testProvider.test();
let testState: 'connected' | 'tested' | 'error' = 'error';
if (success && this.cachedSettings[provider]?.connected) {
testState = 'connected';
} else if (success) {
testState = 'tested';
}
return {
success,
testState,
error,
};
} catch {
return {
success: false,
testState: 'error',
};
} finally {
if (testProvider) {
await testProvider.disconnect();
}
}
}
async updateProvider(provider: string): Promise<boolean> {
if (!this.license.isExternalSecretsEnabled()) {
return false;
}
if (!this.providers[provider] || this.providers[provider].state !== 'connected') {
return false;
}
try {
await this.providers[provider].update();
await this.broadcastReloadExternalSecretsProviders();
return true;
} catch {
return false;
}
}
}