From f321cb7b063af74d44f12fbdfc365021f57e7cba Mon Sep 17 00:00:00 2001 From: "Song, Michelle" <268042956+mish-elle@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:33:25 -0400 Subject: [PATCH 1/3] feat: add AWS IAM authentication support for S3 Add opt-in AWS IAM authentication for S3 connections. When IAM is enabled, services authenticate to S3 using short-lived SigV4 pre-signed tokens instead of static credentials, with a new token generated for each request. New environment variables: - S3_AWS_IAM_AUTH_ENABLED: enable IAM authentication for S3 - S3_MIRROR_AWS_IAM_AUTH_ENABLED: enable IAM authentication for S3 Mirror - S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED: enable IAM authentication for S3 Audit Log --- .changeset/jolly-chefs-behave.md | 24 + packages/services/api/package.json | 2 + packages/services/api/src/create.ts | 29 +- .../worker/persisted-documents-worker.ts | 21 +- .../providers/audit-logs-manager.spec.ts | 114 +++++ .../providers/audit-logs-manager.ts | 3 + .../api/src/modules/cdn/providers/aws.ts | 161 ++++++- .../src/modules/cdn/providers/s3-iam.spec.ts | 440 ++++++++++++++++++ packages/services/cdn-worker/src/aws.ts | 82 +++- .../services/cdn-worker/tests/s3-iam.spec.ts | 200 ++++++++ packages/services/server/README.md | 15 +- packages/services/server/src/environment.ts | 67 ++- packages/services/server/src/index.ts | 44 +- .../server/src/persisted-documents-worker.ts | 38 +- 14 files changed, 1134 insertions(+), 106 deletions(-) create mode 100644 .changeset/jolly-chefs-behave.md create mode 100644 packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts create mode 100644 packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts create mode 100644 packages/services/cdn-worker/tests/s3-iam.spec.ts diff --git a/.changeset/jolly-chefs-behave.md b/.changeset/jolly-chefs-behave.md new file mode 100644 index 00000000000..59d4a284d57 --- /dev/null +++ b/.changeset/jolly-chefs-behave.md @@ -0,0 +1,24 @@ +--- +'hive': minor +--- + +Added opt-in AWS IAM authentication for S3 connections. When IAM is enabled, services authenticate +to S3 using short-lived SigV4 pre-signed tokens instead of static passwords, since S3 connections +are HTTP requests a new token will be generate for each call. + +### New environment variables + +| Variable | Service | Description | +| ----------------------------------- | ------- | --------------------------------------------------------- | +| `S3_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3. | +| `S3_MIRROR_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3 Mirror. | +| `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED` | server | Set to `1` to enable IAM authentication for S3 Audit Log. | + +### To enable + +- `S3_*_AWS_IAM_AUTH_ENABLED=1`. +- `S3_BUCKET_NAME` set to the AWS S3 bucket. +- `S3_ENDPOINT` set with the S3 endpoint with the AWS Region (i.e. https://s3.us-east-1.amazonaws.com) + +When `CDN_API=1` is set on the server, the CDN artifact handler also uses IAM-authenticated S3 clients. +Adds support for S3 Audit Logs exported to AWS S3. diff --git a/packages/services/api/package.json b/packages/services/api/package.json index f5ed2a41df4..0b8c980f1e6 100644 --- a/packages/services/api/package.json +++ b/packages/services/api/package.json @@ -9,6 +9,7 @@ "exports": { "./modules/auth/lib/supertokens-at-home/crypto": "./src/modules/auth/lib/supertokens-at-home/crypto.ts", "./modules/auth/providers/supertokens-store": "./src/modules/auth/providers/supertokens-store.ts", + "./modules/cdn/providers/aws": "./src/modules/cdn/providers/aws.ts", "./modules/shared/providers/logger": "./src/modules/shared/providers/logger.ts", "./modules/shared/providers/redis": "./src/modules/shared/providers/redis.ts", "./modules/schema/providers/schema-version-store": "./src/modules/schema/providers/schema-version-store.ts" @@ -19,6 +20,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "3.1035.0", + "@aws-sdk/credential-providers": "3.1035.0", "@aws-sdk/s3-request-presigner": "3.1035.0", "@bentocache/plugin-prometheus": "0.2.0", "@date-fns/utc": "2.1.1", diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 8546fa9c023..2aafb41b79f 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -13,6 +13,7 @@ import { authModule } from './modules/auth'; import { Session } from './modules/auth/lib/authz'; import { cdnModule } from './modules/cdn'; import { AwsClient } from './modules/cdn/providers/aws'; +import type { AwsCredentialProvider } from './modules/cdn/providers/aws'; import { CDN_CONFIG, CDNConfig } from './modules/cdn/providers/tokens'; import { collectionModule } from './modules/collection'; import { commerceModule } from './modules/commerce'; @@ -137,23 +138,17 @@ export function createRegistry({ s3: { bucketName: string; endpoint: string; - accessKeyId: string; - secretAccessKeyId: string; - sessionToken?: string; + credentialProvider: AwsCredentialProvider; }; s3Mirror: { bucketName: string; endpoint: string; - accessKeyId: string; - secretAccessKeyId: string; - sessionToken?: string; + credentialProvider: AwsCredentialProvider; } | null; s3AuditLogs: { bucketName: string; endpoint: string; - accessKeyId: string; - secretAccessKeyId: string; - sessionToken?: string; + credentialProvider: AwsCredentialProvider; } | null; encryptionSecret: string; app: { @@ -177,9 +172,7 @@ export function createRegistry({ const s3Config: S3Config = [ { client: new AwsClient({ - accessKeyId: s3.accessKeyId, - secretAccessKey: s3.secretAccessKeyId, - sessionToken: s3.sessionToken, + credentialProvider: s3.credentialProvider, service: 's3', }), bucket: s3.bucketName, @@ -190,9 +183,7 @@ export function createRegistry({ if (s3Mirror) { s3Config.push({ client: new AwsClient({ - accessKeyId: s3Mirror.accessKeyId, - secretAccessKey: s3Mirror.secretAccessKeyId, - sessionToken: s3Mirror.sessionToken, + credentialProvider: s3Mirror.credentialProvider, service: 's3', }), bucket: s3Mirror.bucketName, @@ -205,13 +196,11 @@ export function createRegistry({ const auditLogS3Config = s3AuditLogs ? new AuditLogS3Config( new AwsClient({ - accessKeyId: s3AuditLogs.accessKeyId, - secretAccessKey: s3AuditLogs.secretAccessKeyId, - sessionToken: s3AuditLogs.sessionToken, + credentialProvider: s3AuditLogs.credentialProvider, service: 's3', }), - s3.endpoint, - s3.bucketName, + s3AuditLogs.endpoint, + s3AuditLogs.bucketName, ) : new AuditLogS3Config(s3Config[0].client, s3Config[0].endpoint, s3Config[0].bucket); diff --git a/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts b/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts index 5ddd0f5ceeb..39fcd8bff90 100644 --- a/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts +++ b/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts @@ -1,5 +1,6 @@ import { type MessagePort } from 'node:worker_threads'; import { AwsClient } from '../../cdn/providers/aws'; +import type { AwsCredentialProvider } from '../../cdn/providers/aws'; import { ClickHouse } from '../../operations/providers/clickhouse-client'; import { HttpClient } from '../../shared/providers/http-client'; import { Logger } from '../../shared/providers/logger'; @@ -21,20 +22,12 @@ export function createWorker( s3: { readonly bucketName: string; readonly endpoint: string; - readonly credentials: { - readonly accessKeyId: string; - readonly secretAccessKey: string; - readonly sessionToken: string | undefined; - }; + readonly credentialProvider: AwsCredentialProvider; }; s3Mirror: { readonly bucketName: string; readonly endpoint: string; - readonly credentials: { - readonly accessKeyId: string; - readonly secretAccessKey: string; - readonly sessionToken: string | undefined; - }; + readonly credentialProvider: AwsCredentialProvider; } | null; clickhouse: { readonly host: string; @@ -48,9 +41,7 @@ export function createWorker( const s3Config: S3Config = [ { client: new AwsClient({ - accessKeyId: env.s3.credentials.accessKeyId, - secretAccessKey: env.s3.credentials.secretAccessKey, - sessionToken: env.s3.credentials.sessionToken, + credentialProvider: env.s3.credentialProvider, service: 's3', }), bucket: env.s3.bucketName, @@ -61,9 +52,7 @@ export function createWorker( if (env.s3Mirror) { s3Config.push({ client: new AwsClient({ - accessKeyId: env.s3Mirror.credentials.accessKeyId, - secretAccessKey: env.s3Mirror.credentials.secretAccessKey, - sessionToken: env.s3Mirror.credentials.sessionToken, + credentialProvider: env.s3Mirror.credentialProvider, service: 's3', }), bucket: env.s3Mirror.bucketName, diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts new file mode 100644 index 00000000000..93e86dc65ce --- /dev/null +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AwsClient, type AwsCredentialProvider } from '../../cdn/providers/aws'; +import { AuditLogManager, AuditLogS3Config } from './audit-logs-manager'; + +vi.mock('@hive/workflows/kit', () => ({ TaskScheduler: class {} })); +vi.mock('@hive/workflows/tasks/audit-log-export', () => ({ AuditLogExportTask: {} })); +vi.mock('@sentry/node', () => ({ captureException: vi.fn() })); +vi.mock('../../operations/providers/clickhouse-client', () => ({ + ClickHouse: class {}, + sql: (strings: TemplateStringsArray, ...values: any[]) => ({ strings, values }), +})); +vi.mock('./audit-log-recorder', () => ({ + formatToClickhouseDateTime: (d: Date) => d.toISOString(), +})); +vi.mock('./audit-logs-types', () => ({ + AuditLogClickhouseArrayModel: { parse: (v: any) => v }, +})); + +function fakeCredentialProvider(keyId: string): AwsCredentialProvider { + return { + getCredentials: async () => ({ accessKeyId: keyId, secretAccessKey: `${keyId}-secret` }), + }; +} + +function createAuditLogManager(fetchImpl: (...args: any[]) => any) { + const credentialProvider = fakeCredentialProvider('test-key'); + const client = new AwsClient({ credentialProvider, service: 's3' }); + const fetchSpy = vi.spyOn(client, 'fetch').mockImplementation(fetchImpl as any); + + const s3Config = new AuditLogS3Config(client, 'https://s3.example.com', 'audit-bucket'); + + const mockSession = { + assertPerformAction: vi.fn().mockResolvedValue(undefined), + getViewer: vi.fn().mockResolvedValue({ email: 'user@example.com' }), + }; + const mockClickHouse = { + query: vi.fn().mockResolvedValue({ + data: [ + { + id: '1', + timestamp: '2026-01-15', + organizationId: 'org-1', + eventAction: 'login', + userId: 'u1', + userEmail: 'u@x.com', + accessTokenId: null, + metadata: '{}', + }, + ], + }), + }; + const mockLogger = { child: () => mockLogger, info: vi.fn(), error: vi.fn() } as any; + const mockTaskScheduler = { scheduleTask: vi.fn().mockResolvedValue(undefined) }; + const mockStorage = { + getOrganization: vi.fn().mockResolvedValue({ name: 'TestOrg', id: 'org-1' }), + }; + + return { + manager: new AuditLogManager( + mockLogger, + mockClickHouse as any, + s3Config, + mockTaskScheduler as any, + mockSession as any, + mockStorage as any, + ), + fetchSpy, + }; +} + +/** + * Guards against regression where the S3 PUT upload was missing + * `aws: { signQuery: true }`, causing the `got` library to receive + * auth headers it can't handle — resulting in silent upload failures. + */ +describe('AuditLogManager S3 upload uses signQuery', () => { + it('PUT upload includes aws.signQuery: true', async () => { + const { manager, fetchSpy } = createAuditLogManager( + vi.fn().mockResolvedValue({ ok: true, url: 'https://s3.example.com/key' }), + ); + + await manager.exportAndSendEmail('org-1', { + startDate: new Date('2026-01-01'), + endDate: new Date('2026-01-31'), + }); + + const putCall = fetchSpy.mock.calls[0]; + expect(putCall[1]).toEqual( + expect.objectContaining({ + method: 'PUT', + aws: expect.objectContaining({ signQuery: true }), + }), + ); + }); + + it('GET presigned URL includes aws.signQuery: true', async () => { + const { manager, fetchSpy } = createAuditLogManager( + vi.fn().mockResolvedValue({ ok: true, url: 'https://s3.example.com/key' }), + ); + + await manager.exportAndSendEmail('org-1', { + startDate: new Date('2026-01-01'), + endDate: new Date('2026-01-31'), + }); + + const getCall = fetchSpy.mock.calls[1]; + expect(getCall[1]).toEqual( + expect.objectContaining({ + method: 'GET', + aws: expect.objectContaining({ signQuery: true }), + }), + ); + }); +}); diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts index 65c45476452..1c473ede191 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.ts @@ -171,6 +171,9 @@ export class AuditLogManager { 'Content-Type': 'text/csv', }, body: csvData, + aws: { + signQuery: true, + }, }); if (!uploadResult.ok) { diff --git a/packages/services/api/src/modules/cdn/providers/aws.ts b/packages/services/api/src/modules/cdn/providers/aws.ts index c303aed2215..e05773b739f 100644 --- a/packages/services/api/src/modules/cdn/providers/aws.ts +++ b/packages/services/api/src/modules/cdn/providers/aws.ts @@ -1,4 +1,127 @@ import { got, type Method, type OptionsInit } from 'got'; +import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; + +/** + * AWS credential provider chain + custom SigV4 signer (ported from aws4fetch). + * + * Uses @aws-sdk/credential-providers for all credential resolution (static, + * container metadata, EKS Pod Identity, IMDS, env vars, etc.) so that upstream + * changes are handled by the SDK automatically. + * The SDK is only used for credential resolution — request signing uses the + * custom AwsV4Signer below. + */ + +// --------------------------------------------------------------------------- +// Credential types & providers +// --------------------------------------------------------------------------- + +/** + * AWS credentials resolved by a credential provider. + */ +export interface AwsCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + +/** + * A provider that returns AWS credentials, potentially fetching them + * from an external source (ECS task role, EKS Pod Identity, etc.). + * + * NOTE: This interface is also defined (structurally) in the cdn-worker package, + * which targets Cloudflare Workers and cannot import from the AWS SDK. + */ +export interface AwsCredentialProvider { + getCredentials(): Promise; +} + +/** + * Options for creating a credential provider via the default chain. + */ +export interface DefaultCredentialChainOptions { + /** Static credentials as fallback when IAM auth is unavailable or disabled. */ + staticCredentials?: { + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken?: string | undefined; + }; + /** + * Whether IAM auth is enabled. Derived from `AWS_REGION` being set and the + * per-service `*_AWS_IAM_AUTH_ENABLED` flag being `'1'`. + * + * When `true`, the SDK credential chain is used (IAM wins over static creds). + * When `false`, only static credentials are accepted — throws if missing. + */ + awsIamAuthEnabled?: boolean; + /** Optional label to prefix log messages, e.g. 's3', 's3mirror', 's3audit'. */ + label?: string; + /** Logger for diagnostic messages. */ + logger?: { info(msg: string): void; debug?(msg: string): void; error(msg: string): void }; +} + +/** + * Creates an AWS credential provider. + * + * Priority order: + * 1. If IAM auth is enabled → use SDK credential chain (Pod Identity, IRSA, etc.) + * 2. If IAM auth is disabled or unavailable → use static credentials + * 3. If neither → throw + * + * The SDK handles credential caching and automatic refresh before expiry. + */ +export function createDefaultCredentialProvider( + options?: DefaultCredentialChainOptions, +): AwsCredentialProvider { + const logger = options?.logger; + const prefix = options?.label ? `[${options.label}] ` : ''; + const awsIamAuthEnabled = options?.awsIamAuthEnabled ?? false; + const { accessKeyId, secretAccessKey } = options?.staticCredentials ?? {}; + + let sdkProvider: () => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>; + + if (awsIamAuthEnabled) { + logger?.info(`${prefix}Using AWS SDK default credential chain (IAM auth enabled)`); + sdkProvider = fromNodeProviderChain(); + } else if (accessKeyId && secretAccessKey) { + logger?.info(`${prefix}Using static AWS credentials`); + sdkProvider = async () => ({ + accessKeyId, + secretAccessKey, + sessionToken: options?.staticCredentials?.sessionToken, + }); + } else { + throw new Error( + 'No AWS credentials available. Either deploy on AWS with IAM auth enabled, ' + + 'or provide static credentials (e.g. S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY).', + ); + } + + return { + getCredentials: async () => { + const creds = await sdkProvider(); + const expiration = + 'expiration' in creds && creds.expiration instanceof Date ? creds.expiration : undefined; + logger?.debug?.( + `${prefix}Credentials resolved` + + (expiration ? ` (expires at ${expiration.toISOString()})` : ''), + ); + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// Custom SigV4 signer (ported from aws4fetch) +// --------------------------------------------------------------------------- + /** * This is a copy of https://github.com/mhart/aws4fetch which is licensed MIT @@ -54,36 +177,21 @@ type AwsRequestInit = { }; export class AwsClient { - private secretAccessKey: string; - private accessKeyId: string; - private sessionToken?: string; + private credentialProvider: AwsCredentialProvider; private service?: string; private region?: string; private cache: Map; - constructor({ - accessKeyId, - secretAccessKey, - sessionToken, - service, - region, - cache, - }: { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; + constructor(options: { + credentialProvider: AwsCredentialProvider; service?: string; region?: string; cache?: Map; }) { - if (accessKeyId == null) throw new TypeError('accessKeyId is a required option'); - if (secretAccessKey == null) throw new TypeError('secretAccessKey is a required option'); - this.accessKeyId = accessKeyId; - this.secretAccessKey = secretAccessKey; - this.sessionToken = sessionToken; - this.service = service; - this.region = region; - this.cache = cache || new Map(); + this.credentialProvider = options.credentialProvider; + this.service = options.service; + this.region = options.region; + this.cache = options.cache || new Map(); } async fetch(url: string, init: AwsRequestInit) { @@ -138,14 +246,17 @@ export class AwsClient { } private async sign(url: string, init?: AwsRequestInit) { + // Resolve credentials dynamically (static credentials and EKS Pod Identity) + const credentials = await this.credentialProvider.getCredentials(); + const signer = new AwsV4Signer({ method: init?.method, url, headers: init?.headers, body: init?.body, - accessKeyId: init?.aws?.accessKeyId || this.accessKeyId, - secretAccessKey: init?.aws?.secretAccessKey || this.secretAccessKey, - sessionToken: init?.aws?.sessionToken || this.sessionToken, + accessKeyId: init?.aws?.accessKeyId || credentials.accessKeyId, + secretAccessKey: init?.aws?.secretAccessKey || credentials.secretAccessKey, + sessionToken: init?.aws?.sessionToken || credentials.sessionToken, service: init?.aws?.service || this.service, region: init?.aws?.region || this.region, cache: init?.aws?.cache || this.cache, diff --git a/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts b/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts new file mode 100644 index 00000000000..293f9b18d14 --- /dev/null +++ b/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts @@ -0,0 +1,440 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AwsClient, createDefaultCredentialProvider, type AwsCredentialProvider } from './aws'; + +const { mockFromNodeProviderChain } = vi.hoisted(() => ({ + mockFromNodeProviderChain: vi.fn(), +})); +vi.mock('@aws-sdk/credential-providers', () => ({ + fromNodeProviderChain: mockFromNodeProviderChain, +})); + +function createMockLogger() { + return { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }; +} + +describe('createDefaultCredentialProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('static credentials (IAM disabled)', () => { + it('returns static credentials with session token', async () => { + const provider = createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }, + }); + + const creds = await provider.getCredentials(); + expect(creds).toEqual({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'TOKEN', + }); + }); + + it('returns static credentials without session token', async () => { + const provider = createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + }, + }); + + const creds = await provider.getCredentials(); + expect(creds).toEqual({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: undefined, + }); + }); + }); + + describe('IAM credential chain (IAM enabled)', () => { + it('delegates to the SDK credential chain', async () => { + const sdkProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'IAM_AKID', + secretAccessKey: 'IAM_SECRET', + sessionToken: 'IAM_TOKEN', + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + + const provider = createDefaultCredentialProvider({ + awsIamAuthEnabled: true, + }); + + const creds = await provider.getCredentials(); + expect(mockFromNodeProviderChain).toHaveBeenCalled(); + expect(creds).toEqual({ + accessKeyId: 'IAM_AKID', + secretAccessKey: 'IAM_SECRET', + sessionToken: 'IAM_TOKEN', + }); + }); + + it('ignores static credentials when awsIamAuthEnabled is true', async () => { + const sdkProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'IAM_AKID', + secretAccessKey: 'IAM_SECRET', + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + + const provider = createDefaultCredentialProvider({ + awsIamAuthEnabled: true, + staticCredentials: { + accessKeyId: 'STATIC_AKID', + secretAccessKey: 'STATIC_SECRET', + }, + }); + + const creds = await provider.getCredentials(); + expect(creds.accessKeyId).toBe('IAM_AKID'); + }); + + it('calls the SDK provider on every getCredentials() invocation', async () => { + let callCount = 0; + const sdkProvider = vi.fn().mockImplementation(async () => { + callCount++; + return { + accessKeyId: `AKID_${callCount}`, + secretAccessKey: `SECRET_${callCount}`, + }; + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + + const provider = createDefaultCredentialProvider({ awsIamAuthEnabled: true }); + + const first = await provider.getCredentials(); + const second = await provider.getCredentials(); + + expect(first.accessKeyId).toBe('AKID_1'); + expect(second.accessKeyId).toBe('AKID_2'); + expect(sdkProvider).toHaveBeenCalledTimes(2); + }); + }); + + describe('validation errors', () => { + it('throws when no credentials and IAM disabled', () => { + expect(() => + createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + }), + ).toThrow('No AWS credentials available'); + }); + + it('throws when called with no options', () => { + expect(() => createDefaultCredentialProvider()).toThrow('No AWS credentials available'); + }); + + it('throws when accessKeyId is an empty string', () => { + expect(() => + createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { + accessKeyId: '', + secretAccessKey: 'SECRET', + }, + }), + ).toThrow('No AWS credentials available'); + }); + + it('throws when accessKeyId is undefined', () => { + expect(() => + createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { + accessKeyId: undefined, + secretAccessKey: 'SECRET', + }, + }), + ).toThrow('No AWS credentials available'); + }); + + it('throws when secretAccessKey is an empty string', () => { + expect(() => + createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { + accessKeyId: 'AKID', + secretAccessKey: '', + }, + }), + ).toThrow('No AWS credentials available'); + }); + }); + + describe('error propagation', () => { + it('propagates SDK provider rejection', async () => { + const sdkProvider = vi.fn().mockRejectedValue(new Error('IMDS timeout')); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + + const provider = createDefaultCredentialProvider({ awsIamAuthEnabled: true }); + + await expect(provider.getCredentials()).rejects.toThrow('IMDS timeout'); + }); + }); + + describe('logging', () => { + it('logs info when using IAM auth', () => { + const logger = createMockLogger(); + const sdkProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'X', + secretAccessKey: 'Y', + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + + createDefaultCredentialProvider({ awsIamAuthEnabled: true, logger }); + + expect(logger.info).toHaveBeenCalledWith( + 'Using AWS SDK default credential chain (IAM auth enabled)', + ); + }); + + it('logs info when using static credentials', () => { + const logger = createMockLogger(); + createDefaultCredentialProvider({ + awsIamAuthEnabled: false, + staticCredentials: { accessKeyId: 'A', secretAccessKey: 'B' }, + logger, + }); + + expect(logger.info).toHaveBeenCalledWith('Using static AWS credentials'); + }); + + it('logs debug with expiration when SDK returns one', async () => { + const expiration = new Date('2026-06-01T00:00:00Z'); + const sdkProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'X', + secretAccessKey: 'Y', + sessionToken: 'Z', + expiration, + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + const logger = createMockLogger(); + + const provider = createDefaultCredentialProvider({ awsIamAuthEnabled: true, logger }); + await provider.getCredentials(); + + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('2026-06-01T00:00:00.000Z'), + ); + }); + + it('logs debug without expiration when SDK omits it', async () => { + const sdkProvider = vi.fn().mockResolvedValue({ + accessKeyId: 'X', + secretAccessKey: 'Y', + }); + mockFromNodeProviderChain.mockReturnValue(sdkProvider); + const logger = createMockLogger(); + + const provider = createDefaultCredentialProvider({ awsIamAuthEnabled: true, logger }); + await provider.getCredentials(); + + expect(logger.debug).toHaveBeenCalledWith('Credentials resolved'); + }); + }); +}); + +describe('AwsClient signing', () => { + describe('happy path', () => { + it('resolves credentials from the provider and signs the request', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'TEST_AKID', + secretAccessKey: 'TEST_SECRET', + sessionToken: undefined, + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const signed = await (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }); + + expect(mockProvider.getCredentials).toHaveBeenCalledOnce(); + expect(signed.init.headers['authorization']).toContain('TEST_AKID'); + expect(signed.init.headers['authorization']).toContain('AWS4-HMAC-SHA256'); + }); + + it('fetches fresh credentials on each sign() call', async () => { + let callCount = 0; + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockImplementation(async () => { + callCount++; + return { + accessKeyId: `KEY_${callCount}`, + secretAccessKey: 'SECRET', + }; + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const signed1 = await (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }); + const signed2 = await (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'PUT', + headers: {}, + body: 'artifact-content', + }); + + expect(signed1.init.headers['authorization']).toContain('KEY_1'); + expect(signed2.init.headers['authorization']).toContain('KEY_2'); + }); + + it('includes x-amz-security-token for temporary credentials', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'MY_SESSION_TOKEN', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const signed = await (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }); + + expect(signed.init.headers['x-amz-security-token']).toBe('MY_SESSION_TOKEN'); + }); + + it('sets x-amz-content-sha256 to UNSIGNED-PAYLOAD', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const signed = await (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'PUT', + headers: {}, + body: 'artifact-data', + }); + + expect(signed.init.headers['x-amz-content-sha256']).toBe('UNSIGNED-PAYLOAD'); + }); + + it('produces correct credential scope with the configured region', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'eu-west-1', + }); + + const signed = await (client as any).sign('https://s3.eu-west-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }); + + expect(signed.init.headers['authorization']).toContain('eu-west-1/s3/aws4_request'); + }); + }); + + describe('error handling', () => { + it('throws when provider returns null accessKeyId', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: null, + secretAccessKey: 'SECRET', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }), + ).rejects.toThrow('accessKeyId is a required option'); + }); + + it('throws when provider returns null secretAccessKey', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: null, + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }), + ).rejects.toThrow('secretAccessKey is a required option'); + }); + + it('propagates credential provider rejection', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockRejectedValue(new Error('Pod Identity unavailable')), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + (client as any).sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + headers: {}, + }), + ).rejects.toThrow('Pod Identity unavailable'); + }); + }); +}); diff --git a/packages/services/cdn-worker/src/aws.ts b/packages/services/cdn-worker/src/aws.ts index 963749bde42..144075948e0 100644 --- a/packages/services/cdn-worker/src/aws.ts +++ b/packages/services/cdn-worker/src/aws.ts @@ -81,23 +81,55 @@ type AwsRequestInit = RequestInit & { isResponseOk?: (response: Response) => boolean; }; -export type AWSClientConfig = { +/** + * AWS credentials resolved by a credential provider. + * + * NOTE: This interface (and {@link AwsCredentialProvider}) are also defined in + * `@hive/api/modules/cdn/providers/aws`. The duplication exists because + * cdn-worker targets Cloudflare Workers and cannot import from hive/api (which pulls in + * Node.js-only dependencies). TypeScript structural typing keeps them compatible. + * If a shared lightweight package is introduced in the future, both definitions could be + * consolidated there. + */ +export interface AwsCredentials { accessKeyId: string; secretAccessKey: string; sessionToken?: string; - service?: string; - region?: string; - cache?: Map; - retries?: number; - initRetryMs?: number; - /* fetch implementation */ - fetch?: typeof fetch; -}; +} + +/** + * A provider that returns AWS credentials, potentially fetching them + * from an external source (IAM role, ECS task role, EKS Pod Identity, etc.). + * For Node.js servers, use createDefaultCredentialProvider from @hive/api. + */ +export interface AwsCredentialProvider { + getCredentials(): Promise; +} + +export type AWSClientConfig = + | { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + service?: string; + region?: string; + cache?: Map; + retries?: number; + initRetryMs?: number; + fetch?: typeof fetch; + } + | { + credentialProvider: AwsCredentialProvider; + service?: string; + region?: string; + cache?: Map; + retries?: number; + initRetryMs?: number; + fetch?: typeof fetch; + }; export class AwsClient { - private secretAccessKey: string; - private accessKeyId: string; - private sessionToken?: string; + private credentialProvider: AwsCredentialProvider; private service?: string; private region?: string; private cache: Map; @@ -106,9 +138,16 @@ export class AwsClient { private _fetch: typeof fetch; constructor(args: AWSClientConfig) { - this.accessKeyId = args.accessKeyId; - this.secretAccessKey = args.secretAccessKey; - this.sessionToken = args.sessionToken; + if ('credentialProvider' in args) { + this.credentialProvider = args.credentialProvider; + } else { + const credentials: AwsCredentials = { + accessKeyId: args.accessKeyId, + secretAccessKey: args.secretAccessKey, + sessionToken: args.sessionToken, + }; + this.credentialProvider = { getCredentials: async () => credentials }; + } this.service = args.service; this.region = args.region; this.cache = args.cache || new Map(); @@ -136,8 +175,17 @@ export class AwsClient { } input = url; } - const signer = new AwsV4Signer(Object.assign({ url: input }, init, this, init && init.aws)); - + const signer = new AwsV4Signer( + Object.assign( + { url: input }, + init, + // Resolve credentials dynamically (static credentials or EKS Pod Identity if cdn is run on server) + await this.credentialProvider.getCredentials(), + { service: this.service, region: this.region, cache: this.cache }, + init && init.aws, + ), + ); + const signals: AbortSignal[] = []; if (init?.timeout) { diff --git a/packages/services/cdn-worker/tests/s3-iam.spec.ts b/packages/services/cdn-worker/tests/s3-iam.spec.ts new file mode 100644 index 00000000000..e9e4b58fe8a --- /dev/null +++ b/packages/services/cdn-worker/tests/s3-iam.spec.ts @@ -0,0 +1,200 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { describe, expect, test, vi } from 'vitest'; +import { AwsClient, type AwsCredentialProvider } from '../src/aws'; + +describe('S3 IAM AwsClient (cdn-worker)', () => { + describe('constructor', () => { + test('wraps static S3 credentials in a credential provider', async () => { + const client = new AwsClient({ + accessKeyId: 'STATIC_AKID', + secretAccessKey: 'STATIC_SECRET', + sessionToken: 'STATIC_TOKEN', + service: 's3', + region: 'us-east-1', + }); + + const provider = (client as any).credentialProvider as AwsCredentialProvider; + const creds = await provider.getCredentials(); + + expect(creds).toEqual({ + accessKeyId: 'STATIC_AKID', + secretAccessKey: 'STATIC_SECRET', + sessionToken: 'STATIC_TOKEN', + }); + }); + + test('uses credentialProvider directly when provided', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'IAM_AKID', + secretAccessKey: 'IAM_SECRET', + sessionToken: 'IAM_TOKEN', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const provider = (client as any).credentialProvider as AwsCredentialProvider; + expect(provider).toBe(mockProvider); + + const creds = await provider.getCredentials(); + expect(creds.accessKeyId).toBe('IAM_AKID'); + }); + + test('static creds without sessionToken omits it', async () => { + const client = new AwsClient({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + service: 's3', + region: 'us-east-1', + }); + + const provider = (client as any).credentialProvider as AwsCredentialProvider; + const creds = await provider.getCredentials(); + + expect(creds.accessKeyId).toBe('AKID'); + expect(creds.secretAccessKey).toBe('SECRET'); + expect(creds.sessionToken).toBeUndefined(); + }); + }); + + describe('sign()', () => { + test('resolves credentials from provider before signing S3 requests', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'SIGN_AKID', + secretAccessKey: 'SIGN_SECRET', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const [url, signed] = await client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + }); + + expect(mockProvider.getCredentials).toHaveBeenCalledOnce(); + expect(url).toBe('https://s3.us-east-1.amazonaws.com/bucket/key'); + const authHeader = (signed.headers as Headers).get('authorization'); + expect(authHeader).toContain('SIGN_AKID'); + expect(authHeader).toContain('AWS4-HMAC-SHA256'); + }); + + test('uses refreshed credentials on successive S3 calls', async () => { + let callCount = 0; + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockImplementation(async () => { + callCount++; + return { + accessKeyId: `KEY_${callCount}`, + secretAccessKey: 'SECRET', + }; + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const [, signed1] = await client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + }); + const [, signed2] = await client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + }); + + const auth1 = (signed1.headers as Headers).get('authorization'); + const auth2 = (signed2.headers as Headers).get('authorization'); + + expect(auth1).toContain('KEY_1'); + expect(auth2).toContain('KEY_2'); + }); + + test('includes x-amz-security-token for IAM temporary credentials', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: 'SECRET', + sessionToken: 'SESSION_TOK', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + const [, signed] = await client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { + method: 'GET', + }); + + const tokenHeader = (signed.headers as Headers).get('x-amz-security-token'); + expect(tokenHeader).toBe('SESSION_TOK'); + }); + + test('throws when provider returns credentials missing accessKeyId', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: null, + secretAccessKey: 'SECRET', + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { method: 'GET' }), + ).rejects.toThrow('accessKeyId is a required option'); + }); + + test('throws when provider returns credentials missing secretAccessKey', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockResolvedValue({ + accessKeyId: 'AKID', + secretAccessKey: null, + }), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { method: 'GET' }), + ).rejects.toThrow('secretAccessKey is a required option'); + }); + + test('rejects when credential provider throws', async () => { + const mockProvider: AwsCredentialProvider = { + getCredentials: vi.fn().mockRejectedValue(new Error('Pod Identity unavailable')), + }; + + const client = new AwsClient({ + credentialProvider: mockProvider, + service: 's3', + region: 'us-east-1', + }); + + await expect( + client.sign('https://s3.us-east-1.amazonaws.com/bucket/key', { method: 'GET' }), + ).rejects.toThrow('Pod Identity unavailable'); + }); + }); +}); diff --git a/packages/services/server/README.md b/packages/services/server/README.md index 46f57000e57..9065b158417 100644 --- a/packages/services/server/README.md +++ b/packages/services/server/README.md @@ -36,17 +36,19 @@ The GraphQL API for GraphQL Hive. | `REDIS_AWS_REGION` | No | AWS region for IAM auth. Falls back to `AWS_REGION`. | `us-east-1` | | `REDIS_AWS_IAM_CACHE_NAME` | No | ElastiCache cache name (required if `REDIS_AWS_IAM_AUTH_ENABLED` is `1`). | `my-cache` | | `S3_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | -| `S3_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | -| `S3_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | +| `S3_ACCESS_KEY_ID` | **Yes** (not required if `S3_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 access key id. | `minioadmin` | +| `S3_SECRET_ACCESS_KEY` | **Yes** (not required if `S3_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 secret access key. | `minioadmin` | | `S3_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | | `S3_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | +| `S3_AWS_IAM_AUTH_ENABLED` | No | Whether to use AWS IAM auth (e.g. Pod Identity) instead of static credentials. Requires `AWS_REGION`. | `1` (enabled) or `0` (disabled) | | `S3_MIRROR` | No | Whether S3 mirror is enabled | `1` (enabled) or `0` (disabled) | | `S3_MIRROR_ENDPOINT` | **Yes** | The S3 endpoint. | `http://localhost:9000` | -| `S3_MIRROR_ACCESS_KEY_ID` | **Yes** | The S3 access key id. | `minioadmin` | -| `S3_MIRROR_SECRET_ACCESS_KEY` | **Yes** | The S3 secret access key. | `minioadmin` | +| `S3_MIRROR_ACCESS_KEY_ID` | **Yes** (not required if `S3_MIRROR_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 access key id. | `minioadmin` | +| `S3_MIRROR_SECRET_ACCESS_KEY` | **Yes** (not required if `S3_MIRROR_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 secret access key. | `minioadmin` | | `S3_MIRROR_BUCKET_NAME` | **Yes** | The S3 bucket name. | `artifacts` | | `S3_MIRROR_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | | `S3_MIRROR_PUBLIC_URL` | No | The public URL of the S3, in case it differs from the `S3_ENDPOINT`. | `http://localhost:8083` | +| `S3_MIRROR_AWS_IAM_AUTH_ENABLED` | No | Whether to use AWS IAM auth for the S3 mirror instead of static credentials. Requires `AWS_REGION`. | `1` (enabled) or `0` (disabled) | | `CDN_API` | No | Whether the CDN exposed via API is enabled. | `1` (enabled) or `0` (disabled) | | `CDN_API_BASE_URL` | No (Yes if `CDN_API` is set to `1`) | The public base url of the API service. | `http://localhost:8082` | | `CDN_API_KV_BASE_URL` | No (**Optional** if `CDN_API` is set to `1`) | The base URL for the KV for API Provider. Used for scenarios where we cache CDN access. | `https://key-cache.graphql-hive.com` | @@ -74,11 +76,12 @@ The GraphQL API for GraphQL Hive. | `FEATURE_FLAGS_SCHEMA_PROPOSALS_ENABLED` | No | Whether schema proposals should be enabled for every organization. | `1` (enabled) or `0` (disabled) | | `S3_AUDIT_LOG` | No (audit log uses default S3 if not configured) | Whether audit logs should be stored on another S3 bucket than the artifacts. | `1` (enabled) or `0` (disabled) | | `S3_AUDIT_LOG_ENDPOINT` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 endpoint. | `http://localhost:9000` | -| `S3_AUDIT_LOG_ACCESS_KEY_ID` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 access key id. | `minioadmin` | -| `S3_AUDIT_LOG_SECRET_ACCESS_KEY` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 secret access key. | `minioadmin` | +| `S3_AUDIT_LOG_ACCESS_KEY_ID` | **Yes** (if `S3_AUDIT_LOG` is `1`, not required if `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 access key id. | `minioadmin` | +| `S3_AUDIT_LOG_SECRET_ACCESS_KEY` | **Yes** (if `S3_AUDIT_LOG` is `1`, not required if `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED` is `1`) | The S3 secret access key. | `minioadmin` | | `S3_AUDIT_LOG_BUCKET_NAME` | **Yes** (if `S3_AUDIT_LOG` is `1`) | The S3 bucket name. | `artifacts` | | `S3_AUDIT_LOG_SESSION_TOKEN` | No | The S3 session token. | `dummytoken` | | `S3_AUDIT_LOG_PUBLIC_URL` | No | The public URL of the S3, in case it differs from the `S3_ENDPOINT`. | `http://localhost:8083` | +| `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED` | No | Whether to use AWS IAM auth for audit log S3 instead of static credentials. Requires `AWS_REGION`. | `1` (enabled) or `0` (disabled) | | `ENVIRONMENT` | No | The environment of your Hive app. (**Note:** This will be used for Sentry reporting.) | `staging` | | `SENTRY` | No | Whether Sentry error reporting should be enabled. | `1` (enabled) or `0` (disabled) | | `SENTRY_DSN` | No | The DSN for reporting errors to Sentry. | `https://dooobars@o557896.ingest.sentry.io/12121212` | diff --git a/packages/services/server/src/environment.ts b/packages/services/server/src/environment.ts index 7277bcc0ebd..400865b4fbf 100644 --- a/packages/services/server/src/environment.ts +++ b/packages/services/server/src/environment.ts @@ -190,10 +190,11 @@ const PrometheusModel = zod.object({ const S3Model = zod.object({ S3_ENDPOINT: zod.string().url(), - S3_ACCESS_KEY_ID: zod.string(), - S3_SECRET_ACCESS_KEY: zod.string(), + S3_ACCESS_KEY_ID: emptyString(zod.string().optional()), + S3_SECRET_ACCESS_KEY: emptyString(zod.string().optional()), S3_SESSION_TOKEN: emptyString(zod.string().optional()), S3_BUCKET_NAME: zod.string(), + S3_AWS_IAM_AUTH_ENABLED: emptyString(zod.union([zod.literal('0'), zod.literal('1')]).optional()), }); const S3MirrorModel = zod.union([ @@ -203,11 +204,14 @@ const S3MirrorModel = zod.union([ zod.object({ S3_MIRROR: zod.literal('1'), S3_MIRROR_ENDPOINT: zod.string().url(), - S3_MIRROR_ACCESS_KEY_ID: zod.string(), - S3_MIRROR_SECRET_ACCESS_KEY: zod.string(), + S3_MIRROR_ACCESS_KEY_ID: emptyString(zod.string().optional()), + S3_MIRROR_SECRET_ACCESS_KEY: emptyString(zod.string().optional()), S3_MIRROR_SESSION_TOKEN: emptyString(zod.string().optional()), S3_MIRROR_BUCKET_NAME: zod.string(), S3_MIRROR_PUBLIC_URL: emptyString(zod.string().url().optional()), + S3_MIRROR_AWS_IAM_AUTH_ENABLED: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ), }), ]); @@ -218,11 +222,14 @@ const S3AuditLogModel = zod.union([ zod.object({ S3_AUDIT_LOG: zod.literal('1'), S3_AUDIT_LOG_ENDPOINT: zod.string().url(), - S3_AUDIT_LOG_ACCESS_KEY_ID: zod.string(), - S3_AUDIT_LOG_SECRET_ACCESS_KEY: zod.string(), + S3_AUDIT_LOG_ACCESS_KEY_ID: emptyString(zod.string().optional()), + S3_AUDIT_LOG_SECRET_ACCESS_KEY: emptyString(zod.string().optional()), S3_AUDIT_LOG_SESSION_TOKEN: emptyString(zod.string().optional()), S3_AUDIT_LOG_BUCKET_NAME: zod.string(), S3_AUDIT_LOG_PUBLIC_URL: emptyString(zod.string().url().optional()), + S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED: emptyString( + zod.union([zod.literal('0'), zod.literal('1')]).optional(), + ), }), ]); @@ -340,6 +347,51 @@ if (redisConfigResult.type === 'error') { environmentErrors.push(...redisConfigResult.errors); } +if (configs.s3.success && configs.s3.data.S3_AWS_IAM_AUTH_ENABLED !== '1') { + const missingS3Vars: string[] = []; + if (!configs.s3.data.S3_ACCESS_KEY_ID) missingS3Vars.push('S3_ACCESS_KEY_ID'); + if (!configs.s3.data.S3_SECRET_ACCESS_KEY) missingS3Vars.push('S3_SECRET_ACCESS_KEY'); + if (missingS3Vars.length > 0) { + environmentErrors.push( + `S3_AWS_IAM_AUTH_ENABLED is not enabled so static credentials are required: ${missingS3Vars.join(', ')}`, + ); + } +} + +if ( + configs.s3Mirror.success && + configs.s3Mirror.data.S3_MIRROR === '1' && + configs.s3Mirror.data.S3_MIRROR_AWS_IAM_AUTH_ENABLED !== '1' +) { + const missingS3MirrorVars: string[] = []; + if (!configs.s3Mirror.data.S3_MIRROR_ACCESS_KEY_ID) + missingS3MirrorVars.push('S3_MIRROR_ACCESS_KEY_ID'); + if (!configs.s3Mirror.data.S3_MIRROR_SECRET_ACCESS_KEY) + missingS3MirrorVars.push('S3_MIRROR_SECRET_ACCESS_KEY'); + if (missingS3MirrorVars.length > 0) { + environmentErrors.push( + `S3_MIRROR_AWS_IAM_AUTH_ENABLED is not enabled so static credentials are required: ${missingS3MirrorVars.join(', ')}`, + ); + } +} + +if ( + configs.s3AuditLog.success && + configs.s3AuditLog.data.S3_AUDIT_LOG === '1' && + configs.s3AuditLog.data.S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED !== '1' +) { + const missingS3AuditVars: string[] = []; + if (!configs.s3AuditLog.data.S3_AUDIT_LOG_ACCESS_KEY_ID) + missingS3AuditVars.push('S3_AUDIT_LOG_ACCESS_KEY_ID'); + if (!configs.s3AuditLog.data.S3_AUDIT_LOG_SECRET_ACCESS_KEY) + missingS3AuditVars.push('S3_AUDIT_LOG_SECRET_ACCESS_KEY'); + if (missingS3AuditVars.length > 0) { + environmentErrors.push( + `S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED is not enabled so static credentials are required: ${missingS3AuditVars.join(', ')}`, + ); + } +} + if (environmentErrors.length) { const fullError = environmentErrors.join(`\n`); console.error('❌ Invalid environment variables:', fullError); @@ -531,6 +583,7 @@ export const env = { s3: { bucketName: s3.S3_BUCKET_NAME, endpoint: s3.S3_ENDPOINT, + awsIamAuthEnabled: s3.S3_AWS_IAM_AUTH_ENABLED === '1', credentials: { accessKeyId: s3.S3_ACCESS_KEY_ID, secretAccessKey: s3.S3_SECRET_ACCESS_KEY, @@ -543,6 +596,7 @@ export const env = { bucketName: s3Mirror.S3_MIRROR_BUCKET_NAME, endpoint: s3Mirror.S3_MIRROR_ENDPOINT, publicUrl: s3Mirror.S3_MIRROR_PUBLIC_URL ?? null, + awsIamAuthEnabled: s3Mirror.S3_MIRROR_AWS_IAM_AUTH_ENABLED === '1', credentials: { accessKeyId: s3Mirror.S3_MIRROR_ACCESS_KEY_ID, secretAccessKey: s3Mirror.S3_MIRROR_SECRET_ACCESS_KEY, @@ -556,6 +610,7 @@ export const env = { bucketName: s3AuditLog.S3_AUDIT_LOG_BUCKET_NAME, endpoint: s3AuditLog.S3_AUDIT_LOG_ENDPOINT, publicUrl: s3AuditLog.S3_AUDIT_LOG_PUBLIC_URL ?? null, + awsIamAuthEnabled: s3AuditLog.S3_AUDIT_LOG_AWS_IAM_AUTH_ENABLED === '1', credentials: { accessKeyId: s3AuditLog.S3_AUDIT_LOG_ACCESS_KEY_ID, secretAccessKey: s3AuditLog.S3_AUDIT_LOG_SECRET_ACCESS_KEY, diff --git a/packages/services/server/src/index.ts b/packages/services/server/src/index.ts index 6433d36846b..dda94e6e083 100644 --- a/packages/services/server/src/index.ts +++ b/packages/services/server/src/index.ts @@ -16,6 +16,7 @@ import { import { AccessTokenKeyContainer } from '@hive/api/modules/auth/lib/supertokens-at-home/crypto'; import { EmailVerification } from '@hive/api/modules/auth/providers/email-verification'; import { OAuthCache } from '@hive/api/modules/auth/providers/oauth-cache'; +import { createDefaultCredentialProvider } from '@hive/api/modules/cdn/providers/aws'; import { OIDCIntegrationStore } from '@hive/api/modules/oidc-integrations/providers/oidc-integration.store'; import { RedisRateLimiter } from '@hive/api/modules/shared/providers/redis-rate-limiter'; import { TargetsByIdCache } from '@hive/api/modules/target/providers/targets-by-id-cache'; @@ -302,26 +303,35 @@ export async function main() { }, cdn: env.cdn, s3: { - accessKeyId: env.s3.credentials.accessKeyId, - secretAccessKeyId: env.s3.credentials.secretAccessKey, - sessionToken: env.s3.credentials.sessionToken, + credentialProvider: createDefaultCredentialProvider({ + label: 's3', + logger, + staticCredentials: env.s3.credentials, + awsIamAuthEnabled: env.s3.awsIamAuthEnabled, + }), bucketName: env.s3.bucketName, endpoint: env.s3.endpoint, }, s3Mirror: env.s3Mirror ? { - accessKeyId: env.s3Mirror.credentials.accessKeyId, - secretAccessKeyId: env.s3Mirror.credentials.secretAccessKey, - sessionToken: env.s3Mirror.credentials.sessionToken, + credentialProvider: createDefaultCredentialProvider({ + label: 's3mirror', + logger, + staticCredentials: env.s3Mirror.credentials, + awsIamAuthEnabled: env.s3Mirror.awsIamAuthEnabled, + }), bucketName: env.s3Mirror.bucketName, endpoint: env.s3Mirror.endpoint, } : null, s3AuditLogs: env.s3AuditLogs ? { - accessKeyId: env.s3AuditLogs.credentials.accessKeyId, - secretAccessKeyId: env.s3AuditLogs.credentials.secretAccessKey, - sessionToken: env.s3AuditLogs.credentials.sessionToken, + credentialProvider: createDefaultCredentialProvider({ + label: 's3audit', + logger, + staticCredentials: env.s3AuditLogs.credentials, + awsIamAuthEnabled: env.s3AuditLogs.awsIamAuthEnabled, + }), bucketName: env.s3AuditLogs.bucketName, endpoint: env.s3AuditLogs.endpoint, } @@ -536,8 +546,12 @@ export async function main() { if (env.cdn.providers.api !== null) { const s3 = { client: new AwsClient({ - accessKeyId: env.s3.credentials.accessKeyId, - secretAccessKey: env.s3.credentials.secretAccessKey, + credentialProvider: createDefaultCredentialProvider({ + label: 'cdn-s3', + logger, + staticCredentials: env.s3.credentials, + awsIamAuthEnabled: env.s3.awsIamAuthEnabled, + }), service: 's3', }), endpoint: env.s3.endpoint, @@ -547,8 +561,12 @@ export async function main() { const s3Mirror = env.s3Mirror ? { client: new AwsClient({ - accessKeyId: env.s3Mirror.credentials.accessKeyId, - secretAccessKey: env.s3Mirror.credentials.secretAccessKey, + credentialProvider: createDefaultCredentialProvider({ + label: 'cdn-s3mirror', + logger, + staticCredentials: env.s3Mirror.credentials, + awsIamAuthEnabled: env.s3Mirror.awsIamAuthEnabled, + }), service: 's3', }), endpoint: env.s3Mirror.endpoint, diff --git a/packages/services/server/src/persisted-documents-worker.ts b/packages/services/server/src/persisted-documents-worker.ts index 1241eb5fc1a..e1fac3ddfaf 100644 --- a/packages/services/server/src/persisted-documents-worker.ts +++ b/packages/services/server/src/persisted-documents-worker.ts @@ -1,6 +1,8 @@ import 'reflect-metadata'; import { parentPort } from 'node:worker_threads'; import { createWorker } from '../../api/src/modules/app-deployments/worker/persisted-documents-worker'; +import type { AwsCredentials } from '../../api/src/modules/cdn/providers/aws'; +import { createDefaultCredentialProvider } from '../../api/src/modules/cdn/providers/aws'; import { createMessagePortLogger } from '../../api/src/modules/shared/providers/logger'; import { env } from './environment'; @@ -8,8 +10,38 @@ if (!parentPort) { throw new Error('This script must be run as a worker.'); } -createWorker(parentPort, createMessagePortLogger(parentPort), { +const logger = createMessagePortLogger(parentPort); + +function buildS3Config( + label: string, + s3: { + bucketName: string; + endpoint: string; + awsIamAuthEnabled: boolean; + credentials: { + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken: string | undefined; + }; + }, +) { + return { + credentialProvider: createDefaultCredentialProvider({ + label, + logger, + staticCredentials: + s3.credentials.accessKeyId && s3.credentials.secretAccessKey + ? (s3.credentials as AwsCredentials) + : undefined, + awsIamAuthEnabled: s3.awsIamAuthEnabled, + }), + bucketName: s3.bucketName, + endpoint: s3.endpoint, + }; +} + +createWorker(parentPort, logger, { clickhouse: env.clickhouse, - s3: env.s3, - s3Mirror: env.s3Mirror, + s3: buildS3Config('worker-s3', env.s3), + s3Mirror: env.s3Mirror ? buildS3Config('worker-s3mirror', env.s3Mirror) : null, }); From 415100f6e10b313ee59c9946f1d06de5510b8634 Mon Sep 17 00:00:00 2001 From: "Song, Michelle" <268042956+mish-elle@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:33:37 -0400 Subject: [PATCH 2/3] address pr comments --- packages/services/api/src/create.ts | 8 ++--- .../worker/persisted-documents-worker.ts | 6 ++-- .../providers/audit-logs-manager.spec.ts | 6 ++-- .../api/src/modules/cdn/providers/aws.ts | 35 ++++++++++--------- .../src/modules/cdn/providers/s3-iam.spec.ts | 18 +++++----- packages/services/cdn-worker/src/aws.ts | 12 +++---- .../services/cdn-worker/tests/s3-iam.spec.ts | 22 ++++++------ 7 files changed, 53 insertions(+), 54 deletions(-) diff --git a/packages/services/api/src/create.ts b/packages/services/api/src/create.ts index 2aafb41b79f..bfb64cb1147 100644 --- a/packages/services/api/src/create.ts +++ b/packages/services/api/src/create.ts @@ -13,7 +13,7 @@ import { authModule } from './modules/auth'; import { Session } from './modules/auth/lib/authz'; import { cdnModule } from './modules/cdn'; import { AwsClient } from './modules/cdn/providers/aws'; -import type { AwsCredentialProvider } from './modules/cdn/providers/aws'; +import type { S3CredentialProvider } from './modules/cdn/providers/aws'; import { CDN_CONFIG, CDNConfig } from './modules/cdn/providers/tokens'; import { collectionModule } from './modules/collection'; import { commerceModule } from './modules/commerce'; @@ -138,17 +138,17 @@ export function createRegistry({ s3: { bucketName: string; endpoint: string; - credentialProvider: AwsCredentialProvider; + credentialProvider: S3CredentialProvider; }; s3Mirror: { bucketName: string; endpoint: string; - credentialProvider: AwsCredentialProvider; + credentialProvider: S3CredentialProvider; } | null; s3AuditLogs: { bucketName: string; endpoint: string; - credentialProvider: AwsCredentialProvider; + credentialProvider: S3CredentialProvider; } | null; encryptionSecret: string; app: { diff --git a/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts b/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts index 39fcd8bff90..051c002cdc1 100644 --- a/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts +++ b/packages/services/api/src/modules/app-deployments/worker/persisted-documents-worker.ts @@ -1,6 +1,6 @@ import { type MessagePort } from 'node:worker_threads'; import { AwsClient } from '../../cdn/providers/aws'; -import type { AwsCredentialProvider } from '../../cdn/providers/aws'; +import type { S3CredentialProvider } from '../../cdn/providers/aws'; import { ClickHouse } from '../../operations/providers/clickhouse-client'; import { HttpClient } from '../../shared/providers/http-client'; import { Logger } from '../../shared/providers/logger'; @@ -22,12 +22,12 @@ export function createWorker( s3: { readonly bucketName: string; readonly endpoint: string; - readonly credentialProvider: AwsCredentialProvider; + readonly credentialProvider: S3CredentialProvider; }; s3Mirror: { readonly bucketName: string; readonly endpoint: string; - readonly credentialProvider: AwsCredentialProvider; + readonly credentialProvider: S3CredentialProvider; } | null; clickhouse: { readonly host: string; diff --git a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts index 93e86dc65ce..dd49ad9b70c 100644 --- a/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts +++ b/packages/services/api/src/modules/audit-logs/providers/audit-logs-manager.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { AwsClient, type AwsCredentialProvider } from '../../cdn/providers/aws'; +import { AwsClient, type S3CredentialProvider } from '../../cdn/providers/aws'; import { AuditLogManager, AuditLogS3Config } from './audit-logs-manager'; vi.mock('@hive/workflows/kit', () => ({ TaskScheduler: class {} })); @@ -16,7 +16,7 @@ vi.mock('./audit-logs-types', () => ({ AuditLogClickhouseArrayModel: { parse: (v: any) => v }, })); -function fakeCredentialProvider(keyId: string): AwsCredentialProvider { +function fakeCredentialProvider(keyId: string): S3CredentialProvider { return { getCredentials: async () => ({ accessKeyId: keyId, secretAccessKey: `${keyId}-secret` }), }; @@ -71,7 +71,7 @@ function createAuditLogManager(fetchImpl: (...args: any[]) => any) { /** * Guards against regression where the S3 PUT upload was missing * `aws: { signQuery: true }`, causing the `got` library to receive - * auth headers it can't handle — resulting in silent upload failures. + * auth headers it can't handle - resulting in silent upload failures. */ describe('AuditLogManager S3 upload uses signQuery', () => { it('PUT upload includes aws.signQuery: true', async () => { diff --git a/packages/services/api/src/modules/cdn/providers/aws.ts b/packages/services/api/src/modules/cdn/providers/aws.ts index e05773b739f..e3d96d9d6c1 100644 --- a/packages/services/api/src/modules/cdn/providers/aws.ts +++ b/packages/services/api/src/modules/cdn/providers/aws.ts @@ -7,7 +7,7 @@ import { fromNodeProviderChain } from '@aws-sdk/credential-providers'; * Uses @aws-sdk/credential-providers for all credential resolution (static, * container metadata, EKS Pod Identity, IMDS, env vars, etc.) so that upstream * changes are handled by the SDK automatically. - * The SDK is only used for credential resolution — request signing uses the + * The SDK is only used for credential resolution - request signing uses the * custom AwsV4Signer below. */ @@ -31,7 +31,7 @@ export interface AwsCredentials { * NOTE: This interface is also defined (structurally) in the cdn-worker package, * which targets Cloudflare Workers and cannot import from the AWS SDK. */ -export interface AwsCredentialProvider { +export interface S3CredentialProvider { getCredentials(): Promise; } @@ -39,18 +39,18 @@ export interface AwsCredentialProvider { * Options for creating a credential provider via the default chain. */ export interface DefaultCredentialChainOptions { - /** Static credentials as fallback when IAM auth is unavailable or disabled. */ + /** + * Default static S3 credentials for manual key/secret configuration. + * Provide both `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` + */ staticCredentials?: { accessKeyId: string | undefined; secretAccessKey: string | undefined; sessionToken?: string | undefined; }; /** - * Whether IAM auth is enabled. Derived from `AWS_REGION` being set and the - * per-service `*_AWS_IAM_AUTH_ENABLED` flag being `'1'`. - * - * When `true`, the SDK credential chain is used (IAM wins over static creds). - * When `false`, only static credentials are accepted — throws if missing. + * When `true`, the SDK credential chain is used and static credentials are ignored. + * When `false`, static credentials are required and must include both `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`. */ awsIamAuthEnabled?: boolean; /** Optional label to prefix log messages, e.g. 's3', 's3mirror', 's3audit'. */ @@ -63,15 +63,16 @@ export interface DefaultCredentialChainOptions { * Creates an AWS credential provider. * * Priority order: - * 1. If IAM auth is enabled → use SDK credential chain (Pod Identity, IRSA, etc.) - * 2. If IAM auth is disabled or unavailable → use static credentials - * 3. If neither → throw + * 1. If IAM auth is enabled, use SDK credential chain (Pod Identity, IRSA, etc.) + * and ignore static credentials. + * 2. If IAM auth is disabled (default), use static credentials. + * 3. If neither, throw. * * The SDK handles credential caching and automatic refresh before expiry. */ export function createDefaultCredentialProvider( options?: DefaultCredentialChainOptions, -): AwsCredentialProvider { +): S3CredentialProvider { const logger = options?.logger; const prefix = options?.label ? `[${options.label}] ` : ''; const awsIamAuthEnabled = options?.awsIamAuthEnabled ?? false; @@ -81,6 +82,7 @@ export function createDefaultCredentialProvider( accessKeyId: string; secretAccessKey: string; sessionToken?: string; + expiration?: Date; }>; if (awsIamAuthEnabled) { @@ -95,8 +97,8 @@ export function createDefaultCredentialProvider( }); } else { throw new Error( - 'No AWS credentials available. Either deploy on AWS with IAM auth enabled, ' + - 'or provide static credentials (e.g. S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY).', + 'No S3 credentials available. Either enable S3 IAM auth, or ' + + 'provide S3 static credentials S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY.', ); } @@ -122,7 +124,6 @@ export function createDefaultCredentialProvider( // Custom SigV4 signer (ported from aws4fetch) // --------------------------------------------------------------------------- - /** * This is a copy of https://github.com/mhart/aws4fetch which is licensed MIT * See https://github.com/mhart/aws4fetch/issues/22 @@ -177,13 +178,13 @@ type AwsRequestInit = { }; export class AwsClient { - private credentialProvider: AwsCredentialProvider; + private credentialProvider: S3CredentialProvider; private service?: string; private region?: string; private cache: Map; constructor(options: { - credentialProvider: AwsCredentialProvider; + credentialProvider: S3CredentialProvider; service?: string; region?: string; cache?: Map; diff --git a/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts b/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts index 293f9b18d14..284392d64c9 100644 --- a/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts +++ b/packages/services/api/src/modules/cdn/providers/s3-iam.spec.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AwsClient, createDefaultCredentialProvider, type AwsCredentialProvider } from './aws'; +import { AwsClient, createDefaultCredentialProvider, type S3CredentialProvider } from './aws'; const { mockFromNodeProviderChain } = vi.hoisted(() => ({ mockFromNodeProviderChain: vi.fn(), @@ -248,7 +248,7 @@ describe('createDefaultCredentialProvider', () => { describe('AwsClient signing', () => { describe('happy path', () => { it('resolves credentials from the provider and signs the request', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'TEST_AKID', secretAccessKey: 'TEST_SECRET', @@ -274,7 +274,7 @@ describe('AwsClient signing', () => { it('fetches fresh credentials on each sign() call', async () => { let callCount = 0; - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockImplementation(async () => { callCount++; return { @@ -305,7 +305,7 @@ describe('AwsClient signing', () => { }); it('includes x-amz-security-token for temporary credentials', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: 'SECRET', @@ -328,7 +328,7 @@ describe('AwsClient signing', () => { }); it('sets x-amz-content-sha256 to UNSIGNED-PAYLOAD', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: 'SECRET', @@ -351,7 +351,7 @@ describe('AwsClient signing', () => { }); it('produces correct credential scope with the configured region', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: 'SECRET', @@ -375,7 +375,7 @@ describe('AwsClient signing', () => { describe('error handling', () => { it('throws when provider returns null accessKeyId', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: null, secretAccessKey: 'SECRET', @@ -397,7 +397,7 @@ describe('AwsClient signing', () => { }); it('throws when provider returns null secretAccessKey', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: null, @@ -419,7 +419,7 @@ describe('AwsClient signing', () => { }); it('propagates credential provider rejection', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockRejectedValue(new Error('Pod Identity unavailable')), }; diff --git a/packages/services/cdn-worker/src/aws.ts b/packages/services/cdn-worker/src/aws.ts index 144075948e0..838e30ce13d 100644 --- a/packages/services/cdn-worker/src/aws.ts +++ b/packages/services/cdn-worker/src/aws.ts @@ -84,12 +84,10 @@ type AwsRequestInit = RequestInit & { /** * AWS credentials resolved by a credential provider. * - * NOTE: This interface (and {@link AwsCredentialProvider}) are also defined in + * NOTE: This interface (and {@link S3CredentialProvider}) are also defined in * `@hive/api/modules/cdn/providers/aws`. The duplication exists because * cdn-worker targets Cloudflare Workers and cannot import from hive/api (which pulls in * Node.js-only dependencies). TypeScript structural typing keeps them compatible. - * If a shared lightweight package is introduced in the future, both definitions could be - * consolidated there. */ export interface AwsCredentials { accessKeyId: string; @@ -102,7 +100,7 @@ export interface AwsCredentials { * from an external source (IAM role, ECS task role, EKS Pod Identity, etc.). * For Node.js servers, use createDefaultCredentialProvider from @hive/api. */ -export interface AwsCredentialProvider { +export interface S3CredentialProvider { getCredentials(): Promise; } @@ -119,7 +117,7 @@ export type AWSClientConfig = fetch?: typeof fetch; } | { - credentialProvider: AwsCredentialProvider; + credentialProvider: S3CredentialProvider; service?: string; region?: string; cache?: Map; @@ -129,7 +127,7 @@ export type AWSClientConfig = }; export class AwsClient { - private credentialProvider: AwsCredentialProvider; + private credentialProvider: S3CredentialProvider; private service?: string; private region?: string; private cache: Map; @@ -185,7 +183,7 @@ export class AwsClient { init && init.aws, ), ); - + const signals: AbortSignal[] = []; if (init?.timeout) { diff --git a/packages/services/cdn-worker/tests/s3-iam.spec.ts b/packages/services/cdn-worker/tests/s3-iam.spec.ts index e9e4b58fe8a..b65ce938ee8 100644 --- a/packages/services/cdn-worker/tests/s3-iam.spec.ts +++ b/packages/services/cdn-worker/tests/s3-iam.spec.ts @@ -1,6 +1,6 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { describe, expect, test, vi } from 'vitest'; -import { AwsClient, type AwsCredentialProvider } from '../src/aws'; +import { AwsClient, type S3CredentialProvider } from '../src/aws'; describe('S3 IAM AwsClient (cdn-worker)', () => { describe('constructor', () => { @@ -13,7 +13,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { region: 'us-east-1', }); - const provider = (client as any).credentialProvider as AwsCredentialProvider; + const provider = (client as any).credentialProvider as S3CredentialProvider; const creds = await provider.getCredentials(); expect(creds).toEqual({ @@ -24,7 +24,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { }); test('uses credentialProvider directly when provided', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'IAM_AKID', secretAccessKey: 'IAM_SECRET', @@ -38,7 +38,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { region: 'us-east-1', }); - const provider = (client as any).credentialProvider as AwsCredentialProvider; + const provider = (client as any).credentialProvider as S3CredentialProvider; expect(provider).toBe(mockProvider); const creds = await provider.getCredentials(); @@ -53,7 +53,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { region: 'us-east-1', }); - const provider = (client as any).credentialProvider as AwsCredentialProvider; + const provider = (client as any).credentialProvider as S3CredentialProvider; const creds = await provider.getCredentials(); expect(creds.accessKeyId).toBe('AKID'); @@ -64,7 +64,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { describe('sign()', () => { test('resolves credentials from provider before signing S3 requests', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'SIGN_AKID', secretAccessKey: 'SIGN_SECRET', @@ -90,7 +90,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { test('uses refreshed credentials on successive S3 calls', async () => { let callCount = 0; - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockImplementation(async () => { callCount++; return { @@ -121,7 +121,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { }); test('includes x-amz-security-token for IAM temporary credentials', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: 'SECRET', @@ -144,7 +144,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { }); test('throws when provider returns credentials missing accessKeyId', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: null, secretAccessKey: 'SECRET', @@ -163,7 +163,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { }); test('throws when provider returns credentials missing secretAccessKey', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockResolvedValue({ accessKeyId: 'AKID', secretAccessKey: null, @@ -182,7 +182,7 @@ describe('S3 IAM AwsClient (cdn-worker)', () => { }); test('rejects when credential provider throws', async () => { - const mockProvider: AwsCredentialProvider = { + const mockProvider: S3CredentialProvider = { getCredentials: vi.fn().mockRejectedValue(new Error('Pod Identity unavailable')), }; From 8d9b58bfece9ef86d8713e40d96aa7eb60216c17 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Sun, 12 Jul 2026 16:22:23 +0900 Subject: [PATCH 3/3] sync lockfile --- pnpm-lock.yaml | 285 ++++++++----------------------------------------- 1 file changed, 43 insertions(+), 242 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db72989934f..7cae33cb0a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1073,6 +1073,9 @@ importers: '@aws-sdk/client-s3': specifier: 3.1035.0 version: 3.1035.0 + '@aws-sdk/credential-providers': + specifier: 3.1035.0 + version: 3.1035.0 '@aws-sdk/s3-request-presigner': specifier: 3.1035.0 version: 3.1035.0 @@ -2846,10 +2849,6 @@ packages: resolution: {integrity: sha512-dHpeqa29a0cBYq/h59IC2EK3AphLY96nKy4F35kBtiz9GuKDc32UYRTgjZaF8uuJCnqgw9omUZKR+9myyDHC2A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.34': - resolution: {integrity: sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.39': resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} engines: {node: '>=20.0.0'} @@ -2862,10 +2861,6 @@ packages: resolution: {integrity: sha512-A+ZTT//Mswkf9DFEM6XlngwOtYdD8X4CUcoZ2wdpgI8cCs9mcGeuhgTwbGJvealub/MeONOaUr3FbRPMKmTDjg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.36': - resolution: {integrity: sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.41': resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} engines: {node: '>=20.0.0'} @@ -2878,10 +2873,6 @@ packages: resolution: {integrity: sha512-MoRc7tLnx3JpFkV2R826enEfBUVN8o9Cc7y3hnbMwiWzL/VJhgfxRQzHkEL9vWorMWP7tibltsRcLoid9fsVdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.38': - resolution: {integrity: sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.43': resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} engines: {node: '>=20.0.0'} @@ -2890,10 +2881,6 @@ packages: resolution: {integrity: sha512-bIRFDf54qIUFFLTZNYt40d6EseNeK9w80dHEs7BVEAWoS23c9+MSqkdg/LJBBK9Kgy01vRmjiedfBZN+jGypLw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.34': - resolution: {integrity: sha512-XVSklkRRQ/CQDmv3VVFdZRl5hTFgncFhZrLyi0Ai4LZk5o3jpY5HIfuTK7ad7tixPKa+iQmL9+vg9qNyYZB+nw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.43': resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} engines: {node: '>=20.0.0'} @@ -2922,10 +2909,6 @@ packages: resolution: {integrity: sha512-McJPomNTSEo+C6UA3Zq6pFrcyTUaVsoPPBOvbOHAoIFPc8Z2CMLndqFJOnB+9bVFiBTWQLutlVGmrocBbvv4MQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.34': - resolution: {integrity: sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.39': resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} engines: {node: '>=20.0.0'} @@ -2938,10 +2921,6 @@ packages: resolution: {integrity: sha512-WngYb2K+/yhkDOmDfAOjoCa9Ja3he0DZiAraboKwgWoVRkajDIcDYBCVbUTxtTUldvQoe7VvHLTrBNxvftN1aQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.38': - resolution: {integrity: sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.43': resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} engines: {node: '>=20.0.0'} @@ -2954,10 +2933,6 @@ packages: resolution: {integrity: sha512-5KLUH+XmSNRj6amJiJSrPsCxU5l/PYDfxyqPa1MxWhHoQC3sxvGPrSib3IE+HQlfRA4e2kO0bnJy7HJdjvpuuA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.38': - resolution: {integrity: sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.43': resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} engines: {node: '>=20.0.0'} @@ -3038,10 +3013,6 @@ packages: resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.2': - resolution: {integrity: sha512-uGGQO08YetrqfInOKG5atRMrCDRQWRuZ9gGfKY6svPmuE4K7ac+XcbCkpWpjcA7yCYsBaKB/Nly4XKgPXUO1PA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.969.0': resolution: {integrity: sha512-scj9OXqKpcjJ4jsFLtqYWz3IaNvNOQTFFvEY8XMJXTv+3qF5I7/x9SJtKzTRJEBF3spjzBUYPtGFbs9sj4fisQ==} engines: {node: '>=20.0.0'} @@ -3070,10 +3041,6 @@ packages: resolution: {integrity: sha512-E6IO3Cn+OzBe6Sb5pnubd5Y8qSUMAsVKkD5QSwFfIx5fV1g5SkYwUDRDyPlm90RuIVcCo28wpMJU6W8wXH46Aw==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1041.0': - resolution: {integrity: sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1052.0': resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} engines: {node: '>=20.0.0'} @@ -9154,10 +9121,6 @@ packages: resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.4': resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} engines: {node: '>=18.0.0'} @@ -9310,10 +9273,6 @@ packages: resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.14': resolution: {integrity: sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==} engines: {node: '>=18.0.0'} @@ -19626,28 +19585,28 @@ snapshots: '@aws-sdk/util-user-agent-node': 3.969.0 '@smithy/config-resolver': 4.4.17 '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.3.17 + '@smithy/fetch-http-handler': 5.4.4 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.7.1 + '@smithy/node-http-handler': 4.7.4 '@smithy/protocol-http': 5.4.3 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.13.1 '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.4.1 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -19704,7 +19663,7 @@ snapshots: '@aws-sdk/xml-builder': 3.969.0 '@smithy/core': 3.24.4 '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.8 + '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.4.3 '@smithy/signature-v4': 5.4.3 '@smithy/smithy-client': 4.12.12 @@ -19771,14 +19730,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.39': dependencies: '@aws-sdk/core': 3.974.13 @@ -19791,11 +19742,11 @@ snapshots: dependencies: '@aws-sdk/core': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 '@smithy/property-provider': 4.2.14 '@smithy/protocol-http': 5.4.3 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.13.1 '@smithy/types': 4.14.2 '@smithy/util-stream': 4.5.24 tslib: 2.8.1 @@ -19813,19 +19764,6 @@ snapshots: '@smithy/util-stream': 4.5.24 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.36': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.7.1 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.4.3 - '@smithy/smithy-client': 4.13.1 - '@smithy/types': 4.14.2 - '@smithy/util-stream': 4.6.1 - tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.41': dependencies: '@aws-sdk/core': 3.974.13 @@ -19847,7 +19785,7 @@ snapshots: '@aws-sdk/credential-provider-web-identity': 3.969.0 '@aws-sdk/nested-clients': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.14 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.2 @@ -19856,25 +19794,6 @@ snapshots: - aws-crt '@aws-sdk/credential-provider-ini@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/credential-provider-env': 3.972.30 - '@aws-sdk/credential-provider-http': 3.972.32 - '@aws-sdk/credential-provider-login': 3.972.34 - '@aws-sdk/credential-provider-process': 3.972.30 - '@aws-sdk/credential-provider-sso': 3.972.34 - '@aws-sdk/credential-provider-web-identity': 3.972.34 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.9 - '@smithy/credential-provider-imds': 4.2.14 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-ini@3.972.38': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/credential-provider-env': 3.972.39 @@ -19920,19 +19839,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/protocol-http': 5.4.3 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-login@3.972.43': dependencies: '@aws-sdk/core': 3.974.13 @@ -19951,8 +19857,8 @@ snapshots: '@aws-sdk/credential-provider-sso': 3.969.0 '@aws-sdk/credential-provider-web-identity': 3.969.0 '@aws-sdk/types': 3.969.0 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -19973,19 +19879,17 @@ snapshots: '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt '@aws-sdk/credential-provider-node@3.972.39': dependencies: - '@aws-sdk/credential-provider-env': 3.972.34 - '@aws-sdk/credential-provider-http': 3.972.36 - '@aws-sdk/credential-provider-ini': 3.972.38 - '@aws-sdk/credential-provider-process': 3.972.34 - '@aws-sdk/credential-provider-sso': 3.972.38 - '@aws-sdk/credential-provider-web-identity': 3.972.38 + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-ini': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 '@aws-sdk/types': 3.973.9 - '@smithy/credential-provider-imds': 4.2.14 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 '@smithy/types': 4.14.2 @@ -20023,15 +19927,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.39': dependencies: '@aws-sdk/core': 3.974.13 @@ -20054,23 +19949,10 @@ snapshots: - aws-crt '@aws-sdk/credential-provider-sso@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/token-providers': 3.1035.0 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-sso@3.972.38': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.11 - '@aws-sdk/token-providers': 3.1041.0 + '@aws-sdk/token-providers': 3.1035.0 '@aws-sdk/types': 3.973.9 '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.9 @@ -20100,18 +19982,6 @@ snapshots: - aws-crt '@aws-sdk/credential-provider-web-identity@3.972.34': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.38': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.11 @@ -20325,28 +20195,28 @@ snapshots: '@aws-sdk/util-user-agent-node': 3.969.0 '@smithy/config-resolver': 4.4.17 '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.3.17 + '@smithy/fetch-http-handler': 5.4.4 '@smithy/hash-node': 4.2.14 '@smithy/invalid-dependency': 4.2.14 '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 + '@smithy/middleware-endpoint': 4.5.1 + '@smithy/middleware-retry': 4.6.1 + '@smithy/middleware-serde': 4.3.1 '@smithy/middleware-stack': 4.2.14 '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.7.1 + '@smithy/node-http-handler': 4.7.4 '@smithy/protocol-http': 5.4.3 - '@smithy/smithy-client': 4.12.12 + '@smithy/smithy-client': 4.13.1 '@smithy/types': 4.14.2 '@smithy/url-parser': 4.2.14 '@smithy/util-base64': 4.3.2 '@smithy/util-body-length-browser': 4.2.2 '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 + '@smithy/util-defaults-mode-browser': 4.4.1 + '@smithy/util-defaults-mode-node': 4.3.1 '@smithy/util-endpoints': 3.4.2 '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 + '@smithy/util-retry': 4.4.1 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 transitivePeerDependencies: @@ -20365,50 +20235,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.2': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.13 - '@aws-sdk/middleware-host-header': 3.972.10 - '@aws-sdk/middleware-logger': 3.972.10 - '@aws-sdk/middleware-recursion-detection': 3.972.11 - '@aws-sdk/middleware-user-agent': 3.972.34 - '@aws-sdk/region-config-resolver': 3.972.13 - '@aws-sdk/signature-v4-multi-region': 3.996.21 - '@aws-sdk/types': 3.973.9 - '@aws-sdk/util-endpoints': 3.996.8 - '@aws-sdk/util-user-agent-browser': 3.972.10 - '@aws-sdk/util-user-agent-node': 3.973.20 - '@smithy/config-resolver': 4.4.17 - '@smithy/core': 3.24.4 - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/hash-node': 4.2.14 - '@smithy/invalid-dependency': 4.2.14 - '@smithy/middleware-content-length': 4.2.14 - '@smithy/middleware-endpoint': 4.4.31 - '@smithy/middleware-retry': 4.5.4 - '@smithy/middleware-serde': 4.2.19 - '@smithy/middleware-stack': 4.2.14 - '@smithy/node-config-provider': 4.3.14 - '@smithy/node-http-handler': 4.6.0 - '@smithy/protocol-http': 5.4.3 - '@smithy/smithy-client': 4.12.12 - '@smithy/types': 4.14.2 - '@smithy/url-parser': 4.2.14 - '@smithy/util-base64': 4.3.2 - '@smithy/util-body-length-browser': 4.2.2 - '@smithy/util-body-length-node': 4.2.3 - '@smithy/util-defaults-mode-browser': 4.3.48 - '@smithy/util-defaults-mode-node': 4.2.53 - '@smithy/util-endpoints': 3.4.2 - '@smithy/util-middleware': 4.2.14 - '@smithy/util-retry': 4.3.3 - '@smithy/util-utf8': 4.2.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/region-config-resolver@3.969.0': dependencies: '@aws-sdk/types': 3.969.0 @@ -20463,18 +20289,6 @@ snapshots: tslib: 2.8.1 '@aws-sdk/token-providers@3.1035.0': - dependencies: - '@aws-sdk/core': 3.974.13 - '@aws-sdk/nested-clients': 3.997.2 - '@aws-sdk/types': 3.973.9 - '@smithy/property-provider': 4.2.14 - '@smithy/shared-ini-file-loader': 4.4.9 - '@smithy/types': 4.14.2 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/token-providers@3.1041.0': dependencies: '@aws-sdk/core': 3.974.13 '@aws-sdk/nested-clients': 3.997.11 @@ -20603,7 +20417,7 @@ snapshots: '@aws-sdk/xml-builder@3.969.0': dependencies: '@smithy/types': 4.14.2 - fast-xml-parser: 5.7.2 + fast-xml-parser: 5.7.3 tslib: 2.8.1 '@aws-sdk/xml-builder@3.972.18': @@ -29372,14 +29186,6 @@ snapshots: '@smithy/url-parser': 4.2.14 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.14 - '@smithy/types': 4.14.2 - '@smithy/url-parser': 4.2.14 - tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.4': dependencies: '@smithy/core': 3.24.4 @@ -29595,7 +29401,7 @@ snapshots: '@smithy/node-config-provider@4.3.8': dependencies: - '@smithy/property-provider': 4.2.8 + '@smithy/property-provider': 4.2.14 '@smithy/shared-ini-file-loader': 4.4.3 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -29632,11 +29438,6 @@ snapshots: '@smithy/types': 4.14.2 tslib: 2.8.1 - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.14.2 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.14': dependencies: '@smithy/types': 4.14.1 @@ -29810,7 +29611,7 @@ snapshots: '@smithy/util-defaults-mode-browser@4.3.21': dependencies: - '@smithy/property-provider': 4.2.8 + '@smithy/property-provider': 4.2.14 '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -29830,9 +29631,9 @@ snapshots: '@smithy/util-defaults-mode-node@4.2.24': dependencies: '@smithy/config-resolver': 4.4.17 - '@smithy/credential-provider-imds': 4.2.8 + '@smithy/credential-provider-imds': 4.3.4 '@smithy/node-config-provider': 4.3.14 - '@smithy/property-provider': 4.2.8 + '@smithy/property-provider': 4.2.14 '@smithy/smithy-client': 4.12.12 '@smithy/types': 4.14.2 tslib: 2.8.1 @@ -29906,8 +29707,8 @@ snapshots: '@smithy/util-stream@4.5.10': dependencies: - '@smithy/fetch-http-handler': 5.3.17 - '@smithy/node-http-handler': 4.6.0 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 '@smithy/types': 4.14.2 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.0