From 96c2dc73d412599a2a4b3a79c9f6c3842333a12f Mon Sep 17 00:00:00 2001 From: Joon Choi Date: Tue, 15 Oct 2024 14:53:18 -0700 Subject: [PATCH] feat(storage): Add API support for Expected Bucket Owner (#13914) * Update Top/Internal API for expected bucket owner feat --------- Co-authored-by: JoonWon Choi --- packages/aws-amplify/package.json | 14 +- .../__tests__/providers/s3/apis/copy.test.ts | 51 +++++++ .../providers/s3/apis/downloadData.test.ts | 47 ++++++ .../providers/s3/apis/getProperties.test.ts | 89 ++++++++++++ .../providers/s3/apis/getUrl.test.ts | 92 ++++++++++++ .../__tests__/providers/s3/apis/list.test.ts | 134 ++++++++++++++++++ .../providers/s3/apis/remove.test.ts | 45 ++++++ .../s3/apis/uploadData/index.test.ts | 47 ++++++ .../storage/src/errors/types/validation.ts | 4 + .../src/internals/apis/getProperties.ts | 1 + .../src/providers/s3/apis/internal/copy.ts | 16 ++- .../s3/apis/internal/downloadData.ts | 8 +- .../s3/apis/internal/getProperties.ts | 5 + .../src/providers/s3/apis/internal/getUrl.ts | 3 + .../src/providers/s3/apis/internal/list.ts | 3 + .../src/providers/s3/apis/internal/remove.ts | 3 + .../uploadData/multipart/initialUpload.ts | 3 + .../uploadData/multipart/uploadHandlers.ts | 7 + .../multipart/uploadPartExecutor.ts | 3 + .../apis/internal/uploadData/putObjectJob.ts | 4 + .../storage/src/providers/s3/types/options.ts | 9 ++ .../client/s3data/abortMultipartUpload.ts | 10 +- .../client/s3data/completeMultipartUpload.ts | 12 +- .../s3/utils/client/s3data/copyObject.ts | 4 + .../client/s3data/createMultipartUpload.ts | 1 + .../s3/utils/client/s3data/deleteObject.ts | 8 +- .../s3/utils/client/s3data/getObject.ts | 5 + .../s3/utils/client/s3data/headObject.ts | 11 +- .../s3/utils/client/s3data/putObject.ts | 8 +- .../s3/utils/client/s3data/uploadPart.ts | 8 +- .../storage/src/providers/s3/utils/index.ts | 1 + .../s3/utils/validateBucketOwnerID.ts | 18 +++ 32 files changed, 653 insertions(+), 21 deletions(-) create mode 100644 packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index c54a2a9bc9f..889d7112966 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -461,43 +461,43 @@ "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "15.90 kB" + "limit": "16.05 kB" }, { "name": "[Storage] downloadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ downloadData }", - "limit": "16.27 kB" + "limit": "16.40 kB" }, { "name": "[Storage] getProperties (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getProperties }", - "limit": "15.50 kB" + "limit": "15.65 kB" }, { "name": "[Storage] getUrl (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ getUrl }", - "limit": "16.74 kB" + "limit": "16.90 kB" }, { "name": "[Storage] list (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ list }", - "limit": "16.26 kB" + "limit": "16.35 kB" }, { "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "15.37 kB" + "limit": "15.50 kB" }, { "name": "[Storage] uploadData (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ uploadData }", - "limit": "22.19 kB" + "limit": "22.35 kB" } ] } diff --git a/packages/storage/__tests__/providers/s3/apis/copy.test.ts b/packages/storage/__tests__/providers/s3/apis/copy.test.ts index 2a87ebe065b..07b3618f778 100644 --- a/packages/storage/__tests__/providers/s3/apis/copy.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/copy.test.ts @@ -39,6 +39,8 @@ const bucket = 'bucket'; const region = 'region'; const targetIdentityId = 'targetIdentityId'; const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const validBucketOwner2 = '123456789012'; const credentials: AWSCredentials = { accessKeyId: 'accessKeyId', sessionToken: 'sessionToken', @@ -264,6 +266,31 @@ describe('copy API', () => { }), ); }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + key: 'sourceKey', + eTag: mockEtag, + expectedBucketOwner: validBucketOwner, + }, + destination: { + key: 'destinationKey', + expectedBucketOwner: validBucketOwner2, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ExpectedSourceBucketOwner: validBucketOwner, + ExpectedBucketOwner: validBucketOwner2, + }), + ); + }); + }); }); describe('With path', () => { @@ -382,6 +409,30 @@ describe('copy API', () => { }), ); }); + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockEtag = 'mock-etag'; + await copyWrapper({ + source: { + path: 'public/sourceKey', + eTag: mockEtag, + expectedBucketOwner: validBucketOwner, + }, + destination: { + path: 'public/destinationKey', + expectedBucketOwner: validBucketOwner2, + }, + }); + expect(copyObject).toHaveBeenCalledTimes(1); + expect(copyObject).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ExpectedSourceBucketOwner: validBucketOwner, + ExpectedBucketOwner: validBucketOwner2, + }), + ); + }); + }); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts index aab4d1b8260..cca3fc10d59 100644 --- a/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/downloadData.test.ts @@ -50,6 +50,7 @@ const bucket = 'bucket'; const region = 'region'; const targetIdentityId = 'targetIdentityId'; const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; const mockDownloadResultBase = { body: 'body', lastModified: 'lastModified', @@ -286,6 +287,29 @@ describe('downloadData with key', () => { ); }); }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + key: inputKey, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); }); describe('downloadData with path', () => { @@ -497,4 +521,27 @@ describe('downloadData with path', () => { ); }); }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + (getObject as jest.Mock).mockResolvedValueOnce({ Body: 'body' }); + downloadData({ + path: inputKey, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + const { job } = mockCreateDownloadTask.mock.calls[0][0]; + await job(); + + expect(getObject).toHaveBeenCalledTimes(1); + await expect(getObject).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts index cfde93eb069..f00082ba206 100644 --- a/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getProperties.test.ts @@ -42,6 +42,8 @@ const inputKey = 'key'; const inputPath = 'path'; const targetIdentityId = 'targetIdentityId'; const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; +const invalidBucketOwner = '123'; const expectedResult = { size: 100, @@ -412,3 +414,90 @@ describe('Happy cases: With path', () => { }); }); }); + +describe(`getProperties with path and Expected Bucket Owner`, () => { + const getPropertiesWrapper = ( + input: GetPropertiesWithPathInput, + ): Promise => getProperties(input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass expectedBucketOwner to headObject', async () => { + const path = 'public/expectedbucketowner_test'; + + await getPropertiesWrapper({ + path, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + ExpectedBucketOwner: validBucketOwner, + Key: path, + }, + ); + }); + + it('headObject should not expose expectedBucketOwner when not provided', async () => { + const path = 'public/expectedbucketowner_test'; + + await getPropertiesWrapper({ + path, + options: {}, + }); + + expect(headObject).toHaveBeenCalledTimes(1); + await expect(headObject).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + userAgentValue: expect.any(String), + }, + { + Bucket: bucket, + Key: path, + }, + ); + }); + + it('should throw error on invalid bucket owner id', async () => { + const path = 'public/expectedbucketowner_test'; + + await expect( + getPropertiesWrapper({ + path, + options: { + expectedBucketOwner: invalidBucketOwner, + }, + }), + ).rejects.toThrow('Invalid AWS account ID was provided.'); + + expect(headObject).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts index 7383e34f531..fe96802a728 100644 --- a/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/getUrl.test.ts @@ -43,6 +43,8 @@ const credentials: AWSCredentials = { const targetIdentityId = 'targetIdentityId'; const defaultIdentityId = 'defaultIdentityId'; const mockURL = new URL('https://google.com'); +const validBucketOwner = '111122223333'; +const invalidBucketOwner = '123'; describe('getUrl test with key', () => { const getUrlWrapper = (input: GetUrlInput): Promise => @@ -488,3 +490,93 @@ describe('getUrl test with path', () => { }); }); }); + +describe(`getURL with path and Expected Bucket Owner`, () => { + const getUrlWrapper = ( + input: GetUrlWithPathInput, + ): Promise => getUrl(input); + beforeAll(() => { + mockFetchAuthSession.mockResolvedValue({ + credentials, + identityId: defaultIdentityId, + }); + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + buckets: { 'default-bucket': { bucketName: bucket, region } }, + }, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass expectedBucketOwner to getPresignedGetObjectUrl', async () => { + const path = 'public/expectedbucketowner_test'; + + await getUrlWrapper({ + path, + options: { + expiresIn: 300, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + ExpectedBucketOwner: validBucketOwner, + Key: path, + }, + ); + }); + + it('getPresignedGetObjectUrl should not expose expectedBucketOwner when not provided', async () => { + const path = 'public/expectedbucketowner_test'; + + await getUrlWrapper({ + path, + options: { + expiresIn: 300, + }, + }); + + expect(getPresignedGetObjectUrl).toHaveBeenCalledTimes(1); + await expect(getPresignedGetObjectUrl).toBeLastCalledWithConfigAndInput( + { + credentials, + region, + expiration: expect.any(Number), + }, + { + Bucket: bucket, + Key: path, + }, + ); + }); + + it('should throw error on invalid bucket owner id', async () => { + const path = 'public/expectedbucketowner_test'; + + await expect( + getUrlWrapper({ + path, + options: { + expectedBucketOwner: invalidBucketOwner, + }, + }), + ).rejects.toThrow('Invalid AWS account ID was provided.'); + + expect(getPresignedGetObjectUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage/__tests__/providers/s3/apis/list.test.ts b/packages/storage/__tests__/providers/s3/apis/list.test.ts index 68e058fc40c..1d11da78bda 100644 --- a/packages/storage/__tests__/providers/s3/apis/list.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/list.test.ts @@ -43,6 +43,7 @@ const defaultIdentityId = 'defaultIdentityId'; const etagValue = 'eTag'; const lastModifiedValue = 'lastModified'; const sizeValue = 'size'; +const validBucketOwner = '111122223333'; const credentials: AWSCredentials = { accessKeyId: 'accessKeyId', sessionToken: 'sessionToken', @@ -677,6 +678,7 @@ describe('list API', () => { expect(error.$metadata.httpStatusCode).toBe(404); } }); + describe.each([ { type: 'Prefix', @@ -898,4 +900,136 @@ describe('list API', () => { ); }); }); + + describe(`List with path and Expected Bucket Owner`, () => { + describe(`v1`, () => { + const listAllWrapper = (input: ListAllInput): Promise => + list(input); + const listPaginatedWrapper = ( + input: ListPaginateInput, + ): Promise => list(input); + const resolvePath = ( + path: string | (({ identityId }: { identityId: string }) => string), + ) => + typeof path === 'string' + ? path + : path({ identityId: defaultIdentityId }); + const mockPrefix = 'test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + it('should include expectedBucketOwner in headers with listAll call when provided', async () => { + const resolvedPath = resolvePath(mockPrefix); + mockListObjectsV2ApiWithPages(3); + await listAllWrapper({ + prefix: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: true, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + it('should include expectedBucketOwner in headers with paginated call when provided', async () => { + const resolvedPath = resolvePath(mockPrefix); + mockListObjectsV2ApiWithPages(3); + const customPageSize = 5; + await listPaginatedWrapper({ + prefix: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: false, + pageSize: customPageSize, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + + describe(`v2`, () => { + const listAllWrapper = ( + input: ListAllWithPathInput, + ): Promise => list(input); + const listPaginatedWrapper = ( + input: ListPaginateWithPathInput, + ): Promise => list(input); + const resolvePath = ( + path: string | (({ identityId }: { identityId: string }) => string), + ) => + typeof path === 'string' + ? path + : path({ identityId: defaultIdentityId }); + const mockPath = 'public/test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + afterEach(() => { + jest.clearAllMocks(); + mockListObject.mockClear(); + }); + it('should include expectedBucketOwner in headers with listAll call when provided', async () => { + const resolvedPath = resolvePath(mockPath); + mockListObjectsV2ApiWithPages(3); + await listAllWrapper({ + path: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: true, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + Bucket: mockBucket, + MaxKeys: 1000, + Prefix: mockPath, + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + it('should include expectedBucketOwner in headers with paginated call when provided', async () => { + const resolvedPath = resolvePath(mockPath); + mockListObjectsV2ApiWithPages(3); + const customPageSize = 5; + await listPaginatedWrapper({ + path: resolvedPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + listAll: false, + pageSize: customPageSize, + expectedBucketOwner: validBucketOwner, + }, + }); + + expect(listObjectsV2).toBeLastCalledWithConfigAndInput( + expect.any(Object), + expect.objectContaining({ + Bucket: mockBucket, + Prefix: mockPath, + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); + }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/remove.test.ts b/packages/storage/__tests__/providers/s3/apis/remove.test.ts index 16adafd3e7c..91b544ef999 100644 --- a/packages/storage/__tests__/providers/s3/apis/remove.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/remove.test.ts @@ -34,6 +34,7 @@ const inputKey = 'key'; const bucket = 'bucket'; const region = 'region'; const defaultIdentityId = 'defaultIdentityId'; +const validBucketOwner = '111122223333'; const credentials: AWSCredentials = { accessKeyId: 'accessKeyId', sessionToken: 'sessionToken', @@ -161,6 +162,28 @@ describe('remove API', () => { ); }); }); + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockKey = 'test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + key: mockKey, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + expectedBucketOwner: validBucketOwner, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + expect(deleteObject).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); }); describe('With Path', () => { const removeWrapper = ( @@ -248,6 +271,28 @@ describe('remove API', () => { ); }); }); + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided', async () => { + const mockPath = 'public/test-path'; + const mockBucket = 'bucket-1'; + const mockRegion = 'region-1'; + await removeWrapper({ + path: mockPath, + options: { + bucket: { bucketName: mockBucket, region: mockRegion }, + expectedBucketOwner: validBucketOwner, + }, + }); + expect(deleteObject).toHaveBeenCalledTimes(1); + expect(deleteObject).toHaveBeenNthCalledWithConfigAndInput( + 1, + expect.any(Object), + expect.objectContaining({ + ExpectedBucketOwner: validBucketOwner, + }), + ); + }); + }); }); }); diff --git a/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts b/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts index 099fc76cb25..ce1755abfe6 100644 --- a/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/uploadData/index.test.ts @@ -19,6 +19,7 @@ jest.mock( jest.mock('../../../../../src/providers/s3/apis/internal/uploadData/multipart'); const testPath = 'testPath/object'; +const validBucketOwner = '111122223333'; const mockCreateUploadTask = createUploadTask as jest.Mock; const mockPutObjectJob = putObjectJob as jest.Mock; const mockGetMultipartUploadHandlers = ( @@ -237,4 +238,50 @@ describe('uploadData with path', () => { ); }); }); + + describe('ExpectedBucketOwner passed in options', () => { + it('should include expectedBucketOwner in headers when provided for singlepartUpload', async () => { + mockPutObjectJob.mockReturnValueOnce('putObjectJob'); + const smallData = 'smallData'; + uploadData({ + path: testPath, + data: smallData, + options: { + expectedBucketOwner: validBucketOwner, + }, + }); + expect(mockPutObjectJob).toHaveBeenCalledWith( + expect.objectContaining({ + path: 'testPath/object', + data: 'smallData', + options: expect.objectContaining({ + expectedBucketOwner: '111122223333', + }), + }), + expect.any(Object), + expect.any(Number), + ); + + expect(mockGetMultipartUploadHandlers).not.toHaveBeenCalled(); + }); + it('should include expectedBucketOwner in headers when provided for multipartUpload', async () => { + const biggerData = { size: 5 * 1024 * 1024 + 1 } as any; + const testInput = { + path: testPath, + data: biggerData, + options: { + expectedBucketOwner: validBucketOwner, + }, + }; + uploadData(testInput); + expect(mockGetMultipartUploadHandlers).toHaveBeenCalledWith( + expect.objectContaining({ + ...testInput, + }), + expect.any(Number), + ); + + expect(mockPutObjectJob).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/storage/src/errors/types/validation.ts b/packages/storage/src/errors/types/validation.ts index 75c90771461..de15d0a89ec 100644 --- a/packages/storage/src/errors/types/validation.ts +++ b/packages/storage/src/errors/types/validation.ts @@ -17,6 +17,7 @@ export enum StorageValidationErrorCode { InvalidCopyOperationStorageBucket = 'InvalidCopyOperationStorageBucket', InvalidStorageOperationPrefixInput = 'InvalidStorageOperationPrefixInput', InvalidStorageOperationInput = 'InvalidStorageOperationInput', + InvalidAWSAccountID = 'InvalidAWSAccountID', InvalidStoragePathInput = 'InvalidStoragePathInput', InvalidUploadSource = 'InvalidUploadSource', ObjectIsTooLarge = 'ObjectIsTooLarge', @@ -69,6 +70,9 @@ export const validationErrorMap: AmplifyErrorMap = { message: 'Path or key parameter must be specified in the input. Both can not be specified at the same time.', }, + [StorageValidationErrorCode.InvalidAWSAccountID]: { + message: 'Invalid AWS account ID was provided.', + }, [StorageValidationErrorCode.InvalidStorageOperationPrefixInput]: { message: 'Both path and prefix can not be specified at the same time.', }, diff --git a/packages/storage/src/internals/apis/getProperties.ts b/packages/storage/src/internals/apis/getProperties.ts index 2dc431887f9..6d651f54635 100644 --- a/packages/storage/src/internals/apis/getProperties.ts +++ b/packages/storage/src/internals/apis/getProperties.ts @@ -26,6 +26,7 @@ export const getProperties = ( useAccelerateEndpoint: input?.options?.useAccelerateEndpoint, bucket: input?.options?.bucket, locationCredentialsProvider: input?.options?.locationCredentialsProvider, + expectedBucketOwner: input?.options?.expectedBucketOwner, }, // Type casting is necessary because `getPropertiesInternal` supports both Gen1 and Gen2 signatures, but here // given in input can only be Gen2 signature, the return can only ben Gen2 signature. diff --git a/packages/storage/src/providers/s3/apis/internal/copy.ts b/packages/storage/src/providers/s3/apis/internal/copy.ts index 30d4bd136c4..5098096a81f 100644 --- a/packages/storage/src/providers/s3/apis/internal/copy.ts +++ b/packages/storage/src/providers/s3/apis/internal/copy.ts @@ -14,6 +14,7 @@ import { ResolvedS3Config, StorageBucket } from '../../types/options'; import { isInputWithPath, resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { StorageValidationErrorCode } from '../../../../errors/types/validation'; @@ -97,7 +98,8 @@ const copyWithPath = async ( destination, identityId, ); - + validateBucketOwnerID(source.expectedBucketOwner); + validateBucketOwnerID(destination.expectedBucketOwner); const finalCopySource = `${sourceBucket}/${sourcePath}`; const finalCopyDestination = destinationPath; logger.debug(`copying "${finalCopySource}" to "${finalCopyDestination}".`); @@ -109,6 +111,8 @@ const copyWithPath = async ( s3Config, notModifiedSince: input.source.notModifiedSince, eTag: input.source.eTag, + expectedSourceBucketOwner: input.source?.expectedBucketOwner, + expectedBucketOwner: input.destination?.expectedBucketOwner, }); return { path: finalCopyDestination }; @@ -128,6 +132,8 @@ export const copyWithKey = async ( !!destination.key, StorageValidationErrorCode.NoDestinationKey, ); + validateBucketOwnerID(source.expectedBucketOwner); + validateBucketOwnerID(destination.expectedBucketOwner); const { bucket: sourceBucket, keyPrefix: sourceKeyPrefix } = await resolveS3ConfigAndInput(amplify, { @@ -168,6 +174,8 @@ export const copyWithKey = async ( s3Config, notModifiedSince: input.source.notModifiedSince, eTag: input.source.eTag, + expectedSourceBucketOwner: input.source?.expectedBucketOwner, + expectedBucketOwner: input.destination?.expectedBucketOwner, }); return { @@ -182,6 +190,8 @@ const serviceCopy = async ({ s3Config, notModifiedSince, eTag, + expectedSourceBucketOwner, + expectedBucketOwner, }: { source: string; destination: string; @@ -189,6 +199,8 @@ const serviceCopy = async ({ s3Config: ResolvedS3Config; notModifiedSince?: Date; eTag?: string; + expectedSourceBucketOwner?: string; + expectedBucketOwner?: string; }) => { await copyObject( { @@ -202,6 +214,8 @@ const serviceCopy = async ({ MetadataDirective: 'COPY', // Copies over metadata like contentType as well CopySourceIfMatch: eTag, CopySourceIfUnmodifiedSince: notModifiedSince, + ExpectedSourceBucketOwner: expectedSourceBucketOwner, + ExpectedBucketOwner: expectedBucketOwner, }, ); }; diff --git a/packages/storage/src/providers/s3/apis/internal/downloadData.ts b/packages/storage/src/providers/s3/apis/internal/downloadData.ts index fe9b187231c..f1d804b323d 100644 --- a/packages/storage/src/providers/s3/apis/internal/downloadData.ts +++ b/packages/storage/src/providers/s3/apis/internal/downloadData.ts @@ -5,7 +5,11 @@ import { Amplify } from '@aws-amplify/core'; import { StorageAction } from '@aws-amplify/core/internals/utils'; import { resolveS3ConfigAndInput } from '../../utils/resolveS3ConfigAndInput'; -import { createDownloadTask, validateStorageOperationInput } from '../../utils'; +import { + createDownloadTask, + validateBucketOwnerID, + validateStorageOperationInput, +} from '../../utils'; import { getObject } from '../../utils/client/s3data'; import { getStorageUserAgentValue } from '../../utils/userAgent'; import { logger } from '../../../../utils'; @@ -48,6 +52,7 @@ const downloadDataJob = downloadDataInput, identityId, ); + validateBucketOwnerID(downloadDataOptions?.expectedBucketOwner); const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; logger.debug(`download ${objectKey} from ${finalKey}.`); @@ -72,6 +77,7 @@ const downloadDataJob = ...(downloadDataOptions?.bytesRange && { Range: `bytes=${downloadDataOptions.bytesRange.start}-${downloadDataOptions.bytesRange.end}`, }), + ExpectedBucketOwner: downloadDataOptions?.expectedBucketOwner, }, ); const result = { diff --git a/packages/storage/src/providers/s3/apis/internal/getProperties.ts b/packages/storage/src/providers/s3/apis/internal/getProperties.ts index e07d721c989..981c32cb827 100644 --- a/packages/storage/src/providers/s3/apis/internal/getProperties.ts +++ b/packages/storage/src/providers/s3/apis/internal/getProperties.ts @@ -11,6 +11,7 @@ import { } from '../../types'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { headObject } from '../../utils/client/s3data'; @@ -31,6 +32,9 @@ export const getProperties = async ( input, identityId, ); + + validateBucketOwnerID(input.options?.expectedBucketOwner); + const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; @@ -45,6 +49,7 @@ export const getProperties = async ( { Bucket: bucket, Key: finalKey, + ExpectedBucketOwner: input.options?.expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index 6897a9a5d64..db2315ddb78 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -9,6 +9,7 @@ import { StorageValidationErrorCode } from '../../../../errors/types/validation' import { getPresignedGetObjectUrl } from '../../utils/client/s3data'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { assertValidationError } from '../../../../errors/utils/assertValidationError'; @@ -34,6 +35,7 @@ export const getUrl = async ( input, identityId, ); + validateBucketOwnerID(getUrlOptions?.expectedBucketOwner); const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; @@ -80,6 +82,7 @@ export const getUrl = async ( ...(getUrlOptions?.contentType && { ResponseContentType: getUrlOptions.contentType, }), + ExpectedBucketOwner: getUrlOptions?.expectedBucketOwner, }, ), expiresAt: new Date(Date.now() + urlExpirationInSec * 1000), diff --git a/packages/storage/src/providers/s3/apis/internal/list.ts b/packages/storage/src/providers/s3/apis/internal/list.ts index 6da2fe4e307..ff03fe8a3ea 100644 --- a/packages/storage/src/providers/s3/apis/internal/list.ts +++ b/packages/storage/src/providers/s3/apis/internal/list.ts @@ -14,6 +14,7 @@ import { } from '../../types'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInputWithPrefix, } from '../../utils'; import { @@ -64,6 +65,7 @@ export const list = async ( input, identityId, ); + validateBucketOwnerID(options.expectedBucketOwner); const isInputWithPrefix = inputType === STORAGE_INPUT_PREFIX; // @ts-expect-error pageSize and nextToken should not coexist with listAll @@ -82,6 +84,7 @@ export const list = async ( MaxKeys: options?.listAll ? undefined : options?.pageSize, ContinuationToken: options?.listAll ? undefined : options?.nextToken, Delimiter: getDelimiter(options), + ExpectedBucketOwner: options?.expectedBucketOwner, }; logger.debug(`listing items from "${listParams.Prefix}"`); diff --git a/packages/storage/src/providers/s3/apis/internal/remove.ts b/packages/storage/src/providers/s3/apis/internal/remove.ts index ed867ad2c36..e751b6bbb61 100644 --- a/packages/storage/src/providers/s3/apis/internal/remove.ts +++ b/packages/storage/src/providers/s3/apis/internal/remove.ts @@ -7,6 +7,7 @@ import { StorageAction } from '@aws-amplify/core/internals/utils'; import { RemoveInput, RemoveOutput, RemoveWithPathOutput } from '../../types'; import { resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../utils'; import { deleteObject } from '../../utils/client/s3data'; @@ -27,6 +28,7 @@ export const remove = async ( input, identityId, ); + validateBucketOwnerID(input.options?.expectedBucketOwner); let finalKey; if (inputType === STORAGE_INPUT_KEY) { @@ -45,6 +47,7 @@ export const remove = async ( { Bucket: bucket, Key: finalKey, + ExpectedBucketOwner: input.options?.expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts index fdd6c40c168..0d944831994 100644 --- a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/initialUpload.ts @@ -33,6 +33,7 @@ interface LoadOrCreateMultipartUploadOptions { metadata?: Record; size?: number; abortSignal?: AbortSignal; + expectedBucketOwner?: string; } interface LoadOrCreateMultipartUploadResult { @@ -60,6 +61,7 @@ export const loadOrCreateMultipartUpload = async ({ contentEncoding, metadata, abortSignal, + expectedBucketOwner, }: LoadOrCreateMultipartUploadOptions): Promise => { const finalKey = keyPrefix !== undefined ? keyPrefix + key : key; @@ -117,6 +119,7 @@ export const loadOrCreateMultipartUpload = async ({ ContentEncoding: contentEncoding, Metadata: metadata, ChecksumAlgorithm: finalCrc32 ? 'CRC32' : undefined, + ExpectedBucketOwner: expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts index 41cff938d23..ece5e4003b9 100644 --- a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadHandlers.ts @@ -66,6 +66,7 @@ export const getMultipartUploadHandlers = ( let resolvedIdentityId: string | undefined; let uploadCacheKey: string | undefined; let finalKey: string; + let expectedBucketOwner: string | undefined; // Special flag that differentiates HTTP requests abort error caused by pause() from ones caused by cancel(). // The former one should NOT cause the upload job to throw, but cancels any pending HTTP requests. // This should be replaced by a special abort reason. However,the support of this API is lagged behind. @@ -83,6 +84,7 @@ export const getMultipartUploadHandlers = ( resolvedS3Config = resolvedS3Options.s3Config; resolvedBucket = resolvedS3Options.bucket; resolvedIdentityId = resolvedS3Options.identityId; + expectedBucketOwner = uploadDataOptions?.expectedBucketOwner; const { inputType, objectKey } = validateStorageOperationInput( uploadDataInput, @@ -125,6 +127,7 @@ export const getMultipartUploadHandlers = ( data, size, abortSignal: abortController.signal, + expectedBucketOwner, }); inProgressUpload = { uploadId, @@ -181,6 +184,7 @@ export const getMultipartUploadHandlers = ( onProgress: concurrentUploadsProgressTracker.getOnProgressListener(), isObjectLockEnabled: resolvedS3Options.isObjectLockEnabled, useCRC32Checksum: Boolean(inProgressUpload.finalCrc32), + expectedBucketOwner, }), ); } @@ -210,6 +214,7 @@ export const getMultipartUploadHandlers = ( (partA, partB) => partA.PartNumber! - partB.PartNumber!, ), }, + ExpectedBucketOwner: expectedBucketOwner, }, ); @@ -219,6 +224,7 @@ export const getMultipartUploadHandlers = ( { Bucket: resolvedBucket, Key: finalKey, + ExpectedBucketOwner: expectedBucketOwner, }, ); if (uploadedObjectSize && uploadedObjectSize !== size) { @@ -284,6 +290,7 @@ export const getMultipartUploadHandlers = ( Bucket: resolvedBucket, Key: finalKey, UploadId: inProgressUpload?.uploadId, + ExpectedBucketOwner: expectedBucketOwner, }); }; cancelUpload().catch(e => { diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts index fcd0e1d07a2..34a6a8d1ae0 100644 --- a/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/multipart/uploadPartExecutor.ts @@ -26,6 +26,7 @@ interface UploadPartExecutorOptions { crc32: string | undefined, ): void; onProgress?(event: TransferProgressEvent): void; + expectedBucketOwner?: string; } export const uploadPartExecutor = async ({ @@ -40,6 +41,7 @@ export const uploadPartExecutor = async ({ onProgress, isObjectLockEnabled, useCRC32Checksum, + expectedBucketOwner, }: UploadPartExecutorOptions) => { let transferredBytes = 0; for (const { data, partNumber, size } of dataChunkerGenerator) { @@ -85,6 +87,7 @@ export const uploadPartExecutor = async ({ PartNumber: partNumber, ChecksumCRC32: checksumCRC32?.checksum, ContentMD5: contentMD5, + ExpectedBucketOwner: expectedBucketOwner, }, ); transferredBytes += size; diff --git a/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts index 22bf6ce62cc..08c67f19573 100644 --- a/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts +++ b/packages/storage/src/providers/s3/apis/internal/uploadData/putObjectJob.ts @@ -8,6 +8,7 @@ import { UploadDataInput, UploadDataWithPathInput } from '../../../types'; import { calculateContentMd5, resolveS3ConfigAndInput, + validateBucketOwnerID, validateStorageOperationInput, } from '../../../utils'; import { ItemWithKey, ItemWithPath } from '../../../types/outputs'; @@ -38,6 +39,7 @@ export const putObjectJob = uploadDataInput, identityId, ); + validateBucketOwnerID(uploadDataOptions?.expectedBucketOwner); const finalKey = inputType === STORAGE_INPUT_KEY ? keyPrefix + objectKey : objectKey; @@ -48,6 +50,7 @@ export const putObjectJob = preventOverwrite, metadata, onProgress, + expectedBucketOwner, } = uploadDataOptions ?? {}; const checksumCRC32 = await calculateContentCRC32(data); @@ -81,6 +84,7 @@ export const putObjectJob = Metadata: metadata, ContentMD5: contentMD5, ChecksumCRC32: checksumCRC32?.checksum, + ExpectedBucketOwner: expectedBucketOwner, }, ); diff --git a/packages/storage/src/providers/s3/types/options.ts b/packages/storage/src/providers/s3/types/options.ts index 3442b6867cc..04ec06fdd1d 100644 --- a/packages/storage/src/providers/s3/types/options.ts +++ b/packages/storage/src/providers/s3/types/options.ts @@ -51,6 +51,11 @@ interface CommonOptions { useAccelerateEndpoint?: boolean; bucket?: StorageBucket; + + /** + * The expected owner of the target bucket. + */ + expectedBucketOwner?: string; } /** @@ -235,6 +240,7 @@ export type CopySourceWithKeyOptions = ReadOptions & { bucket?: StorageBucket; notModifiedSince?: Date; eTag?: string; + expectedBucketOwner?: string; }; /** @deprecated This may be removed in the next major version. */ @@ -242,16 +248,19 @@ export type CopyDestinationWithKeyOptions = WriteOptions & { /** @deprecated This may be removed in the next major version. */ key: string; bucket?: StorageBucket; + expectedBucketOwner?: string; }; export interface CopyWithPathSourceOptions { bucket?: StorageBucket; notModifiedSince?: Date; eTag?: string; + expectedBucketOwner?: string; } export interface CopyWithPathDestinationOptions { bucket?: StorageBucket; + expectedBucketOwner?: string; } /** diff --git a/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts index eb8036da645..83221ab22e9 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/abortMultipartUpload.ts @@ -15,6 +15,7 @@ import { import { MetadataBearer } from '@aws-sdk/types'; import { + assignStringVariables, buildStorageServiceError, s3TransferHandler, serializePathnameObjectKey, @@ -27,7 +28,7 @@ import { defaultConfig, parseXmlError } from './base'; export type AbortMultipartUploadInput = Pick< AbortMultipartUploadCommandInput, - 'Bucket' | 'Key' | 'UploadId' + 'Bucket' | 'Key' | 'UploadId' | 'ExpectedBucketOwner' >; export type AbortMultipartUploadOutput = MetadataBearer; @@ -48,10 +49,15 @@ const abortMultipartUploadSerializer = ( key: input.Key, objectURL: url, }); + const headers = { + ...assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), + }; return { method: 'DELETE', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts index e9f810d76ed..f638fa8b352 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/completeMultipartUpload.ts @@ -40,7 +40,12 @@ const INVALID_PARAMETER_ERROR_MSG = export type CompleteMultipartUploadInput = Pick< CompleteMultipartUploadCommandInput, - 'Bucket' | 'Key' | 'UploadId' | 'MultipartUpload' | 'ChecksumCRC32' + | 'Bucket' + | 'Key' + | 'UploadId' + | 'MultipartUpload' + | 'ChecksumCRC32' + | 'ExpectedBucketOwner' >; export type CompleteMultipartUploadOutput = Pick< @@ -54,7 +59,10 @@ const completeMultipartUploadSerializer = async ( ): Promise => { const headers = { 'content-type': 'application/xml', - ...assignStringVariables({ 'x-amz-checksum-crc32': input.ChecksumCRC32 }), + ...assignStringVariables({ + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), }; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); diff --git a/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts index e88d4b24594..3e65ab03671 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/copyObject.ts @@ -42,6 +42,8 @@ export type CopyObjectInput = Pick< | 'Metadata' | 'CopySourceIfUnmodifiedSince' | 'CopySourceIfMatch' + | 'ExpectedSourceBucketOwner' + | 'ExpectedBucketOwner' >; export type CopyObjectOutput = CopyObjectCommandOutput; @@ -58,6 +60,8 @@ const copyObjectSerializer = async ( 'x-amz-copy-source-if-match': input.CopySourceIfMatch, 'x-amz-copy-source-if-unmodified-since': input.CopySourceIfUnmodifiedSince?.toISOString(), + 'x-amz-source-expected-bucket-owner': input.ExpectedSourceBucketOwner, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, }), }; validateCopyObjectHeaders(input, headers); diff --git a/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts index 90ce7329dc3..86a9e5cb89a 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/createMultipartUpload.ts @@ -47,6 +47,7 @@ const createMultipartUploadSerializer = async ( ...(await serializeObjectConfigsToHeaders(input)), ...assignStringVariables({ 'x-amz-checksum-algorithm': input.ChecksumAlgorithm, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, }), }; const url = new AmplifyUrl(endpoint.url.toString()); diff --git a/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts index ad56619ac25..ebbba829d94 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/deleteObject.ts @@ -11,6 +11,7 @@ import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; import { + assignStringVariables, buildStorageServiceError, deserializeBoolean, map, @@ -28,7 +29,7 @@ import { defaultConfig, parseXmlError } from './base'; export type DeleteObjectInput = Pick< DeleteObjectCommandInput, - 'Bucket' | 'Key' + 'Bucket' | 'Key' | 'ExpectedBucketOwner' >; export type DeleteObjectOutput = DeleteObjectCommandOutput; @@ -45,10 +46,13 @@ const deleteObjectSerializer = ( key: input.Key, objectURL: url, }); + const headers = assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }); return { method: 'DELETE', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts index 7be47c20ebe..fca84d1b570 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/getObject.ts @@ -16,6 +16,7 @@ import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { CONTENT_SHA256_HEADER, + assignStringVariables, buildStorageServiceError, deserializeBoolean, deserializeMetadata, @@ -48,6 +49,7 @@ export type GetObjectInput = Pick< | 'Range' | 'ResponseContentDisposition' | 'ResponseContentType' + | 'ExpectedBucketOwner' >; export type GetObjectOutput = GetObjectCommandOutput; @@ -69,6 +71,9 @@ const getObjectSerializer = async ( method: 'GET', headers: { ...(input.Range && { Range: input.Range }), + ...assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), }, url, }; diff --git a/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts index 4a1e5f3e22b..c3fc64fb425 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/headObject.ts @@ -11,6 +11,7 @@ import { AmplifyUrl } from '@aws-amplify/core/internals/utils'; import { composeServiceApi } from '@aws-amplify/core/internals/aws-client-utils/composers'; import { + assignStringVariables, buildStorageServiceError, deserializeMetadata, deserializeNumber, @@ -25,7 +26,10 @@ import { validateObjectUrl } from '../../validateObjectUrl'; import { defaultConfig, parseXmlError } from './base'; import type { HeadObjectCommandInput, HeadObjectCommandOutput } from './types'; -export type HeadObjectInput = Pick; +export type HeadObjectInput = Pick< + HeadObjectCommandInput, + 'Bucket' | 'Key' | 'ExpectedBucketOwner' +>; export type HeadObjectOutput = Pick< HeadObjectCommandOutput, @@ -50,10 +54,13 @@ const headObjectSerializer = async ( key: input.Key, objectURL: url, }); + const headers = assignStringVariables({ + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }); return { method: 'HEAD', - headers: {}, + headers, url, }; }; diff --git a/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts index 8b2f56d0e78..e82182c1a51 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/putObject.ts @@ -39,6 +39,7 @@ export type PutObjectInput = Pick< | 'Metadata' | 'Tagging' | 'ChecksumCRC32' + | 'ExpectedBucketOwner' >; export type PutObjectOutput = Pick< @@ -57,8 +58,11 @@ const putObjectSerializer = async ( ...input, ContentType: input.ContentType ?? 'application/octet-stream', })), - ...assignStringVariables({ 'content-md5': input.ContentMD5 }), - ...assignStringVariables({ 'x-amz-checksum-crc32': input.ChecksumCRC32 }), + ...assignStringVariables({ + 'content-md5': input.ContentMD5, + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), }; const url = new AmplifyUrl(endpoint.url.toString()); validateS3RequiredParameter(!!input.Key, 'Key'); diff --git a/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts b/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts index c34e1abc9c7..629f352e42d 100644 --- a/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts +++ b/packages/storage/src/providers/s3/utils/client/s3data/uploadPart.ts @@ -37,6 +37,7 @@ export type UploadPartInput = Pick< | 'Key' | 'ContentMD5' | 'ChecksumCRC32' + | 'ExpectedBucketOwner' >; export type UploadPartOutput = Pick< @@ -49,8 +50,11 @@ const uploadPartSerializer = async ( endpoint: Endpoint, ): Promise => { const headers = { - ...assignStringVariables({ 'x-amz-checksum-crc32': input.ChecksumCRC32 }), - ...assignStringVariables({ 'content-md5': input.ContentMD5 }), + ...assignStringVariables({ + 'x-amz-checksum-crc32': input.ChecksumCRC32, + 'content-md5': input.ContentMD5, + 'x-amz-expected-bucket-owner': input.ExpectedBucketOwner, + }), 'content-type': 'application/octet-stream', }; const url = new AmplifyUrl(endpoint.url.toString()); diff --git a/packages/storage/src/providers/s3/utils/index.ts b/packages/storage/src/providers/s3/utils/index.ts index cd6b9753019..3a1a05c6a8d 100644 --- a/packages/storage/src/providers/s3/utils/index.ts +++ b/packages/storage/src/providers/s3/utils/index.ts @@ -4,6 +4,7 @@ export { calculateContentMd5 } from './md5'; export { resolveS3ConfigAndInput } from './resolveS3ConfigAndInput'; export { createDownloadTask, createUploadTask } from './transferTask'; +export { validateBucketOwnerID } from './validateBucketOwnerID'; export { validateStorageOperationInput } from './validateStorageOperationInput'; export { validateStorageOperationInputWithPrefix } from './validateStorageOperationInputWithPrefix'; export { isInputWithPath } from './isInputWithPath'; diff --git a/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts b/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts new file mode 100644 index 00000000000..d43e91b5e17 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/validateBucketOwnerID.ts @@ -0,0 +1,18 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { StorageValidationErrorCode } from '../../../errors/types/validation'; +import { assertValidationError } from '../../../errors/utils/assertValidationError'; + +const VALID_AWS_ACCOUNT_ID_PATTERN = /^\d{12}/; + +export const validateBucketOwnerID = (accountID?: string) => { + if (accountID === undefined) { + return; + } + + assertValidationError( + VALID_AWS_ACCOUNT_ID_PATTERN.test(accountID), + StorageValidationErrorCode.InvalidAWSAccountID, + ); +};