< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/aws/AwsSsmSecretProvider.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/aws/AwsSsmSecretProvider.ts
Tag: 561_41292037308
Line coverage
98%
Covered lines: 82
Uncovered lines: 1
Coverable lines: 83
Total lines: 250
Line coverage: 98.7%
Branch coverage
93%
Covered branches: 43
Total branches: 46
Branch coverage: 93.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/infrastructure/aws/AwsSsmSecretProvider.ts

#LineLine coverage
 1import {
 2  GetParameterCommand,
 3  GetParametersCommand,
 4  PutParameterCommand,
 5  type SSM,
 6} from '@aws-sdk/client-ssm';
 7import { GetCallerIdentityCommand, type STS } from '@aws-sdk/client-sts';
 8import { injectable } from 'inversify';
 9import pc from 'picocolors';
 10import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 11import {
 12  ExpiredCredentialsError,
 13  SecretOperationError,
 14  SsoSessionExpiredError,
 15} from '../../domain/errors/DomainErrors.js';
 16import type { ILogger } from '../../domain/ports/ILogger.js';
 17import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 18import type { SecretExistence } from '../../domain/SecretExistence.js';
 19import { describeError } from '../describeError.js';
 20import { isExpiredCredentialsError } from './isExpiredCredentialsError.js';
 21import { isSsoSessionExpiredError } from './isSsoSessionExpiredError.js';
 22
 23/** `GetParameters` accepts at most 10 names per call. */
 1224const GET_PARAMETERS_BATCH_SIZE = 10;
 25
 1226const AUTHENTICATION_ERROR_NAMES = new Set([
 27  'CredentialsProviderError',
 28  'UnrecognizedClientException',
 29  'InvalidClientTokenId',
 30  'InvalidSignatureException',
 31  'IncompleteSignatureException',
 32]);
 33
 34/**
 35 * Errors that fail a whole `GetParameters` call because of one of its names,
 36 * so each name is checked on its own to tell which one.
 37 */
 1238const PER_NAME_ERROR_NAMES = new Set([
 39  'AccessDeniedException',
 40  'ValidationException',
 41]);
 42
 1243@injectable()
 1244export class AwsSsmSecretProvider implements ISecretProvider {
 45  private ssm: SSM;
 46  private logger: ILogger;
 47  private sts: STS;
 48  private profile?: string;
 11749  private identityLogged = false;
 50
 51  constructor(ssm: SSM, logger: ILogger, sts: STS, profile?: string) {
 11752    this.ssm = ssm;
 11753    this.logger = logger;
 11754    this.sts = sts;
 11755    this.profile = profile;
 56  }
 57
 58  async getSecret(name: string): Promise<string | undefined> {
 1259    try {
 1260      const command = new GetParameterCommand({
 61        Name: name,
 62        WithDecryption: true,
 63      });
 1264      const { Parameter } = await this.ssm.send(command);
 565      return Parameter?.Value;
 66    } catch (error) {
 767      if (
 68        typeof error === 'object' &&
 69        error !== null &&
 70        'name' in error &&
 71        error.name === 'ParameterNotFound'
 72      ) {
 273        return undefined;
 74      }
 575      if (isSsoSessionExpiredError(error)) {
 176        throw new SsoSessionExpiredError(this.profile, error);
 77      }
 478      if (isExpiredCredentialsError(error)) {
 179        throw new ExpiredCredentialsError(error);
 80      }
 381      throw new SecretOperationError(
 82        `${EnvironmentVariable.maskSecretPath(name)}: ${describeError(error)}`,
 83      );
 84    }
 85  }
 86
 87  async setSecret(name: string, value: string): Promise<void> {
 688    const command = new PutParameterCommand({
 89      Name: name,
 90      Value: value,
 91      Type: 'SecureString',
 92      Overwrite: true,
 93    });
 694    try {
 695      await this.ssm.send(command);
 96    } catch (error) {
 397      if (isSsoSessionExpiredError(error)) {
 198        throw new SsoSessionExpiredError(this.profile, error);
 99      }
 2100      if (isExpiredCredentialsError(error)) {
 1101        throw new ExpiredCredentialsError(error);
 102      }
 1103      throw new SecretOperationError(
 104        `${EnvironmentVariable.maskSecretPath(name)}: ${describeError(error)}`,
 105      );
 106    }
 107  }
 108
 109  /**
 110   * Uses `GetParameters` in batches of 10 with `WithDecryption: false`: the
 111   * names it cannot find come back in `InvalidParameters`, and the values it
 112   * returns are never read. Needs `ssm:GetParameters` on the parameters.
 113   */
 114  async checkSecretsExist(
 115    names: readonly string[],
 116  ): Promise<ReadonlyMap<string, SecretExistence>> {
 13117    const results = new Map<string, SecretExistence>();
 13118    const distinct = [...new Set(names)];
 13119    for (let i = 0; i < distinct.length; i += GET_PARAMETERS_BATCH_SIZE) {
 16120      const batch = distinct.slice(i, i + GET_PARAMETERS_BATCH_SIZE);
 16121      for (const [name, existence] of await this.checkBatch(batch)) {
 51122        results.set(name, existence);
 123      }
 124    }
 11125    return results;
 126  }
 127
 128  private async checkBatch(
 129    batch: readonly string[],
 130  ): Promise<Array<[string, SecretExistence]>> {
 18131    try {
 18132      const { InvalidParameters = [] } = await this.ssm.send(
 133        new GetParametersCommand({
 134          Names: [...batch],
 135          WithDecryption: false,
 136        }),
 137      );
 12138      const missing = new Set(InvalidParameters);
 48139      return batch.map((name) => [
 140        name,
 141        { status: missing.has(name) ? 'missing' : 'exists' },
 142      ]);
 143    } catch (error) {
 6144      if (batch.length > 1 && PER_NAME_ERROR_NAMES.has(errorName(error))) {
 1145        const perName = await Promise.all(
 2146          batch.map((name) => this.checkBatch([name])),
 147        );
 1148        return perName.flat();
 149      }
 5150      const existence = this.toExistence(error);
 5151      return batch.map((name) => [name, existence]);
 152    }
 153  }
 154
 155  private toExistence(error: unknown): SecretExistence {
 5156    if (isSsoSessionExpiredError(error)) {
 1157      throw new SsoSessionExpiredError(this.profile, error);
 158    }
 4159    if (isExpiredCredentialsError(error)) {
 1160      throw new ExpiredCredentialsError(error);
 161    }
 3162    const reason = describeError(error);
 3163    const name = errorName(error);
 3164    if (name === 'AccessDeniedException') {
 1165      return { status: 'access_denied', reason };
 166    }
 2167    if (AUTHENTICATION_ERROR_NAMES.has(name)) {
 1168      return { status: 'authentication_failed', reason };
 169    }
 1170    return { status: 'provider_error', reason };
 171  }
 172
 173  async logIdentity(): Promise<void> {
 9174    if (this.identityLogged) {
 1175      return;
 176    }
 8177    this.identityLogged = true;
 8178    const [region, account] = await Promise.all([
 179      this.resolveRegion(),
 180      this.resolveAccount(),
 181    ]);
 8182    const profile = this.profile ?? 'default';
 9183    this.logger.info(this.formatIdentityBanner(account, region, profile));
 184  }
 185
 186  private formatIdentityBanner(
 187    account: string,
 188    region: string,
 189    profile: string,
 190  ): string {
 8191    const sep = pc.dim(' · ');
 192    const accountValue =
 8193      account === 'unknown' ? pc.red(account) : pc.green(account);
 194    const regionValue =
 8195      region === 'unknown' ? pc.red(region) : pc.yellow(region);
 8196    return (
 197      '\n' +
 198      pc.bold(pc.cyan('☁ AWS identity')) +
 199      sep +
 200      pc.dim('account=') +
 201      accountValue +
 202      sep +
 203      pc.dim('region=') +
 204      regionValue +
 205      sep +
 206      pc.dim('profile=') +
 207      pc.magenta(profile)
 208    );
 209  }
 210
 211  private async resolveRegion(): Promise<string> {
 8212    try {
 8213      return await this.ssm.config.region();
 214    } catch {
 0215      return 'unknown';
 216    }
 217  }
 218
 219  private async resolveAccount(): Promise<string> {
 8220    const fromCredentials = await this.tryCredentialsAccount();
 8221    if (fromCredentials) {
 3222      return fromCredentials;
 223    }
 5224    return this.tryStsAccount();
 225  }
 226
 227  private async tryCredentialsAccount(): Promise<string | undefined> {
 8228    try {
 8229      const credentials = await this.ssm.config.credentials();
 7230      return credentials.accountId;
 231    } catch {
 1232      return undefined;
 233    }
 234  }
 235
 236  private async tryStsAccount(): Promise<string> {
 5237    try {
 5238      const identity = await this.sts.send(new GetCallerIdentityCommand({}));
 4239      return identity.Account ?? 'unknown';
 240    } catch {
 1241      return 'unknown';
 242    }
 243  }
 244}
 245
 246function errorName(error: unknown): string {
 4247  return typeof error === 'object' && error !== null && 'name' in error
 248    ? String(error.name)
 249    : '';
 250}