From 4729d733bbb41fb415e5655e6e7a430daaa646ec Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 22 Oct 2026 21:31:23 -0700 Subject: [PATCH] feat(aws): DynamoDB, Polly and Textract can use the default credential chain (#4307) `useCredentialChain: true` in `dynamo.aws`, `providers['aws-polly']` or `providers['aws-textract']` takes credentials from the default AWS chain instead of static keys, as `s3.s3Config` already does. One provider is shared and refreshes temporary credentials itself. --- config.template.jsonc | 6 +- .../clients/dynamodb/DDBClient.test.ts | 13 ++++ src/backend/clients/dynamodb/DDBClient.ts | 16 +++-- src/backend/drivers/ai-ocr/OCRDriver.ts | 23 +++---- src/backend/drivers/ai-tts/TTSDriver.ts | 9 ++- .../awsPolly/AWSPollyTTSProvider.test.ts | 3 +- .../providers/awsPolly/AWSPollyTTSProvider.ts | 9 +-- src/backend/types.ts | 2 + src/backend/util/awsCredentials.test.ts | 66 +++++++++++++++++++ src/backend/util/awsCredentials.ts | 58 ++++++++++++++++ 10 files changed, 170 insertions(+), 35 deletions(-) create mode 100644 src/backend/util/awsCredentials.test.ts create mode 100644 src/backend/util/awsCredentials.ts diff --git a/config.template.jsonc b/config.template.jsonc index ab8597881..124973342 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -215,7 +215,9 @@ // "bootstrapTables": true, "path": "", // Credentials. NOTE: snake_case here, unlike `s3.s3Config` below. - // For dynamodb-local, any non-empty values work. + // For dynamodb-local, any non-empty values work. On AWS, + // `"useCredentialChain": true` uses the default credential chain + // instead of keys. "aws": { "access_key": "", "secret_key": "", @@ -493,6 +495,8 @@ "defaultVoiceId": "", "speechToSpeechModelId": "" }, + // Both AWS providers also take `"useCredentialChain": true` in place + // of keys, like `dynamo.aws`. "aws-polly": { "access_key": "", "secret_key": "", diff --git a/src/backend/clients/dynamodb/DDBClient.test.ts b/src/backend/clients/dynamodb/DDBClient.test.ts index c1c522b59..6dc7871b0 100644 --- a/src/backend/clients/dynamodb/DDBClient.test.ts +++ b/src/backend/clients/dynamodb/DDBClient.test.ts @@ -501,6 +501,19 @@ describe('DDBClient — credentialled configuration', () => { ).toThrow('requires both `access_key` and `secret_key`'); }); + it('accepts the credential chain in place of keys', async () => { + const client = new DDBClient({ + port: 0, + extensions: [], + dynamo: { + aws: { useCredentialChain: true, region: 'us-west-2' }, + endpoint: 'http://127.0.0.1:1', + }, + } as unknown as IConfig); + + await expect(client.recreateClient()).resolves.toBeUndefined(); + }); + it('skips table creation on a managed deployment', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const client = new DDBClient({ diff --git a/src/backend/clients/dynamodb/DDBClient.ts b/src/backend/clients/dynamodb/DDBClient.ts index 9cf3f9c65..71ebd930d 100644 --- a/src/backend/clients/dynamodb/DDBClient.ts +++ b/src/backend/clients/dynamodb/DDBClient.ts @@ -43,6 +43,10 @@ import { once } from 'node:events'; import { Agent as httpsAgent } from 'node:https'; import { HttpError } from '../../core/http'; import type { IConfig, IDynamoConfig } from '../../types'; +import { + awsCredentialsFrom, + type AwsCredentials, +} from '../../util/awsCredentials.js'; import { BoundedTtlMap } from '../../util/boundedTtlMap.js'; import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js'; import { Span } from '../../util/span.js'; @@ -688,17 +692,15 @@ export class DDBClient extends PuterClient { } #bindAwsClient() { - const accessKeyId = this.#ddbConfig.aws?.access_key; - const secretAccessKey = this.#ddbConfig.aws?.secret_key; - - if (!accessKeyId || !secretAccessKey) { + const credentials = awsCredentialsFrom(this.#ddbConfig.aws); + if (!credentials) { throw new Error( - 'DynamoDB aws config requires both `access_key` and `secret_key`', + 'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`', ); } this.#bindClient({ - credentials: { accessKeyId, secretAccessKey }, + credentials, region: this.#ddbConfig.aws?.region || 'us-west-2', endpoint: this.#ddbConfig.endpoint, }); @@ -720,7 +722,7 @@ export class DDBClient extends PuterClient { region, endpoint, }: { - credentials: { accessKeyId: string; secretAccessKey: string }; + credentials: AwsCredentials; region: string; endpoint?: string; }) { diff --git a/src/backend/drivers/ai-ocr/OCRDriver.ts b/src/backend/drivers/ai-ocr/OCRDriver.ts index 09a536467..3a157cf0d 100644 --- a/src/backend/drivers/ai-ocr/OCRDriver.ts +++ b/src/backend/drivers/ai-ocr/OCRDriver.ts @@ -27,6 +27,10 @@ import { Mistral } from '@mistralai/mistralai'; import { Actor } from '../../core/actor.js'; import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { + awsCredentialsFrom, + type AwsCredentials, +} from '../../util/awsCredentials.js'; import { mimeFromName } from '../../util/fileSigning.js'; import { assertActorHasCredits, @@ -293,11 +297,7 @@ export class OCRDriver extends PuterDriver { // Textract state — one client per region. #textractClients: Record = {}; - #awsConfig: { - accessKeyId?: string; - secretAccessKey?: string; - region?: string; - } | null = null; + #awsConfig: { credentials: AwsCredentials; region: string } | null = null; /** DetectDocumentText rates per region, from AWS's public price list. */ #textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]); @@ -314,16 +314,14 @@ export class OCRDriver extends PuterDriver { Record | undefined; const textractAws = (textract?.aws ?? textract) as Record | undefined; - const textractAccessKey = textractAws?.access_key as string | undefined; - const textractSecretKey = textractAws?.secret_key as string | undefined; + const textractCredentials = awsCredentialsFrom(textractAws); const textractRegion = (textractAws?.region as string | undefined) ?? (textract?.region as string | undefined) ?? DEFAULT_TEXTRACT_REGION; - if (textractAccessKey && textractSecretKey) { + if (textractCredentials) { this.#awsConfig = { - accessKeyId: textractAccessKey, - secretAccessKey: textractSecretKey, + credentials: textractCredentials, region: textractRegion, }; // Warm the configured region so the cost report has its rate. @@ -460,10 +458,7 @@ export class OCRDriver extends PuterDriver { const cached = this.#textractClients[region]; if (cached) return cached; const client = new TextractClient({ - credentials: { - accessKeyId: this.#awsConfig!.accessKeyId!, - secretAccessKey: this.#awsConfig!.secretAccessKey!, - }, + credentials: this.#awsConfig!.credentials, region, }); this.#textractClients[region] = client; diff --git a/src/backend/drivers/ai-tts/TTSDriver.ts b/src/backend/drivers/ai-tts/TTSDriver.ts index 483bbcfaa..bf56b19c0 100644 --- a/src/backend/drivers/ai-tts/TTSDriver.ts +++ b/src/backend/drivers/ai-tts/TTSDriver.ts @@ -19,6 +19,7 @@ import { Context } from '../../core/context.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { awsCredentialsFrom } from '../../util/awsCredentials.js'; import type { DriverStreamResult } from '../meta.js'; import { PuterDriver } from '../types.js'; import { @@ -241,15 +242,13 @@ export class TTSDriver extends PuterDriver { const polly = providers['aws-polly']; const pollyAws = (polly?.aws ?? polly) as Record | undefined; - const pollyAccessKey = pollyAws?.access_key as string | undefined; - const pollySecretKey = pollyAws?.secret_key as string | undefined; - if (pollyAccessKey && pollySecretKey) { + const pollyCredentials = awsCredentialsFrom(pollyAws); + if (pollyCredentials) { register( 'aws-polly', () => new AWSPollyTTSProvider(m, { - access_key: pollyAccessKey, - secret_key: pollySecretKey, + credentials: pollyCredentials, region: (pollyAws?.region ?? polly?.region) as string | undefined, }), diff --git a/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.test.ts b/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.test.ts index 66d5f51b2..ecf031f3a 100644 --- a/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.test.ts +++ b/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.test.ts @@ -101,8 +101,7 @@ const aiMetering = () => const makeProvider = (region?: string) => new AWSPollyTTSProvider(aiMetering(), { - access_key: 'AKIA-TEST', - secret_key: 'secret', + credentials: { accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' }, region, }); diff --git a/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.ts b/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.ts index 037eb8722..089e6e34b 100644 --- a/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.ts +++ b/src/backend/drivers/ai-tts/providers/awsPolly/AWSPollyTTSProvider.ts @@ -27,6 +27,7 @@ import { } from '@aws-sdk/client-polly'; import { HttpError } from '../../../../core/http/HttpError.js'; import { Context } from '../../../../core/context.js'; +import type { AwsCredentials } from '../../../../util/awsCredentials.js'; import type { AiMeteringService } from '../../../util/aiCostFactor.js'; import type { DriverStreamResult } from '../../../meta.js'; import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js'; @@ -83,8 +84,7 @@ export class AWSPollyTTSProvider implements ITTSProvider { constructor( private readonly meteringService: AiMeteringService, private readonly config: { - access_key: string; - secret_key: string; + credentials: AwsCredentials; region?: string; }, ) { @@ -115,10 +115,7 @@ export class AWSPollyTTSProvider implements ITTSProvider { } this.clients[resolvedRegion] = new PollyClient({ - credentials: { - accessKeyId: cfg.access_key, - secretAccessKey: cfg.secret_key, - }, + credentials: cfg.credentials, region: resolvedRegion, }); diff --git a/src/backend/types.ts b/src/backend/types.ts index 1f8c2ee47..28206fbbc 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -23,6 +23,8 @@ export interface IAWSCredentials { access_key?: string; secret_key?: string; region?: string; + /** Use the default AWS credential chain instead of the keys. */ + useCredentialChain?: boolean; } export interface IDynamoConfig { diff --git a/src/backend/util/awsCredentials.test.ts b/src/backend/util/awsCredentials.test.ts new file mode 100644 index 000000000..24f5df826 --- /dev/null +++ b/src/backend/util/awsCredentials.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { awsCredentialsFrom } from './awsCredentials.js'; + +describe('awsCredentialsFrom', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns the static keys', () => { + expect( + awsCredentialsFrom({ + access_key: 'AKIA-TEST', + secret_key: 'secret', + }), + ).toEqual({ accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' }); + }); + + it('returns null without both keys or the chain', () => { + expect(awsCredentialsFrom(undefined)).toBeNull(); + expect(awsCredentialsFrom({ access_key: 'AKIA-TEST' })).toBeNull(); + expect( + awsCredentialsFrom({ access_key: '', secret_key: '' }), + ).toBeNull(); + expect(awsCredentialsFrom({ useCredentialChain: 'true' })).toBeNull(); + }); + + it('prefers the credential chain over keys, sharing one provider', async () => { + vi.stubEnv('AWS_ACCESS_KEY_ID', 'AKIA-CHAIN'); + vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'chain-secret'); + + const first = awsCredentialsFrom({ + useCredentialChain: true, + access_key: 'AKIA-TEST', + secret_key: 'secret', + }); + const second = awsCredentialsFrom({ useCredentialChain: true }); + + expect(typeof first).toBe('function'); + expect(second).toBe(first); + await expect( + (first as () => Promise)(), + ).resolves.toMatchObject({ + accessKeyId: 'AKIA-CHAIN', + secretAccessKey: 'chain-secret', + }); + }); +}); diff --git a/src/backend/util/awsCredentials.ts b/src/backend/util/awsCredentials.ts new file mode 100644 index 000000000..72626da54 --- /dev/null +++ b/src/backend/util/awsCredentials.ts @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +type CredentialChain = ReturnType; + +export type AwsCredentials = + { accessKeyId: string; secretAccessKey: string } | CredentialChain; + +let chain: CredentialChain | null = null; + +/** + * Credentials for an AWS client from a `{ access_key, secret_key, + * useCredentialChain }` config block, or null when it has neither. The default + * chain is one shared provider that refreshes temporary credentials itself. + */ +export const awsCredentialsFrom = ( + conf: + | { + access_key?: unknown; + secret_key?: unknown; + useCredentialChain?: unknown; + } + | undefined, +): AwsCredentials | null => { + if (conf?.useCredentialChain === true) { + chain ??= fromNodeProviderChain(); + return chain; + } + const accessKeyId = conf?.access_key; + const secretAccessKey = conf?.secret_key; + if ( + typeof accessKeyId === 'string' && + accessKeyId && + typeof secretAccessKey === 'string' && + secretAccessKey + ) { + return { accessKeyId, secretAccessKey }; + } + return null; +};