| | | 1 | | import { |
| | | 2 | | GetParameterCommand, |
| | | 3 | | GetParametersCommand, |
| | | 4 | | PutParameterCommand, |
| | | 5 | | type SSM, |
| | | 6 | | } from '@aws-sdk/client-ssm'; |
| | | 7 | | import { GetCallerIdentityCommand, type STS } from '@aws-sdk/client-sts'; |
| | | 8 | | import { injectable } from 'inversify'; |
| | | 9 | | import pc from 'picocolors'; |
| | | 10 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 11 | | import { |
| | | 12 | | ExpiredCredentialsError, |
| | | 13 | | SecretOperationError, |
| | | 14 | | SsoSessionExpiredError, |
| | | 15 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 16 | | import type { ILogger } from '../../domain/ports/ILogger.js'; |
| | | 17 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 18 | | import type { SecretExistence } from '../../domain/SecretExistence.js'; |
| | | 19 | | import { describeError } from '../describeError.js'; |
| | | 20 | | import { isExpiredCredentialsError } from './isExpiredCredentialsError.js'; |
| | | 21 | | import { isSsoSessionExpiredError } from './isSsoSessionExpiredError.js'; |
| | | 22 | | |
| | | 23 | | /** `GetParameters` accepts at most 10 names per call. */ |
| | 12 | 24 | | const GET_PARAMETERS_BATCH_SIZE = 10; |
| | | 25 | | |
| | 12 | 26 | | const 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 | | */ |
| | 12 | 38 | | const PER_NAME_ERROR_NAMES = new Set([ |
| | | 39 | | 'AccessDeniedException', |
| | | 40 | | 'ValidationException', |
| | | 41 | | ]); |
| | | 42 | | |
| | 12 | 43 | | @injectable() |
| | 12 | 44 | | export class AwsSsmSecretProvider implements ISecretProvider { |
| | | 45 | | private ssm: SSM; |
| | | 46 | | private logger: ILogger; |
| | | 47 | | private sts: STS; |
| | | 48 | | private profile?: string; |
| | 117 | 49 | | private identityLogged = false; |
| | | 50 | | |
| | | 51 | | constructor(ssm: SSM, logger: ILogger, sts: STS, profile?: string) { |
| | 117 | 52 | | this.ssm = ssm; |
| | 117 | 53 | | this.logger = logger; |
| | 117 | 54 | | this.sts = sts; |
| | 117 | 55 | | this.profile = profile; |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | async getSecret(name: string): Promise<string | undefined> { |
| | 12 | 59 | | try { |
| | 12 | 60 | | const command = new GetParameterCommand({ |
| | | 61 | | Name: name, |
| | | 62 | | WithDecryption: true, |
| | | 63 | | }); |
| | 12 | 64 | | const { Parameter } = await this.ssm.send(command); |
| | 5 | 65 | | return Parameter?.Value; |
| | | 66 | | } catch (error) { |
| | 7 | 67 | | if ( |
| | | 68 | | typeof error === 'object' && |
| | | 69 | | error !== null && |
| | | 70 | | 'name' in error && |
| | | 71 | | error.name === 'ParameterNotFound' |
| | | 72 | | ) { |
| | 2 | 73 | | return undefined; |
| | | 74 | | } |
| | 5 | 75 | | if (isSsoSessionExpiredError(error)) { |
| | 1 | 76 | | throw new SsoSessionExpiredError(this.profile, error); |
| | | 77 | | } |
| | 4 | 78 | | if (isExpiredCredentialsError(error)) { |
| | 1 | 79 | | throw new ExpiredCredentialsError(error); |
| | | 80 | | } |
| | 3 | 81 | | throw new SecretOperationError( |
| | | 82 | | `${EnvironmentVariable.maskSecretPath(name)}: ${describeError(error)}`, |
| | | 83 | | ); |
| | | 84 | | } |
| | | 85 | | } |
| | | 86 | | |
| | | 87 | | async setSecret(name: string, value: string): Promise<void> { |
| | 6 | 88 | | const command = new PutParameterCommand({ |
| | | 89 | | Name: name, |
| | | 90 | | Value: value, |
| | | 91 | | Type: 'SecureString', |
| | | 92 | | Overwrite: true, |
| | | 93 | | }); |
| | 6 | 94 | | try { |
| | 6 | 95 | | await this.ssm.send(command); |
| | | 96 | | } catch (error) { |
| | 3 | 97 | | if (isSsoSessionExpiredError(error)) { |
| | 1 | 98 | | throw new SsoSessionExpiredError(this.profile, error); |
| | | 99 | | } |
| | 2 | 100 | | if (isExpiredCredentialsError(error)) { |
| | 1 | 101 | | throw new ExpiredCredentialsError(error); |
| | | 102 | | } |
| | 1 | 103 | | 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>> { |
| | 13 | 117 | | const results = new Map<string, SecretExistence>(); |
| | 13 | 118 | | const distinct = [...new Set(names)]; |
| | 13 | 119 | | for (let i = 0; i < distinct.length; i += GET_PARAMETERS_BATCH_SIZE) { |
| | 16 | 120 | | const batch = distinct.slice(i, i + GET_PARAMETERS_BATCH_SIZE); |
| | 16 | 121 | | for (const [name, existence] of await this.checkBatch(batch)) { |
| | 51 | 122 | | results.set(name, existence); |
| | | 123 | | } |
| | | 124 | | } |
| | 11 | 125 | | return results; |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | private async checkBatch( |
| | | 129 | | batch: readonly string[], |
| | | 130 | | ): Promise<Array<[string, SecretExistence]>> { |
| | 18 | 131 | | try { |
| | 18 | 132 | | const { InvalidParameters = [] } = await this.ssm.send( |
| | | 133 | | new GetParametersCommand({ |
| | | 134 | | Names: [...batch], |
| | | 135 | | WithDecryption: false, |
| | | 136 | | }), |
| | | 137 | | ); |
| | 12 | 138 | | const missing = new Set(InvalidParameters); |
| | 48 | 139 | | return batch.map((name) => [ |
| | | 140 | | name, |
| | | 141 | | { status: missing.has(name) ? 'missing' : 'exists' }, |
| | | 142 | | ]); |
| | | 143 | | } catch (error) { |
| | 6 | 144 | | if (batch.length > 1 && PER_NAME_ERROR_NAMES.has(errorName(error))) { |
| | 1 | 145 | | const perName = await Promise.all( |
| | 2 | 146 | | batch.map((name) => this.checkBatch([name])), |
| | | 147 | | ); |
| | 1 | 148 | | return perName.flat(); |
| | | 149 | | } |
| | 5 | 150 | | const existence = this.toExistence(error); |
| | 5 | 151 | | return batch.map((name) => [name, existence]); |
| | | 152 | | } |
| | | 153 | | } |
| | | 154 | | |
| | | 155 | | private toExistence(error: unknown): SecretExistence { |
| | 5 | 156 | | if (isSsoSessionExpiredError(error)) { |
| | 1 | 157 | | throw new SsoSessionExpiredError(this.profile, error); |
| | | 158 | | } |
| | 4 | 159 | | if (isExpiredCredentialsError(error)) { |
| | 1 | 160 | | throw new ExpiredCredentialsError(error); |
| | | 161 | | } |
| | 3 | 162 | | const reason = describeError(error); |
| | 3 | 163 | | const name = errorName(error); |
| | 3 | 164 | | if (name === 'AccessDeniedException') { |
| | 1 | 165 | | return { status: 'access_denied', reason }; |
| | | 166 | | } |
| | 2 | 167 | | if (AUTHENTICATION_ERROR_NAMES.has(name)) { |
| | 1 | 168 | | return { status: 'authentication_failed', reason }; |
| | | 169 | | } |
| | 1 | 170 | | return { status: 'provider_error', reason }; |
| | | 171 | | } |
| | | 172 | | |
| | | 173 | | async logIdentity(): Promise<void> { |
| | 9 | 174 | | if (this.identityLogged) { |
| | 1 | 175 | | return; |
| | | 176 | | } |
| | 8 | 177 | | this.identityLogged = true; |
| | 8 | 178 | | const [region, account] = await Promise.all([ |
| | | 179 | | this.resolveRegion(), |
| | | 180 | | this.resolveAccount(), |
| | | 181 | | ]); |
| | 8 | 182 | | const profile = this.profile ?? 'default'; |
| | 9 | 183 | | this.logger.info(this.formatIdentityBanner(account, region, profile)); |
| | | 184 | | } |
| | | 185 | | |
| | | 186 | | private formatIdentityBanner( |
| | | 187 | | account: string, |
| | | 188 | | region: string, |
| | | 189 | | profile: string, |
| | | 190 | | ): string { |
| | 8 | 191 | | const sep = pc.dim(' · '); |
| | | 192 | | const accountValue = |
| | 8 | 193 | | account === 'unknown' ? pc.red(account) : pc.green(account); |
| | | 194 | | const regionValue = |
| | 8 | 195 | | region === 'unknown' ? pc.red(region) : pc.yellow(region); |
| | 8 | 196 | | 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> { |
| | 8 | 212 | | try { |
| | 8 | 213 | | return await this.ssm.config.region(); |
| | | 214 | | } catch { |
| | 0 | 215 | | return 'unknown'; |
| | | 216 | | } |
| | | 217 | | } |
| | | 218 | | |
| | | 219 | | private async resolveAccount(): Promise<string> { |
| | 8 | 220 | | const fromCredentials = await this.tryCredentialsAccount(); |
| | 8 | 221 | | if (fromCredentials) { |
| | 3 | 222 | | return fromCredentials; |
| | | 223 | | } |
| | 5 | 224 | | return this.tryStsAccount(); |
| | | 225 | | } |
| | | 226 | | |
| | | 227 | | private async tryCredentialsAccount(): Promise<string | undefined> { |
| | 8 | 228 | | try { |
| | 8 | 229 | | const credentials = await this.ssm.config.credentials(); |
| | 7 | 230 | | return credentials.accountId; |
| | | 231 | | } catch { |
| | 1 | 232 | | return undefined; |
| | | 233 | | } |
| | | 234 | | } |
| | | 235 | | |
| | | 236 | | private async tryStsAccount(): Promise<string> { |
| | 5 | 237 | | try { |
| | 5 | 238 | | const identity = await this.sts.send(new GetCallerIdentityCommand({})); |
| | 4 | 239 | | return identity.Account ?? 'unknown'; |
| | | 240 | | } catch { |
| | 1 | 241 | | return 'unknown'; |
| | | 242 | | } |
| | | 243 | | } |
| | | 244 | | } |
| | | 245 | | |
| | | 246 | | function errorName(error: unknown): string { |
| | 4 | 247 | | return typeof error === 'object' && error !== null && 'name' in error |
| | | 248 | | ? String(error.name) |
| | | 249 | | : ''; |
| | | 250 | | } |