// Copyright Abridged, Inc. 2021,2024. All Rights Reserved. // Node module: @collabland/aws // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT import { CreateSecretRequest, SecretsManager, SecretsManagerClientConfig, UpdateSecretRequest, } from '@aws-sdk/client-secrets-manager'; import { AnyError, AnyType, ResourceType, getEnvVar, getResourceName, loggers, } from '@collabland/common'; import fs from 'fs'; import {promisify} from 'util'; import {getAWSRegion, getDefaultConfig} from '../common/types.js'; const {debug, error} = loggers('collabland:aws:secretsmanager'); const writeFile = promisify(fs.writeFile); export class SecretsServiceClient { readonly secretsManager: SecretsManager; constructor(options?: SecretsManagerClientConfig) { this.secretsManager = new SecretsManager({ ...getDefaultConfig(), ...options, }); } /** * Get a secret string by name * @param secretName - Secret name */ async getSecretString(secretName: string) { try { const res = await this.secretsManager.getSecretValue({ SecretId: secretName, }); return res.SecretString ?? ''; } catch (err: AnyError) { error('Fail to load secret %s: %O', secretName, err); if (err.code === 'ResourceNotFoundException') { return undefined; } throw err; } } /** * Get a secret json object by name * @param secretName - Secret name */ async getSecretJson(secretName: string) { const data = await this.getSecretString(secretName); if (data == null) return data; if (data === '') return {}; const json = JSON.parse(data); return json; } /** * Create a secret string * @param secretName - Secret name * @param value - String value * @param description - Description */ async createSecretString( secretName: string, value: string, description = '', ) { const params: CreateSecretRequest = { Name: secretName, Description: description, SecretString: value, }; const res = await this.secretsManager.createSecret(params); return res; } /** * Create a secret json object * @param secretName - Secret name * @param value - JSON object value * @param description - Description */ async createSecretJson(secretName: string, value: object, description = '') { const strVal = JSON.stringify(value); return this.createSecretString(secretName, strVal, description); } /** * Update a secret string by name * @param secretName - Secret name * @param value - String value * @param description - Description */ async updateSecretString( secretName: string, value: string, description = '', ) { const params: UpdateSecretRequest = { SecretId: secretName, Description: description, SecretString: value, }; const res = await this.secretsManager.updateSecret(params); return res; } /** * Update a secret json object by name. It merges with the current value if * exists. * @param secretName - Secret name * @param value - String value * @param description - Description */ async updateSecretJson(secretName: string, value: object, description = '') { const current = await this.getSecretJson(secretName); const data = {...current, ...value}; if (current == null) { return this.createSecretJson(secretName, data, description); } else { const strVal = JSON.stringify(data); return this.updateSecretString(secretName, strVal); } } } export function getSecretName() { return getResourceName(ResourceType.SecretName); } /** * Load secretes from AWS SecretsManager and update the environment variables * * https://us-east-1.console.aws.amazon.com/secretsmanager/home?region=us-east-1#/secret?name=collab-land-api * */ export async function loadSecrets() { const configs: SecretConfig[] = [{secretName: getSecretName()}]; const file = getEnvVar('LOCAL_SECRETS_FILE'); if (file != null) { configs.push({localSecretsFile: file}); } return loadAWSSecrets(...configs); } /** * Secret config */ export type SecretConfig = { /** * Secret name */ secretName?: string; /** * AWS region for the secret manager */ region?: string; /** * An optional local json file to provide secrets */ localSecretsFile?: string; }; /** * Load secrets from AWS SecretsManager/local file and update the environment * variables * @param configs - Secret configs * @returns */ export async function loadAWSSecrets(...configs: SecretConfig[]) { let json: Record = {}; for (const config of configs) { debug('Loading secrets from config: %O', config); const {secretName, localSecretsFile} = config; if (secretName == null && localSecretsFile == null) continue; if (secretName != null) { const region = config.region ?? getAWSRegion(); const secretsService = new SecretsServiceClient({region}); debug('Loading secret %s from region %s...', secretName, region); const secret = await secretsService.getSecretJson(secretName); json = {...json, ...secret}; } else if (localSecretsFile != null) { // Try to load secrets from a local file debug('Loading secrets from a local file: %s', localSecretsFile); const localSecrets = fs.readFileSync(localSecretsFile, 'utf-8'); const localJson = JSON.parse(localSecrets); debug( 'Secrets loaded from %s: %j', localSecretsFile, Object.keys(localJson), ); // Merge local secrets to overwrite remote ones from AWS json = {...json, ...localJson}; } } // Set secrets into `global.collabLandSecrets` globalThis.collabLandSecrets = {}; for (const s in json) { const val = json[s]; globalThis.collabLandSecrets[s] = typeof val === 'string' ? val : JSON.stringify(val); } debug('Secrets loaded %j', Object.keys(json)); return json; } /** * Update secrets * * https://us-east-1.console.aws.amazon.com/secretsmanager/home?region=us-east-1#/secret?name=collab-land-api * * @param data - JSON object for secrets * @param secretName - Secret name * @param region - AWS region */ export async function updateSecret( data: object, secretName = getSecretName(), region = getAWSRegion(), ) { const secretsService = new SecretsServiceClient({region}); return secretsService.updateSecretJson( secretName, data, 'CollabLand API Server Secrets', ); } export async function createSecret( data: object, secretName = getSecretName(), region = getAWSRegion(), description = 'CollabLand Server Secrets', ) { const secretsService = new SecretsServiceClient({region}); return secretsService.createSecretJson(secretName, data, description); } /** * Load the secret value and save it to the file * @param secretName - Secret name * @param region - AWS region * @param file - File path to save the secrets */ export async function loadSecretsToFile( secretName = getSecretName(), region = getAWSRegion(), file?: string, ) { const secretsService = new SecretsServiceClient({region}); const json = (await secretsService.getSecretJson(secretName)) ?? {}; debug('Secrets loaded %j', Object.keys(json)); if (file) { await writeFile(file, JSON.stringify(json, null, 2), 'utf-8'); } return json; }