< Summary - Envilder CLI

Information
Class: src/envilder/core/infrastructure/azure/AzureKeyVaultSecretProvider.ts
Assembly: Default
File(s): src/envilder/core/infrastructure/azure/AzureKeyVaultSecretProvider.ts
Tag: 561_41292037308
Line coverage
98%
Covered lines: 53
Uncovered lines: 1
Coverable lines: 54
Total lines: 161
Line coverage: 98.1%
Branch coverage
94%
Covered branches: 34
Total branches: 36
Branch coverage: 94.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/infrastructure/azure/AzureKeyVaultSecretProvider.ts

#LineLine coverage
 1import type { SecretClient } from '@azure/keyvault-secrets';
 2import { injectable } from 'inversify';
 3import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 4import {
 5  InvalidArgumentError,
 6  SecretOperationError,
 7} from '../../domain/errors/DomainErrors.js';
 8import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 9import type { SecretExistence } from '../../domain/SecretExistence.js';
 10import { describeError } from '../describeError.js';
 11
 1212@injectable()
 1213export class AzureKeyVaultSecretProvider implements ISecretProvider {
 14  private client: SecretClient;
 6315  private normalizedNameRegistry = new Map<string, string>();
 16
 17  constructor(client: SecretClient) {
 6318    this.client = client;
 19  }
 20
 21  async getSecret(name: string): Promise<string | undefined> {
 3222    const secretName = this.resolveSecretName(name);
 3223    try {
 3224      const secret = await this.client.getSecret(secretName);
 2225      return secret?.value ?? undefined;
 26    } catch (error) {
 627      if (
 28        typeof error === 'object' &&
 29        error !== null &&
 30        'statusCode' in error &&
 31        error.statusCode === 404
 32      ) {
 233        return undefined;
 34      }
 435      throw new SecretOperationError(
 36        `${EnvironmentVariable.maskSecretPath(name)}: ${describeError(error)}`,
 37      );
 38    }
 39  }
 40
 41  async setSecret(name: string, value: string): Promise<void> {
 1042    const secretName = this.resolveSecretName(name);
 1043    await this.client.setSecret(secretName, value);
 44  }
 45
 46  /**
 47   * Lists the vault's secret names once, which reads metadata only, and
 48   * matches every name against it the way Key Vault does, ignoring case. A
 49   * disabled secret cannot be read, so it counts as missing. Needs the `List`
 50   * secret permission (access policies) or
 51   * `Microsoft.KeyVault/vaults/secrets/readMetadata/action` (Azure RBAC,
 52   * included in Key Vault Secrets User).
 53   */
 54  async checkSecretsExist(
 55    names: readonly string[],
 56  ): Promise<ReadonlyMap<string, SecretExistence>> {
 1157    const results = new Map<string, SecretExistence>();
 1158    const secretNames = new Map<string, string>();
 1159    for (const name of new Set(names)) {
 1660      try {
 1661        secretNames.set(name, this.resolveSecretName(name).toLowerCase());
 62      } catch (error) {
 163        results.set(name, {
 64          status: 'provider_error',
 65          reason: describeError(error),
 66        });
 67      }
 68    }
 1169    if (secretNames.size === 0) {
 070      return results;
 71    }
 72
 73    let enabled: Set<string>;
 1174    try {
 1175      enabled = await this.listEnabledSecretNames();
 76    } catch (error) {
 377      const existence = toExistence(error);
 378      for (const name of secretNames.keys()) {
 479        results.set(name, existence);
 80      }
 381      return results;
 82    }
 883    for (const [name, secretName] of secretNames) {
 1184      results.set(name, {
 85        status: enabled.has(secretName) ? 'exists' : 'missing',
 86      });
 87    }
 888    return results;
 89  }
 90
 91  private async listEnabledSecretNames(): Promise<Set<string>> {
 1192    const enabled = new Set<string>();
 1193    for await (const properties of this.client.listPropertiesOfSecrets()) {
 894      if (properties.enabled !== false) {
 795        enabled.add(properties.name.toLowerCase());
 96      }
 97    }
 898    return enabled;
 99  }
 100
 101  private resolveSecretName(originalName: string): string {
 58102    if (originalName.trim().length === 0) {
 1103      throw new InvalidArgumentError(
 104        'Invalid secret name: name cannot be empty or whitespace-only.',
 105      );
 106    }
 57107    const normalized = toAzureSecretName(originalName);
 108    // Key Vault names are case-insensitive, so two references that differ
 109    // only by case still address the same secret.
 57110    const registryKey = normalized.toLowerCase();
 57111    const existing = this.normalizedNameRegistry.get(registryKey);
 57112    if (existing !== undefined && existing !== originalName) {
 5113      throw new SecretOperationError(
 114        `Secret name collision: '${originalName}' and '${existing}' ` +
 115          `both resolve to '${normalized}'. Use distinct ` +
 116          'Key Vault secret names in your map file ' +
 117          'when targeting Azure.',
 118      );
 119    }
 52120    this.normalizedNameRegistry.set(registryKey, originalName);
 52121    return normalized;
 122  }
 123}
 124
 12125const AUTHENTICATION_ERROR_NAMES = new Set([
 126  'CredentialUnavailableError',
 127  'AuthenticationError',
 128  'AuthenticationRequiredError',
 129  'AggregateAuthenticationError',
 130]);
 131
 132function toExistence(error: unknown): SecretExistence {
 3133  const reason = describeError(error);
 134  const statusCode =
 3135    typeof error === 'object' && error !== null && 'statusCode' in error
 136      ? error.statusCode
 137      : undefined;
 138  const name =
 3139    typeof error === 'object' && error !== null && 'name' in error
 140      ? String(error.name)
 141      : '';
 3142  if (statusCode === 403) {
 1143    return { status: 'access_denied', reason };
 144  }
 2145  if (statusCode === 401 || AUTHENTICATION_ERROR_NAMES.has(name)) {
 1146    return { status: 'authentication_failed', reason };
 147  }
 1148  return { status: 'provider_error', reason };
 149}
 150
 151/**
 152 * The only translation between a map-file reference and a Key Vault secret
 153 * name, shared with every SDK: `/` becomes `-`, ignoring leading slashes, so
 154 * a path-style reference such as `/myapp/db/password` reads
 155 * `myapp-db-password`. Anything else reaches Key Vault as written, and a
 156 * name it does not accept fails with Key Vault's own error.
 157 * @see https://learn.microsoft.com/azure/key-vault/general/about-keys-secrets-certificates#objects-identifiers-and-vers
 158 */
 159export function toAzureSecretName(name: string): string {
 57160  return name.replace(/^\/+/, '').replace(/\//g, '-');
 161}