UNPKG

@rytass/secret-adapter-vault

Version:

Rytass Secret Vault adapter

283 lines (280 loc) 10.4 kB
import { SecretManager } from '@rytass/secret'; import axios from 'axios'; import { EventEmitter } from 'events'; import { VaultSecretState, VaultEvents } from './typings.js'; class VaultSecret extends SecretManager { _host; _auth; _tokenTTL = 2764724; _sessionInterval; _state = VaultSecretState.INIT; emitter = new EventEmitter(); _tokenExpiredOn; _token; _online = false; _cacheData; _cacheVersion; constructor(path, options){ super(path); this._host = options.host; this._auth = options.auth; this._tokenTTL = options.tokenTTL || this._tokenTTL; this._online = options.online || this._online; this.retrieveToken()?.then(async ()=>{ if (!this._online) { [this._cacheData, this._cacheVersion] = await this.getSecretVersionOnline(); } this._state = VaultSecretState.READY; this.emitter.emit(VaultEvents.READY); }).catch((ex)=>{ this.emitter.emit(VaultEvents.ERROR, ex); }); if (typeof options.onError === 'function') { this.emitter.on(VaultEvents.ERROR, options.onError); } if (typeof options.onReady === 'function') { this.emitter.on(VaultEvents.READY, options.onReady); } if (this._online) { this._sessionInterval = setInterval(()=>this.checkRenew(), this._tokenTTL); } } checkRenew() { if (this._token) this.renewToken(); } retrieveToken() { if (this._auth.account) { return this.retrieveTokenByUserPass(this._auth.account, this._auth.password); } } async retrieveTokenByUserPass(account, password) { const { data } = await axios.post(`${this._host}/v1/auth/userpass/login/${account}`, JSON.stringify({ password }), { headers: { 'Content-Type': 'application/json' } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); this.terminate(); return; } this._token = data.auth.client_token; this._tokenExpiredOn = Date.now() + Math.max(data.auth.lease_duration - 300, 0); // Calculate safety expires time } async renewToken() { if (this._tokenExpiredOn < Date.now()) { return this.retrieveToken(); } const { data } = await axios.post(`${this._host}/v1/auth/token/renew-self`, null, { headers: { 'X-Vault-Token': this._token } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); this.terminate(); return; } this._token = data.auth.client_token; this._tokenExpiredOn = Date.now() + data.auth.lease_duration - 300; // Calculate safety expires time this.emitter.emit(VaultEvents.TOKEN_RENEWED); } async getSecretVersionOnline() { try { const { data } = await axios.get(`${this._host}/v1/secret/data/${this.project}`, { headers: { 'X-Vault-Token': this._token } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); throw new Error(data?.errors.join(', ')); } return [ data.data.data, data.data.metadata.version ]; } catch (ex) { if (axios.isAxiosError(ex)) { const { response } = ex; switch(response?.status){ case 404: return [ {}, 0 ]; case 403: default: this.emitter.emit(VaultEvents.ERROR, ex); throw ex; } } throw ex; } } getSecretValue(key) { if (!this._online) { return this._cacheData[key]; } return this.getSecretVersionOnline().then(([currentVersion])=>currentVersion[key] || undefined); } async fullReplaceSecretValue(newData) { await this.renewToken(); const { data } = await axios.post(`${this._host}/v1/secret/data/${this.project}`, JSON.stringify({ data: newData }), { headers: { 'X-Vault-Token': this._token } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); throw new Error(data?.errors.join(', ')); } } async setSecretValueOnline(key, value) { const [currentValue, currentVersion] = await this.getSecretVersionOnline(); const { data } = await axios.post(`${this._host}/v1/secret/data/${this.project}`, JSON.stringify({ data: { ...currentValue, [key]: value }, options: { cas: currentVersion } }), { headers: { 'X-Vault-Token': this._token } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); throw new Error(data?.errors.join(', ')); } } async removeSecretKeyOnline(key) { const [currentValue, currentVersion] = await this.getSecretVersionOnline(); if (!currentValue[key]) throw new Error('Secret key not found'); const { data } = await axios.post(`${this._host}/v1/secret/data/${this.project}`, JSON.stringify({ data: { ...Object.entries(currentValue).reduce((vars, [secretKey, secret])=>{ if (secretKey === key) return vars; return { ...vars, [secretKey]: secret }; }, {}) }, options: { cas: currentVersion } }), { headers: { 'X-Vault-Token': this._token } }); if (Array.isArray(data?.errors)) { data?.errors.forEach((error)=>{ this.emitter.emit(VaultEvents.ERROR, error); }); throw new Error(data?.errors.join(', ')); } } terminate() { this._state = VaultSecretState.TERMINATED; this._tokenExpiredOn = Date.now(); if (this._sessionInterval) { clearInterval(this._sessionInterval); } this.emitter.emit(VaultEvents.TERMINATED); } get state() { return this._state; } get(key) { if (!this._online) { if (this._state === VaultSecretState.INIT) { throw new Error('Cache data not ready'); } return this.getSecretValue(key); } if (this._token && this._tokenExpiredOn && this._tokenExpiredOn >= Date.now()) { return this.getSecretValue(key); } return new Promise((resolve)=>{ const onTokenRetrieved = ()=>{ this.emitter.removeListener(VaultEvents.READY, onTokenRetrieved); this.emitter.removeListener(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); resolve(this.getSecretValue(key)); }; this.emitter.on(VaultEvents.READY, onTokenRetrieved); this.emitter.on(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); }); } set(key, value) { if (!this._online) { if (this._state === VaultSecretState.INIT) { throw new Error('Cache data not ready'); } this._cacheData[key] = value; return Promise.resolve(); } if (this._token && this._tokenExpiredOn && this._tokenExpiredOn >= Date.now()) { return this.setSecretValueOnline(key, value); } return new Promise((resolve)=>{ const onTokenRetrieved = async ()=>{ this.emitter.removeListener(VaultEvents.READY, onTokenRetrieved); this.emitter.removeListener(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); resolve(this.setSecretValueOnline(key, value)); }; this.emitter.on(VaultEvents.READY, onTokenRetrieved); this.emitter.on(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); }); } delete(key) { if (!this._online) { if (this._state === VaultSecretState.INIT) { throw new Error('Cache data not ready'); } delete this._cacheData[key]; return Promise.resolve(); } if (this._token && this._tokenExpiredOn && this._tokenExpiredOn >= Date.now()) { return this.removeSecretKeyOnline(key); } return new Promise((resolve)=>{ const onTokenRetrieved = async ()=>{ this.emitter.removeListener(VaultEvents.READY, onTokenRetrieved); this.emitter.removeListener(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); resolve(this.removeSecretKeyOnline(key)); }; this.emitter.on(VaultEvents.READY, onTokenRetrieved); this.emitter.on(VaultEvents.TOKEN_RENEWED, onTokenRetrieved); }); } async sync(force = false) { if (this._online) { throw new Error('This feature only works for offline mode'); } const [data, version] = await this.getSecretVersionOnline(); if (version !== this._cacheVersion && !force) { throw new Error('Online version is not match cached version, please use force mode instead'); } await this.fullReplaceSecretValue(this._cacheData); [this._cacheData, this._cacheVersion] = await this.getSecretVersionOnline(); } } export { VaultSecret };