| | | 1 | | import type { SecretClient } from '@azure/keyvault-secrets'; |
| | | 2 | | import { injectable } from 'inversify'; |
| | | 3 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 4 | | import { |
| | | 5 | | InvalidArgumentError, |
| | | 6 | | SecretOperationError, |
| | | 7 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 8 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 9 | | import type { SecretExistence } from '../../domain/SecretExistence.js'; |
| | | 10 | | import { describeError } from '../describeError.js'; |
| | | 11 | | |
| | 12 | 12 | | @injectable() |
| | 12 | 13 | | export class AzureKeyVaultSecretProvider implements ISecretProvider { |
| | | 14 | | private client: SecretClient; |
| | 63 | 15 | | private normalizedNameRegistry = new Map<string, string>(); |
| | | 16 | | |
| | | 17 | | constructor(client: SecretClient) { |
| | 63 | 18 | | this.client = client; |
| | | 19 | | } |
| | | 20 | | |
| | | 21 | | async getSecret(name: string): Promise<string | undefined> { |
| | 32 | 22 | | const secretName = this.resolveSecretName(name); |
| | 32 | 23 | | try { |
| | 32 | 24 | | const secret = await this.client.getSecret(secretName); |
| | 22 | 25 | | return secret?.value ?? undefined; |
| | | 26 | | } catch (error) { |
| | 6 | 27 | | if ( |
| | | 28 | | typeof error === 'object' && |
| | | 29 | | error !== null && |
| | | 30 | | 'statusCode' in error && |
| | | 31 | | error.statusCode === 404 |
| | | 32 | | ) { |
| | 2 | 33 | | return undefined; |
| | | 34 | | } |
| | 4 | 35 | | throw new SecretOperationError( |
| | | 36 | | `${EnvironmentVariable.maskSecretPath(name)}: ${describeError(error)}`, |
| | | 37 | | ); |
| | | 38 | | } |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | async setSecret(name: string, value: string): Promise<void> { |
| | 10 | 42 | | const secretName = this.resolveSecretName(name); |
| | 10 | 43 | | 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>> { |
| | 11 | 57 | | const results = new Map<string, SecretExistence>(); |
| | 11 | 58 | | const secretNames = new Map<string, string>(); |
| | 11 | 59 | | for (const name of new Set(names)) { |
| | 16 | 60 | | try { |
| | 16 | 61 | | secretNames.set(name, this.resolveSecretName(name).toLowerCase()); |
| | | 62 | | } catch (error) { |
| | 1 | 63 | | results.set(name, { |
| | | 64 | | status: 'provider_error', |
| | | 65 | | reason: describeError(error), |
| | | 66 | | }); |
| | | 67 | | } |
| | | 68 | | } |
| | 11 | 69 | | if (secretNames.size === 0) { |
| | 0 | 70 | | return results; |
| | | 71 | | } |
| | | 72 | | |
| | | 73 | | let enabled: Set<string>; |
| | 11 | 74 | | try { |
| | 11 | 75 | | enabled = await this.listEnabledSecretNames(); |
| | | 76 | | } catch (error) { |
| | 3 | 77 | | const existence = toExistence(error); |
| | 3 | 78 | | for (const name of secretNames.keys()) { |
| | 4 | 79 | | results.set(name, existence); |
| | | 80 | | } |
| | 3 | 81 | | return results; |
| | | 82 | | } |
| | 8 | 83 | | for (const [name, secretName] of secretNames) { |
| | 11 | 84 | | results.set(name, { |
| | | 85 | | status: enabled.has(secretName) ? 'exists' : 'missing', |
| | | 86 | | }); |
| | | 87 | | } |
| | 8 | 88 | | return results; |
| | | 89 | | } |
| | | 90 | | |
| | | 91 | | private async listEnabledSecretNames(): Promise<Set<string>> { |
| | 11 | 92 | | const enabled = new Set<string>(); |
| | 11 | 93 | | for await (const properties of this.client.listPropertiesOfSecrets()) { |
| | 8 | 94 | | if (properties.enabled !== false) { |
| | 7 | 95 | | enabled.add(properties.name.toLowerCase()); |
| | | 96 | | } |
| | | 97 | | } |
| | 8 | 98 | | return enabled; |
| | | 99 | | } |
| | | 100 | | |
| | | 101 | | private resolveSecretName(originalName: string): string { |
| | 58 | 102 | | if (originalName.trim().length === 0) { |
| | 1 | 103 | | throw new InvalidArgumentError( |
| | | 104 | | 'Invalid secret name: name cannot be empty or whitespace-only.', |
| | | 105 | | ); |
| | | 106 | | } |
| | 57 | 107 | | 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. |
| | 57 | 110 | | const registryKey = normalized.toLowerCase(); |
| | 57 | 111 | | const existing = this.normalizedNameRegistry.get(registryKey); |
| | 57 | 112 | | if (existing !== undefined && existing !== originalName) { |
| | 5 | 113 | | 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 | | } |
| | 52 | 120 | | this.normalizedNameRegistry.set(registryKey, originalName); |
| | 52 | 121 | | return normalized; |
| | | 122 | | } |
| | | 123 | | } |
| | | 124 | | |
| | 12 | 125 | | const AUTHENTICATION_ERROR_NAMES = new Set([ |
| | | 126 | | 'CredentialUnavailableError', |
| | | 127 | | 'AuthenticationError', |
| | | 128 | | 'AuthenticationRequiredError', |
| | | 129 | | 'AggregateAuthenticationError', |
| | | 130 | | ]); |
| | | 131 | | |
| | | 132 | | function toExistence(error: unknown): SecretExistence { |
| | 3 | 133 | | const reason = describeError(error); |
| | | 134 | | const statusCode = |
| | 3 | 135 | | typeof error === 'object' && error !== null && 'statusCode' in error |
| | | 136 | | ? error.statusCode |
| | | 137 | | : undefined; |
| | | 138 | | const name = |
| | 3 | 139 | | typeof error === 'object' && error !== null && 'name' in error |
| | | 140 | | ? String(error.name) |
| | | 141 | | : ''; |
| | 3 | 142 | | if (statusCode === 403) { |
| | 1 | 143 | | return { status: 'access_denied', reason }; |
| | | 144 | | } |
| | 2 | 145 | | if (statusCode === 401 || AUTHENTICATION_ERROR_NAMES.has(name)) { |
| | 1 | 146 | | return { status: 'authentication_failed', reason }; |
| | | 147 | | } |
| | 1 | 148 | | 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 | | */ |
| | | 159 | | export function toAzureSecretName(name: string): string { |
| | 57 | 160 | | return name.replace(/^\/+/, '').replace(/\//g, '-'); |
| | | 161 | | } |