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.
This commit is contained in:
Daniel Salazar authored and GitHub committed 2026-10-22 21:31:23 -07:00
1 parent 26d843778e
commit 4729d733bb
10 files changed
+170 -35

No files matched your search

+5 -1
View File
@@ -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": "",
@@ -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({
+9 -7
View File
@@ -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;
}) {
+9 -14
View File
@@ -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<string, TextractClient> = {};
#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<string, unknown> | undefined;
const textractAws = (textract?.aws ?? textract) as
Record<string, unknown> | 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;
+4 -5
View File
@@ -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<string, unknown> | 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,
}),
@@ -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,
});
@@ -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,
});
+2
View File
@@ -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 {
+66
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<unknown>)(),
).resolves.toMatchObject({
accessKeyId: 'AKIA-CHAIN',
secretAccessKey: 'chain-secret',
});
});
});
+58
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
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;
};