From e44d290313a4b2f7443d55380282193499a7abde Mon Sep 17 00:00:00 2001 From: Jim Blanchard Date: Tue, 16 Jul 2024 20:05:09 -0500 Subject: [PATCH 1/4] feat: Implement getLocationCredentials handler & integrate with adapter (#13600) --- packages/aws-amplify/package.json | 12 +- packages/core/src/Platform/types.ts | 1 + .../storageBrowser/apis/getDataAccess.test.ts | 116 ++++++++++++++++++ .../src/storageBrowser/apis/constants.ts | 4 + .../src/storageBrowser/apis/getDataAccess.ts | 66 ++++++++++ .../{ => apis}/listCallerAccessGrants.ts | 13 +- .../storage/src/storageBrowser/apis/types.ts | 33 +++++ .../createLocationCredentialsHandler.ts | 32 ++++- .../createManagedAuthConfigAdapter.ts | 1 + packages/storage/src/storageBrowser/types.ts | 29 ++++- 10 files changed, 284 insertions(+), 23 deletions(-) create mode 100644 packages/storage/__tests__/storageBrowser/apis/getDataAccess.test.ts create mode 100644 packages/storage/src/storageBrowser/apis/constants.ts create mode 100644 packages/storage/src/storageBrowser/apis/getDataAccess.ts rename packages/storage/src/storageBrowser/{ => apis}/listCallerAccessGrants.ts (55%) create mode 100644 packages/storage/src/storageBrowser/apis/types.ts diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index d4cfecc01d7..ee7960ade9a 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,7 +293,7 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.09 kB" + "limit": "17.11 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -317,7 +317,7 @@ "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.59 kB" + "limit": "15.60 kB" }, { "name": "[Analytics] enable", @@ -383,7 +383,7 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.27 kB" + "limit": "28.28 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", @@ -401,7 +401,7 @@ "name": "[Auth] verifyTOTPSetup (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ verifyTOTPSetup }", - "limit": "12.6 kB" + "limit": "12.7 kB" }, { "name": "[Auth] updatePassword (Cognito)", @@ -449,13 +449,13 @@ "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.06 kB" + "limit": "30.07 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "21.47 kB" + "limit": "21.49 kB" }, { "name": "[Storage] copy (S3)", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index 96ca1de77ec..aa49d1f06b6 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -120,6 +120,7 @@ export enum StorageAction { Remove = '5', GetProperties = '6', GetUrl = '7', + GetDataAccess = '8', } interface ActionMap { diff --git a/packages/storage/__tests__/storageBrowser/apis/getDataAccess.test.ts b/packages/storage/__tests__/storageBrowser/apis/getDataAccess.test.ts new file mode 100644 index 00000000000..0753e0ae334 --- /dev/null +++ b/packages/storage/__tests__/storageBrowser/apis/getDataAccess.test.ts @@ -0,0 +1,116 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getDataAccess } from '../../../src/storageBrowser/apis/getDataAccess'; +import { getDataAccess as getDataAccessClient } from '../../../src/providers/s3/utils/client/s3control'; +import { GetDataAccessInput } from '../../../src/storageBrowser/apis/types'; + +jest.mock('../../../src/providers/s3/utils/client/s3control'); + +const MOCK_ACCOUNT_ID = 'accountId'; +const MOCK_REGION = 'us-east-2'; +const MOCK_ACCESS_ID = 'accessId'; +const MOCK_SECRET_ACCESS_KEY = 'secretAccessKey'; +const MOCK_SESSION_TOKEN = 'sessionToken'; +const MOCK_EXPIRATION = '2013-09-17T18:07:53.000Z'; +const MOCK_EXPIRATION_DATE = new Date(MOCK_EXPIRATION); +const MOCK_SCOPE = 's3://mybucket/files/*'; +const MOCK_CREDENTIALS = { + credentials: { + accessKeyId: MOCK_ACCESS_ID, + secretAccessKey: MOCK_SECRET_ACCESS_KEY, + sessionToken: MOCK_SESSION_TOKEN, + expiration: MOCK_EXPIRATION_DATE, + }, +}; +const MOCK_ACCESS_CREDENTIALS = { + AccessKeyId: MOCK_ACCESS_ID, + SecretAccessKey: MOCK_SECRET_ACCESS_KEY, + SessionToken: MOCK_SESSION_TOKEN, + Expiration: MOCK_EXPIRATION_DATE, +}; +const MOCK_CREDENTIAL_PROVIDER = async () => MOCK_CREDENTIALS; + +const sharedGetDataAccessParams: GetDataAccessInput = { + accountId: MOCK_ACCOUNT_ID, + credentialsProvider: MOCK_CREDENTIAL_PROVIDER, + durationSeconds: 900, + permission: 'READWRITE', + region: MOCK_REGION, + scope: MOCK_SCOPE, +}; + +describe('getDataAccess', () => { + const getDataAccessClientMock = getDataAccessClient as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + getDataAccessClientMock.mockResolvedValue({ + Credentials: MOCK_ACCESS_CREDENTIALS, + MatchedGrantTarget: MOCK_SCOPE, + }); + }); + + it('should invoke the getDataAccess client correctly', async () => { + const result = await getDataAccess(sharedGetDataAccessParams); + + expect(getDataAccessClientMock).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: MOCK_CREDENTIALS.credentials, + region: MOCK_REGION, + userAgentValue: expect.stringContaining('storage/8'), + }), + expect.objectContaining({ + AccountId: MOCK_ACCOUNT_ID, + Target: MOCK_SCOPE, + Permission: 'READWRITE', + TargetType: undefined, + DurationSeconds: 900, + }), + ); + + expect(result.credentials).toEqual(MOCK_CREDENTIALS.credentials); + expect(result.scope).toEqual(MOCK_SCOPE); + }); + + it('should throw an error if the service does not return credentials', async () => { + expect.assertions(1); + + getDataAccessClientMock.mockResolvedValue({ + Credentials: undefined, + MatchedGrantTarget: MOCK_SCOPE, + }); + + expect(getDataAccess(sharedGetDataAccessParams)).rejects.toThrow( + 'Service did not return credentials.', + ); + }); + + it('should set the correct target type when accessing an object', async () => { + const MOCK_OBJECT_SCOPE = 's3://mybucket/files/file.md'; + + getDataAccessClientMock.mockResolvedValue({ + Credentials: MOCK_ACCESS_CREDENTIALS, + MatchedGrantTarget: MOCK_OBJECT_SCOPE, + }); + + const result = await getDataAccess({ + ...sharedGetDataAccessParams, + scope: MOCK_OBJECT_SCOPE, + }); + + expect(getDataAccessClientMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + AccountId: MOCK_ACCOUNT_ID, + Target: MOCK_OBJECT_SCOPE, + Permission: 'READWRITE', + TargetType: 'Object', + DurationSeconds: 900, + }), + ); + + expect(result.scope).toEqual(MOCK_OBJECT_SCOPE); + }); +}); diff --git a/packages/storage/src/storageBrowser/apis/constants.ts b/packages/storage/src/storageBrowser/apis/constants.ts new file mode 100644 index 00000000000..e333ac5a5e2 --- /dev/null +++ b/packages/storage/src/storageBrowser/apis/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const DEFAULT_CRED_TTL = 15 * 60; // 15 minutes diff --git a/packages/storage/src/storageBrowser/apis/getDataAccess.ts b/packages/storage/src/storageBrowser/apis/getDataAccess.ts new file mode 100644 index 00000000000..5e5bec23540 --- /dev/null +++ b/packages/storage/src/storageBrowser/apis/getDataAccess.ts @@ -0,0 +1,66 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AmplifyErrorCode, + StorageAction, +} from '@aws-amplify/core/internals/utils'; + +import { getStorageUserAgentValue } from '../../providers/s3/utils/userAgent'; +import { getDataAccess as getDataAccessClient } from '../../providers/s3/utils/client/s3control'; +import { StorageError } from '../../errors/StorageError'; +import { logger } from '../../utils'; + +import { GetDataAccessInput, GetDataAccessOutput } from './types'; +import { DEFAULT_CRED_TTL } from './constants'; + +export const getDataAccess = async ( + input: GetDataAccessInput, +): Promise => { + const targetType = input.scope.endsWith('*') ? undefined : 'Object'; + const { credentials } = await input.credentialsProvider(); + + const result = await getDataAccessClient( + { + credentials, + region: input.region, + userAgentValue: getStorageUserAgentValue(StorageAction.GetDataAccess), + }, + { + AccountId: input.accountId, + Target: input.scope, + Permission: input.permission, + TargetType: targetType, + DurationSeconds: DEFAULT_CRED_TTL, + }, + ); + + const grantCredentials = result.Credentials; + + // Ensure that S3 returned credentials (this shouldn't happen) + if (!grantCredentials) { + throw new StorageError({ + name: AmplifyErrorCode.Unknown, + message: 'Service did not return credentials.', + }); + } else { + logger.debug(`Retrieved credentials for: ${result.MatchedGrantTarget}`); + } + + const { + AccessKeyId: accessKeyId, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + Expiration: expiration, + } = grantCredentials; + + return { + credentials: { + accessKeyId: accessKeyId!, + secretAccessKey: secretAccessKey!, + sessionToken, + expiration, + }, + scope: result.MatchedGrantTarget, + }; +}; diff --git a/packages/storage/src/storageBrowser/listCallerAccessGrants.ts b/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts similarity index 55% rename from packages/storage/src/storageBrowser/listCallerAccessGrants.ts rename to packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts index c55b0ea75e8..dbe8b305b36 100644 --- a/packages/storage/src/storageBrowser/listCallerAccessGrants.ts +++ b/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts @@ -1,15 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { AccessGrant, CredentialsProvider, ListLocationsOutput } from './types'; - -export interface ListCallerAccessGrantsInput { - accountId: string; - credentialsProvider: CredentialsProvider; - region: string; -} - -export type ListCallerAccessGrantsOutput = ListLocationsOutput; +import { + ListCallerAccessGrantsInput, + ListCallerAccessGrantsOutput, +} from './types'; export const listCallerAccessGrants = ( // eslint-disable-next-line unused-imports/no-unused-vars diff --git a/packages/storage/src/storageBrowser/apis/types.ts b/packages/storage/src/storageBrowser/apis/types.ts new file mode 100644 index 00000000000..2928cfd1f38 --- /dev/null +++ b/packages/storage/src/storageBrowser/apis/types.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + AccessGrant, + CredentialsProvider, + ListLocationsOutput, + LocationCredentials, + Permission, + PrefixType, + Privilege, +} from '../types'; + +export interface ListCallerAccessGrantsInput { + accountId: string; + credentialsProvider: CredentialsProvider; + region: string; +} + +export type ListCallerAccessGrantsOutput = ListLocationsOutput; + +export interface GetDataAccessInput { + accountId: string; + credentialsProvider: CredentialsProvider; + durationSeconds?: number; + permission: Permission; + prefixType?: PrefixType; + privilege?: Privilege; + region: string; + scope: string; +} + +export type GetDataAccessOutput = LocationCredentials; diff --git a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createLocationCredentialsHandler.ts b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createLocationCredentialsHandler.ts index 9cded212928..248e81882ac 100644 --- a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createLocationCredentialsHandler.ts +++ b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createLocationCredentialsHandler.ts @@ -1,7 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { CredentialsProvider, GetLocationCredentials } from '../types'; +import { getDataAccess } from '../apis/getDataAccess'; +import { + CredentialsProvider, + GetLocationCredentials, + GetLocationCredentialsInput, +} from '../types'; interface CreateLocationCredentialsHandlerInput { accountId: string; @@ -10,9 +15,26 @@ interface CreateLocationCredentialsHandlerInput { } export const createLocationCredentialsHandler = ( - // eslint-disable-next-line unused-imports/no-unused-vars - input: CreateLocationCredentialsHandlerInput, + handlerInput: CreateLocationCredentialsHandlerInput, ): GetLocationCredentials => { - // TODO(@AllanZhengYP) - throw new Error('Not Implemented'); + const { accountId, region, credentialsProvider } = handlerInput; + + /** + * Retrieves credentials for the specified scope & permission. + * + * @param input - An object specifying the requested scope & permission. + * + * @returns A promise which will resolve with the requested credentials. + */ + return (input: GetLocationCredentialsInput) => { + const { scope, permission } = input; + + return getDataAccess({ + accountId, + credentialsProvider, + permission, + region, + scope, + }); + }; }; diff --git a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createManagedAuthConfigAdapter.ts b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createManagedAuthConfigAdapter.ts index 55d334b47c7..267fee96c21 100644 --- a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createManagedAuthConfigAdapter.ts +++ b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createManagedAuthConfigAdapter.ts @@ -38,6 +38,7 @@ export const createManagedAuthConfigAdapter = ({ accountId, region, }); + const getLocationCredentials = createLocationCredentialsHandler({ credentialsProvider, accountId, diff --git a/packages/storage/src/storageBrowser/types.ts b/packages/storage/src/storageBrowser/types.ts index 68fe71aadaf..c770b7472a3 100644 --- a/packages/storage/src/storageBrowser/types.ts +++ b/packages/storage/src/storageBrowser/types.ts @@ -22,7 +22,17 @@ export type CredentialsProvider = (options?: { */ export type LocationType = 'BUCKET' | 'PREFIX' | 'OBJECT'; -export interface CredentialsLocation { +/** + * @internal + */ +export type Privilege = 'Default' | 'Minimal'; + +/** + * @internal + */ +export type PrefixType = 'Object'; + +export interface LocationScope { /** * Scope of storage location. For S3 service, it's the S3 path of the data to * which the access is granted. It can be in following formats: @@ -32,6 +42,9 @@ export interface CredentialsLocation { * @example Object 's3:////' */ readonly scope: string; +} + +export interface CredentialsLocation extends LocationScope { /** * The type of access granted to your Storage data. Can be either of READ, * WRITE or READWRITE @@ -52,6 +65,13 @@ export interface LocationAccess extends CredentialsLocation { readonly type: LocationType; } +export interface LocationCredentials extends Partial { + /** + * AWS credentials which can be used to access the specified location. + */ + readonly credentials: AWSCredentials; +} + export interface AccessGrant extends LocationAccess { /** * The Amazon Resource Name (ARN) of an AWS IAM Identity Center application @@ -82,9 +102,12 @@ export type ListLocations = ( input?: ListLocationsInput, ) => Promise>; +export type GetLocationCredentialsInput = CredentialsLocation; +export type GetLocationCredentialsOutput = LocationCredentials; + export type GetLocationCredentials = ( - input: CredentialsLocation, -) => Promise<{ credentials: AWSCredentials }>; + input: GetLocationCredentialsInput, +) => Promise; export interface LocationCredentialsStore { /** From a8f8e6e747e40e3efc9ea4e08c549a79132b8c95 Mon Sep 17 00:00:00 2001 From: AllanZhengYP Date: Wed, 17 Jul 2024 15:24:06 -0700 Subject: [PATCH 2/4] feat(storage): implement listLocations API and creation handler (#13602) --- packages/aws-amplify/package.json | 28 ++--- packages/core/src/Platform/types.ts | 1 + packages/interactions/package.json | 6 +- .../apis/listCallerAccessGrants.test.ts | 116 ++++++++++++++++++ .../createListLocationsHandler.test.ts | 34 +++++ .../src/storageBrowser/apis/constants.ts | 1 + .../apis/listCallerAccessGrants.ts | 98 ++++++++++++++- .../storage/src/storageBrowser/apis/types.ts | 3 +- .../createListLocationsHandler.ts | 25 +++- 9 files changed, 285 insertions(+), 27 deletions(-) create mode 100644 packages/storage/__tests__/storageBrowser/apis/listCallerAccessGrants.test.ts create mode 100644 packages/storage/__tests__/storageBrowser/managedAuthAdapter/createListLocationsHandler.test.ts diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index ee7960ade9a..ccf5001d233 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -293,7 +293,7 @@ "name": "[Analytics] record (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ record }", - "limit": "17.11 kB" + "limit": "17.14 kB" }, { "name": "[Analytics] record (Kinesis)", @@ -311,13 +311,13 @@ "name": "[Analytics] record (Personalize)", "path": "./dist/esm/analytics/personalize/index.mjs", "import": "{ record }", - "limit": "49.50 kB" + "limit": "49.53 kB" }, { "name": "[Analytics] identifyUser (Pinpoint)", "path": "./dist/esm/analytics/index.mjs", "import": "{ identifyUser }", - "limit": "15.60 kB" + "limit": "15.64 kB" }, { "name": "[Analytics] enable", @@ -353,13 +353,13 @@ "name": "[Auth] resetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resetPassword }", - "limit": "12.44 kB" + "limit": "12.48 kB" }, { "name": "[Auth] confirmResetPassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmResetPassword }", - "limit": "12.39 kB" + "limit": "12.42 kB" }, { "name": "[Auth] signIn (Cognito)", @@ -371,7 +371,7 @@ "name": "[Auth] resendSignUpCode (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ resendSignUpCode }", - "limit": "12.40 kB" + "limit": "12.44 kB" }, { "name": "[Auth] confirmSignUp (Cognito)", @@ -383,19 +383,19 @@ "name": "[Auth] confirmSignIn (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmSignIn }", - "limit": "28.28 kB" + "limit": "28.32 kB" }, { "name": "[Auth] updateMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updateMFAPreference }", - "limit": "11.74 kB" + "limit": "11.78 kB" }, { "name": "[Auth] fetchMFAPreference (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchMFAPreference }", - "limit": "11.78 kB" + "limit": "11.81 kB" }, { "name": "[Auth] verifyTOTPSetup (Cognito)", @@ -407,7 +407,7 @@ "name": "[Auth] updatePassword (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ updatePassword }", - "limit": "12.63 kB" + "limit": "12.67 kB" }, { "name": "[Auth] setUpTOTP (Cognito)", @@ -431,7 +431,7 @@ "name": "[Auth] confirmUserAttribute (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ confirmUserAttribute }", - "limit": "12.61 kB" + "limit": "12.64 kB" }, { "name": "[Auth] signInWithRedirect (Cognito)", @@ -443,19 +443,19 @@ "name": "[Auth] fetchUserAttributes (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ fetchUserAttributes }", - "limit": "11.69 kB" + "limit": "11.72 kB" }, { "name": "[Auth] Basic Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signIn, signOut, fetchAuthSession, confirmSignIn }", - "limit": "30.07 kB" + "limit": "30.11 kB" }, { "name": "[Auth] OAuth Auth Flow (Cognito)", "path": "./dist/esm/auth/index.mjs", "import": "{ signInWithRedirect, signOut, fetchAuthSession }", - "limit": "21.49 kB" + "limit": "21.52 kB" }, { "name": "[Storage] copy (S3)", diff --git a/packages/core/src/Platform/types.ts b/packages/core/src/Platform/types.ts index aa49d1f06b6..1d430706608 100644 --- a/packages/core/src/Platform/types.ts +++ b/packages/core/src/Platform/types.ts @@ -121,6 +121,7 @@ export enum StorageAction { GetProperties = '6', GetUrl = '7', GetDataAccess = '8', + ListCallerAccessGrants = '9', } interface ActionMap { diff --git a/packages/interactions/package.json b/packages/interactions/package.json index faebbd94fae..89f29d4b005 100644 --- a/packages/interactions/package.json +++ b/packages/interactions/package.json @@ -89,19 +89,19 @@ "name": "Interactions (default to Lex v2)", "path": "./dist/esm/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.55 kB" }, { "name": "Interactions (Lex v2)", "path": "./dist/esm/lex-v2/index.mjs", "import": "{ Interactions }", - "limit": "52.52 kB" + "limit": "52.55 kB" }, { "name": "Interactions (Lex v1)", "path": "./dist/esm/lex-v1/index.mjs", "import": "{ Interactions }", - "limit": "47.33 kB" + "limit": "47.37 kB" } ] } diff --git a/packages/storage/__tests__/storageBrowser/apis/listCallerAccessGrants.test.ts b/packages/storage/__tests__/storageBrowser/apis/listCallerAccessGrants.test.ts new file mode 100644 index 00000000000..3e0051f7461 --- /dev/null +++ b/packages/storage/__tests__/storageBrowser/apis/listCallerAccessGrants.test.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import { listCallerAccessGrants } from '../../../src/storageBrowser/apis/listCallerAccessGrants'; +import { listCallerAccessGrants as listCallerAccessGrantsClient } from '../../../src/providers/s3/utils/client/s3control'; + +jest.mock('../../../src/providers/s3/utils/client/s3control'); + +const mockAccountId = '1234567890'; +const mockRegion = 'us-foo-2'; +const mockCredentialsProvider = jest.fn(); +const mockNextToken = '123'; +const mockPageSize = 123; + +describe('listCallerAccessGrants', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should invoke the listCallerAccessGrants client with expected parameters', async () => { + expect.assertions(1); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [], + $metadata: {} as any, + }); + await listCallerAccessGrants({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + nextToken: mockNextToken, + pageSize: mockPageSize, + }); + expect(listCallerAccessGrantsClient).toHaveBeenCalledWith( + expect.objectContaining({ + region: mockRegion, + credentials: expect.any(Function), + }), + expect.objectContaining({ + AccountId: mockAccountId, + NextToken: mockNextToken, + MaxResults: mockPageSize, + }), + ); + }); + + it('should set a default page size', async () => { + expect.assertions(1); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [], + $metadata: {} as any, + }); + await listCallerAccessGrants({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + }); + expect(listCallerAccessGrantsClient).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + MaxResults: 1000, + }), + ); + }); + + it('should set response location type correctly', async () => { + expect.assertions(2); + jest.mocked(listCallerAccessGrantsClient).mockResolvedValue({ + NextToken: undefined, + CallerAccessGrantsList: [ + { + GrantScope: 's3://bucket/*', + Permission: 'READ', + }, + { + GrantScope: 's3://bucket/path/*', + Permission: 'READWRITE', + }, + { + GrantScope: 's3://bucket/path/to/object', + Permission: 'READ', + ApplicationArn: 'arn:123', + }, + ], + $metadata: {} as any, + }); + const { locations, nextToken } = await listCallerAccessGrants({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + }); + + expect(locations).toEqual([ + { + scope: 's3://bucket/*', + type: 'BUCKET', + permission: 'READ', + applicationArn: undefined, + }, + { + scope: 's3://bucket/path/*', + type: 'PREFIX', + permission: 'READWRITE', + applicationArn: undefined, + }, + { + scope: 's3://bucket/path/to/object', + type: 'OBJECT', + permission: 'READ', + applicationArn: 'arn:123', + }, + ]); + expect(nextToken).toBeUndefined(); + }); +}); diff --git a/packages/storage/__tests__/storageBrowser/managedAuthAdapter/createListLocationsHandler.test.ts b/packages/storage/__tests__/storageBrowser/managedAuthAdapter/createListLocationsHandler.test.ts new file mode 100644 index 00000000000..c2104ce728a --- /dev/null +++ b/packages/storage/__tests__/storageBrowser/managedAuthAdapter/createListLocationsHandler.test.ts @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createListLocationsHandler } from '../../../src/storageBrowser/managedAuthConfigAdapter/createListLocationsHandler'; +import { listCallerAccessGrants } from '../../../src/storageBrowser/apis/listCallerAccessGrants'; + +jest.mock('../../../src/storageBrowser/apis/listCallerAccessGrants'); + +jest.mocked(listCallerAccessGrants).mockResolvedValue({ + locations: [], +}); + +describe('createListLocationsHandler', () => { + it('should parse the underlying API with right parameters', async () => { + const mockAccountId = '1234567890'; + const mockRegion = 'us-foo-1'; + const mockCredentialsProvider = jest.fn(); + const mockNextToken = '123'; + const mockPageSize = 123; + const handler = createListLocationsHandler({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + }); + await handler({ nextToken: mockNextToken, pageSize: mockPageSize }); + expect(listCallerAccessGrants).toHaveBeenCalledWith({ + accountId: mockAccountId, + region: mockRegion, + credentialsProvider: mockCredentialsProvider, + nextToken: mockNextToken, + pageSize: mockPageSize, + }); + }); +}); diff --git a/packages/storage/src/storageBrowser/apis/constants.ts b/packages/storage/src/storageBrowser/apis/constants.ts index e333ac5a5e2..4c322de94f1 100644 --- a/packages/storage/src/storageBrowser/apis/constants.ts +++ b/packages/storage/src/storageBrowser/apis/constants.ts @@ -2,3 +2,4 @@ // SPDX-License-Identifier: Apache-2.0 export const DEFAULT_CRED_TTL = 15 * 60; // 15 minutes +export const MAX_PAGE_SIZE = 1000; diff --git a/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts b/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts index dbe8b305b36..957e6eb1fcb 100644 --- a/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts +++ b/packages/storage/src/storageBrowser/apis/listCallerAccessGrants.ts @@ -1,15 +1,105 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { StorageAction } from '@aws-amplify/core/internals/utils'; + +import { logger } from '../../utils'; +import { listCallerAccessGrants as listCallerAccessGrantsClient } from '../../providers/s3/utils/client/s3control'; +import { AccessGrant, LocationType, Permission } from '../types'; +import { StorageError } from '../../errors/StorageError'; +import { getStorageUserAgentValue } from '../../providers/s3/utils/userAgent'; + import { ListCallerAccessGrantsInput, ListCallerAccessGrantsOutput, } from './types'; +import { MAX_PAGE_SIZE } from './constants'; -export const listCallerAccessGrants = ( - // eslint-disable-next-line unused-imports/no-unused-vars +export const listCallerAccessGrants = async ( input: ListCallerAccessGrantsInput, ): Promise => { - // TODO(@AllanZhengYP) - throw new Error('Not Implemented'); + const { credentialsProvider, accountId, region, nextToken, pageSize } = input; + + logger.debug(`listing available locations from account ${input.accountId}`); + + if (!!pageSize && pageSize > MAX_PAGE_SIZE) { + logger.debug(`defaulting pageSize to ${MAX_PAGE_SIZE}.`); + } + + const clientCredentialsProvider = async () => { + const { credentials } = await credentialsProvider(); + + return credentials; + }; + + const { CallerAccessGrantsList, NextToken } = + await listCallerAccessGrantsClient( + { + credentials: clientCredentialsProvider, + region, + userAgentValue: getStorageUserAgentValue( + StorageAction.ListCallerAccessGrants, + ), + }, + { + AccountId: accountId, + NextToken: nextToken, + MaxResults: pageSize ?? MAX_PAGE_SIZE, + }, + ); + + const accessGrants: AccessGrant[] = + CallerAccessGrantsList?.map(grant => { + // These values are correct from service mostly, but we add assertions to make TSC happy. + assertPermission(grant.Permission); + assertGrantScope(grant.GrantScope); + + return { + scope: grant.GrantScope, + permission: grant.Permission, + applicationArn: grant.ApplicationArn, + type: parseGrantType(grant.GrantScope!), + }; + }) ?? []; + + return { + locations: accessGrants, + nextToken: NextToken, + }; }; + +const parseGrantType = (grantScope: string): LocationType => { + const bucketScopeReg = /^s3:\/\/(.*)\/\*$/; + const possibleBucketName = grantScope.match(bucketScopeReg)?.[1]; + if (!grantScope.endsWith('*')) { + return 'OBJECT'; + } else if ( + grantScope.endsWith('/*') && + possibleBucketName && + possibleBucketName.indexOf('/') === -1 + ) { + return 'BUCKET'; + } else { + return 'PREFIX'; + } +}; + +function assertPermission( + permissionValue: string | undefined, +): asserts permissionValue is Permission { + if (!['READ', 'READWRITE', 'WRITE'].includes(permissionValue ?? '')) { + throw new StorageError({ + name: 'InvalidPermission', + message: `Invalid permission: ${permissionValue}`, + }); + } +} + +function assertGrantScope(value: unknown): asserts value is string { + if (typeof value !== 'string' || !value.startsWith('s3://')) { + throw new StorageError({ + name: 'InvalidGrantScope', + message: `Expected a valid grant scope, got ${value}`, + }); + } +} diff --git a/packages/storage/src/storageBrowser/apis/types.ts b/packages/storage/src/storageBrowser/apis/types.ts index 2928cfd1f38..c97a7c4bbdd 100644 --- a/packages/storage/src/storageBrowser/apis/types.ts +++ b/packages/storage/src/storageBrowser/apis/types.ts @@ -4,6 +4,7 @@ import { AccessGrant, CredentialsProvider, + ListLocationsInput, ListLocationsOutput, LocationCredentials, Permission, @@ -11,7 +12,7 @@ import { Privilege, } from '../types'; -export interface ListCallerAccessGrantsInput { +export interface ListCallerAccessGrantsInput extends ListLocationsInput { accountId: string; credentialsProvider: CredentialsProvider; region: string; diff --git a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createListLocationsHandler.ts b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createListLocationsHandler.ts index c3e9c3c1a4a..eb224b6ab3c 100644 --- a/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createListLocationsHandler.ts +++ b/packages/storage/src/storageBrowser/managedAuthConfigAdapter/createListLocationsHandler.ts @@ -2,17 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 import { CredentialsProvider, ListLocations } from '../types'; +import { listCallerAccessGrants } from '../apis/listCallerAccessGrants'; -export interface CreateListLocationsHandlerInput { +interface CreateListLocationsHandlerInput { accountId: string; credentialsProvider: CredentialsProvider; region: string; } export const createListLocationsHandler = ( - // eslint-disable-next-line unused-imports/no-unused-vars - input: CreateListLocationsHandlerInput, + handlerInput: CreateListLocationsHandlerInput, ): ListLocations => { - // TODO(@AllanZhengYP) - throw new Error('Not Implemented'); + return async (input = {}) => { + const { nextToken, pageSize } = input; + const { locations, nextToken: newNextToken } = await listCallerAccessGrants( + { + accountId: handlerInput.accountId, + credentialsProvider: handlerInput.credentialsProvider, + region: handlerInput.region, + pageSize, + nextToken, + }, + ); + + return { + locations, + nextToken: newNextToken || undefined, + }; + }; }; From 5d5bea15744a6e943da30009e3eca62668653985 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Thu, 18 Jul 2024 12:01:09 -0700 Subject: [PATCH 3/4] chore: expose path storage-browser from scoped package (#13611) chore: expose path storage-browser from scoped package Co-authored-by: Ashwin Kumar --- packages/storage/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/storage/package.json b/packages/storage/package.json index 3de800029e0..264c20c0191 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -95,6 +95,11 @@ "import": "./dist/esm/providers/s3/server.mjs", "require": "./dist/cjs/providers/s3/server.js" }, + "./storage-browser": { + "types": "./dist/esm/storageBrowser/index.d.ts", + "import": "./dist/esm/storageBrowser/index.mjs", + "require": "./dist/cjs/storageBrowser/index.js" + }, "./package.json": "./package.json" }, "peerDependencies": { From c5464ac4c8962d365853c3c7d0d41aaca9881bf8 Mon Sep 17 00:00:00 2001 From: israx <70438514+israx@users.noreply.github.com> Date: Mon, 22 Jul 2024 15:55:38 -0400 Subject: [PATCH 4/4] feat(storage): enables location credentials provider (#13605) * feat: add location credentials provider * chore: add unit tests * chore: address feedback * chore: add locationCredentialsOption to copy * chore: remove casting types * chore: assert idenitity id * chore: avoid export common options interface * chore: address feedback * chore: fix test * chore: address feedback * address feedback * chore: clean-up types * chore: add test --- .../utils/resolveS3ConfigAndInput.test.ts | 100 +++++++++++++++++- packages/storage/src/errors/constants.ts | 4 + .../src/providers/s3/apis/downloadData.ts | 2 +- .../src/providers/s3/apis/internal/copy.ts | 13 ++- .../s3/apis/internal/getProperties.ts | 3 +- .../src/providers/s3/apis/internal/getUrl.ts | 2 +- .../src/providers/s3/apis/internal/list.ts | 2 +- .../src/providers/s3/apis/internal/remove.ts | 3 +- .../uploadData/multipart/uploadHandlers.ts | 2 +- .../s3/apis/uploadData/putObjectJob.ts | 2 +- .../storage/src/providers/s3/types/inputs.ts | 6 +- .../providers/s3/utils/resolveIdentityId.ts | 11 ++ .../s3/utils/resolveS3ConfigAndInput.ts | 96 ++++++++++++++++- .../s3/utils/validateStorageOperationInput.ts | 6 +- ...validateStorageOperationInputWithPrefix.ts | 6 +- packages/storage/src/types/inputs.ts | 3 +- 16 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 packages/storage/src/errors/constants.ts create mode 100644 packages/storage/src/providers/s3/utils/resolveIdentityId.ts diff --git a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts index e26cb63b6c7..efae2febaaf 100644 --- a/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts +++ b/packages/storage/__tests__/providers/s3/apis/utils/resolveS3ConfigAndInput.test.ts @@ -9,6 +9,11 @@ import { StorageValidationErrorCode, validationErrorMap, } from '../../../../../src/errors/types/validation'; +import { + CallbackPathStorageInput, + DeprecatedStorageInput, +} from '../../../../../src/providers/s3/utils/resolveS3ConfigAndInput'; +import { INVALID_STORAGE_INPUT } from '../../../../../src/errors/constants'; jest.mock('@aws-amplify/core', () => ({ ConsoleLogger: jest.fn(), @@ -76,13 +81,11 @@ describe('resolveS3ConfigAndInput', () => { } }); - it('should throw if identityId is not available', async () => { + it('should not throw if identityId is not available', async () => { mockFetchAuthSession.mockResolvedValueOnce({ credentials, }); - await expect(resolveS3ConfigAndInput(Amplify, {})).rejects.toMatchObject( - validationErrorMap[StorageValidationErrorCode.NoIdentityId], - ); + expect(async () => resolveS3ConfigAndInput(Amplify, {})).not.toThrow(); }); it('should resolve bucket from S3 config', async () => { @@ -179,7 +182,7 @@ describe('resolveS3ConfigAndInput', () => { it('should resolve prefix with given access level', async () => { mockDefaultResolvePrefix.mockResolvedValueOnce('prefix'); const { keyPrefix } = await resolveS3ConfigAndInput(Amplify, { - accessLevel: 'someLevel' as any, + options: { accessLevel: 'someLevel' as any }, }); expect(mockDefaultResolvePrefix).toHaveBeenCalledWith({ accessLevel: 'someLevel', @@ -214,4 +217,91 @@ describe('resolveS3ConfigAndInput', () => { }); expect(keyPrefix).toEqual('prefix'); }); + + describe('with locationCredentialsProvider', () => { + const mockLocationCredentialsProvider = jest + .fn() + .mockReturnValue({ credentials }); + it('should resolve credentials without Amplify singleton', async () => { + mockGetConfig.mockReturnValue({ + Storage: { + S3: { + bucket, + region, + }, + }, + }); + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials(); + expect(mockLocationCredentialsProvider).toHaveBeenCalled(); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + it('should not throw when path is pass as a string', async () => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + path: 'my-path', + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + + if (typeof s3Config.credentials === 'function') { + const result = await s3Config.credentials(); + expect(mockLocationCredentialsProvider).toHaveBeenCalled(); + expect(result).toEqual(credentials); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + + describe('with deprecated or callback paths as inputs', () => { + const key = 'mock-value'; + const prefix = 'mock-value'; + const path = () => 'path'; + const deprecatedInputs: DeprecatedStorageInput[] = [ + { prefix }, + { key }, + { + source: { key }, + destination: { key }, + }, + ]; + const callbackPathInputs: CallbackPathStorageInput[] = [ + { path }, + { + destination: { path }, + source: { path }, + }, + ]; + + const testCases = [...deprecatedInputs, ...callbackPathInputs]; + + it.each(testCases)('should throw when input is %s', async input => { + const { s3Config } = await resolveS3ConfigAndInput(Amplify, { + ...input, + options: { + locationCredentialsProvider: mockLocationCredentialsProvider, + }, + }); + if (typeof s3Config.credentials === 'function') { + await expect(s3Config.credentials()).rejects.toThrow( + expect.objectContaining({ + name: INVALID_STORAGE_INPUT, + }), + ); + } else { + throw new Error('Expect credentials to be a function'); + } + }); + }); + }); }); diff --git a/packages/storage/src/errors/constants.ts b/packages/storage/src/errors/constants.ts new file mode 100644 index 00000000000..ca127c2e623 --- /dev/null +++ b/packages/storage/src/errors/constants.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const INVALID_STORAGE_INPUT = 'InvalidStorageInput'; diff --git a/packages/storage/src/providers/s3/apis/downloadData.ts b/packages/storage/src/providers/s3/apis/downloadData.ts index 009e75ae95b..32f0c558642 100644 --- a/packages/storage/src/providers/s3/apis/downloadData.ts +++ b/packages/storage/src/providers/s3/apis/downloadData.ts @@ -115,7 +115,7 @@ const downloadDataJob = > => { const { options: downloadDataOptions } = downloadDataInput; const { bucket, keyPrefix, s3Config, identityId } = - await resolveS3ConfigAndInput(Amplify, downloadDataOptions); + await resolveS3ConfigAndInput(Amplify, downloadDataInput); const { inputType, objectKey } = validateStorageOperationInput( downloadDataInput, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/copy.ts b/packages/storage/src/providers/s3/apis/internal/copy.ts index 0e1fc01eadf..44906a816dc 100644 --- a/packages/storage/src/providers/s3/apis/internal/copy.ts +++ b/packages/storage/src/providers/s3/apis/internal/copy.ts @@ -40,8 +40,10 @@ const copyWithPath = async ( input: CopyWithPathInput, ): Promise => { const { source, destination } = input; - const { s3Config, bucket, identityId } = - await resolveS3ConfigAndInput(amplify); + const { s3Config, bucket, identityId } = await resolveS3ConfigAndInput( + amplify, + input, + ); assertValidationError(!!source.path, StorageValidationErrorCode.NoSourcePath); assertValidationError( @@ -92,10 +94,13 @@ export const copyWithKey = async ( s3Config, bucket, keyPrefix: sourceKeyPrefix, - } = await resolveS3ConfigAndInput(amplify, input.source); + } = await resolveS3ConfigAndInput(amplify, { + ...input, + options: input.source, + }); const { keyPrefix: destinationKeyPrefix } = await resolveS3ConfigAndInput( amplify, - input.destination, + { ...input, options: input.destination }, ); // resolveS3ConfigAndInput does not make extra API calls or storage access if called repeatedly. // TODO(ashwinkumar6) V6-logger: warn `You may copy files from another user if the source level is "protected", currently it's ${srcLevel}` diff --git a/packages/storage/src/providers/s3/apis/internal/getProperties.ts b/packages/storage/src/providers/s3/apis/internal/getProperties.ts index 915f02db495..ac04b2dbe6e 100644 --- a/packages/storage/src/providers/s3/apis/internal/getProperties.ts +++ b/packages/storage/src/providers/s3/apis/internal/getProperties.ts @@ -24,9 +24,8 @@ export const getProperties = async ( input: GetPropertiesInput | GetPropertiesWithPathInput, action?: StorageAction, ): Promise => { - const { options: getPropertiesOptions } = input; const { s3Config, bucket, keyPrefix, identityId } = - await resolveS3ConfigAndInput(amplify, getPropertiesOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/getUrl.ts b/packages/storage/src/providers/s3/apis/internal/getUrl.ts index e8440ce80eb..7ea49a448c9 100644 --- a/packages/storage/src/providers/s3/apis/internal/getUrl.ts +++ b/packages/storage/src/providers/s3/apis/internal/getUrl.ts @@ -31,7 +31,7 @@ export const getUrl = async ( ): Promise => { const { options: getUrlOptions } = input; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, getUrlOptions); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, identityId, diff --git a/packages/storage/src/providers/s3/apis/internal/list.ts b/packages/storage/src/providers/s3/apis/internal/list.ts index 6f074858738..7a1ab47007a 100644 --- a/packages/storage/src/providers/s3/apis/internal/list.ts +++ b/packages/storage/src/providers/s3/apis/internal/list.ts @@ -58,7 +58,7 @@ export const list = async ( bucket, keyPrefix: generatedPrefix, identityId, - } = await resolveS3ConfigAndInput(amplify, options); + } = await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInputWithPrefix( input, diff --git a/packages/storage/src/providers/s3/apis/internal/remove.ts b/packages/storage/src/providers/s3/apis/internal/remove.ts index e2a9377f39e..d73a13346e4 100644 --- a/packages/storage/src/providers/s3/apis/internal/remove.ts +++ b/packages/storage/src/providers/s3/apis/internal/remove.ts @@ -23,9 +23,8 @@ export const remove = async ( amplify: AmplifyClassV6, input: RemoveInput | RemoveWithPathInput, ): Promise => { - const { options = {} } = input ?? {}; const { s3Config, keyPrefix, bucket, identityId } = - await resolveS3ConfigAndInput(amplify, options); + await resolveS3ConfigAndInput(amplify, input); const { inputType, objectKey } = validateStorageOperationInput( input, diff --git a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts b/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts index 886a769648b..00d3903914c 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts +++ b/packages/storage/src/providers/s3/apis/uploadData/multipart/uploadHandlers.ts @@ -73,7 +73,7 @@ export const getMultipartUploadHandlers = ( const { options: uploadDataOptions, data } = uploadDataInput; const resolvedS3Options = await resolveS3ConfigAndInput( Amplify, - uploadDataOptions, + uploadDataInput, ); abortController = new AbortController(); diff --git a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts b/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts index 76f9ebf5638..b7c930ea563 100644 --- a/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts +++ b/packages/storage/src/providers/s3/apis/uploadData/putObjectJob.ts @@ -29,7 +29,7 @@ export const putObjectJob = async (): Promise => { const { options: uploadDataOptions, data } = uploadDataInput; const { bucket, keyPrefix, s3Config, isObjectLockEnabled, identityId } = - await resolveS3ConfigAndInput(Amplify, uploadDataOptions); + await resolveS3ConfigAndInput(Amplify, uploadDataInput); const { inputType, objectKey } = validateStorageOperationInput( uploadDataInput, identityId, diff --git a/packages/storage/src/providers/s3/types/inputs.ts b/packages/storage/src/providers/s3/types/inputs.ts index f7bd6c5db44..3158cc0fbba 100644 --- a/packages/storage/src/providers/s3/types/inputs.ts +++ b/packages/storage/src/providers/s3/types/inputs.ts @@ -35,6 +35,8 @@ import { UploadDataOptionsWithPath, } from '../types'; +import { LocationCredentialsProvider } from './options'; + // TODO: support use accelerate endpoint option /** * @deprecated Use {@link CopyWithPathInput} instead. @@ -47,7 +49,9 @@ export type CopyInput = StorageCopyInputWithKey< /** * Input type with path for S3 copy API. */ -export type CopyWithPathInput = StorageCopyInputWithPath; +export type CopyWithPathInput = StorageCopyInputWithPath<{ + locationCredentialsProvider?: LocationCredentialsProvider; +}>; /** * @deprecated Use {@link GetPropertiesWithPathInput} instead. diff --git a/packages/storage/src/providers/s3/utils/resolveIdentityId.ts b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts new file mode 100644 index 00000000000..c4831ae88c4 --- /dev/null +++ b/packages/storage/src/providers/s3/utils/resolveIdentityId.ts @@ -0,0 +1,11 @@ +// 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'; + +export const resolveIdentityId = (identityId?: string): string => { + assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); + + return identityId; +}; diff --git a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts index ae7a185c93c..af6b75f36ec 100644 --- a/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts +++ b/packages/storage/src/providers/s3/utils/resolveS3ConfigAndInput.ts @@ -6,7 +6,18 @@ import { AmplifyClassV6, StorageAccessLevel } from '@aws-amplify/core'; import { assertValidationError } from '../../../errors/utils/assertValidationError'; import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { resolvePrefix as defaultPrefixResolver } from '../../../utils/resolvePrefix'; -import { ResolvedS3Config } from '../types/options'; +import { + LocationCredentialsProvider, + ResolvedS3Config, +} from '../types/options'; +import { + StorageOperationInputWithKey, + StorageOperationInputWithPath, + StorageOperationInputWithPrefix, +} from '../../../types/inputs'; +import { StorageError } from '../../../errors/StorageError'; +import { CopyInput, CopyWithPathInput } from '../types'; +import { INVALID_STORAGE_INPUT } from '../../../errors/constants'; import { DEFAULT_ACCESS_LEVEL, LOCAL_TESTING_S3_ENDPOINT } from './constants'; @@ -14,6 +25,7 @@ interface S3ApiOptions { accessLevel?: StorageAccessLevel; targetIdentityId?: string; useAccelerateEndpoint?: boolean; + locationCredentialsProvider?: LocationCredentialsProvider; } interface ResolvedS3ConfigAndInput { @@ -23,6 +35,16 @@ interface ResolvedS3ConfigAndInput { isObjectLockEnabled?: boolean; identityId?: string; } +export type DeprecatedStorageInput = + | StorageOperationInputWithKey + | StorageOperationInputWithPrefix + | CopyInput; + +export type CallbackPathStorageInput = + | StorageOperationInputWithPath + | CopyWithPathInput; + +type StorageInput = DeprecatedStorageInput | CallbackPathStorageInput; /** * resolve the common input options for S3 API handlers from Amplify configuration and library options. @@ -37,14 +59,14 @@ interface ResolvedS3ConfigAndInput { */ export const resolveS3ConfigAndInput = async ( amplify: AmplifyClassV6, - apiOptions?: S3ApiOptions, + apiInput?: StorageInput & { options?: S3ApiOptions }, ): Promise => { + const { options: apiOptions } = apiInput ?? {}; /** * IdentityId is always cached in memory so we can safely make calls here. It * should be stable even for unauthenticated users, regardless of credentials. */ const { identityId } = await amplify.Auth.fetchAuthSession(); - assertValidationError(!!identityId, StorageValidationErrorCode.NoIdentityId); /** * A credentials provider function instead of a static credentials object is @@ -53,7 +75,13 @@ export const resolveS3ConfigAndInput = async ( * credentials if they are expired. */ const credentialsProvider = async () => { - const { credentials } = await amplify.Auth.fetchAuthSession(); + if (isLocationCredentialsProvider(apiOptions)) { + assertStorageInput(apiInput); + } + + const { credentials } = isLocationCredentialsProvider(apiOptions) + ? await apiOptions.locationCredentialsProvider() + : await amplify.Auth.fetchAuthSession(); assertValidationError( !!credentials, StorageValidationErrorCode.NoCredentials, @@ -101,3 +129,63 @@ export const resolveS3ConfigAndInput = async ( isObjectLockEnabled, }; }; + +const isLocationCredentialsProvider = ( + options?: S3ApiOptions, +): options is S3ApiOptions & { + locationCredentialsProvider: LocationCredentialsProvider; +} => { + return !!options?.locationCredentialsProvider; +}; + +const isInputWithCallbackPath = (input?: CallbackPathStorageInput) => { + return ( + ((input as StorageOperationInputWithPath)?.path && + typeof (input as StorageOperationInputWithPath).path === 'function') || + ((input as CopyWithPathInput)?.destination?.path && + typeof (input as CopyWithPathInput).destination?.path === 'function') || + ((input as CopyWithPathInput)?.source?.path && + typeof (input as CopyWithPathInput).source?.path === 'function') + ); +}; + +const isDeprecatedInput = ( + input?: StorageInput, +): input is DeprecatedStorageInput => { + return ( + isInputWithKey(input) || + isInputWithPrefix(input) || + isInputWithCopySourceOrDestination(input) + ); +}; +const assertStorageInput = (input?: StorageInput) => { + if (isDeprecatedInput(input) || isInputWithCallbackPath(input)) { + throw new StorageError({ + name: INVALID_STORAGE_INPUT, + message: 'The input needs to have a path as a string value.', + recoverySuggestion: + 'Please provide a valid path as a string value for the input.', + }); + } +}; + +const isInputWithKey = ( + input?: StorageInput, +): input is StorageOperationInputWithKey => { + return !!(typeof (input as StorageOperationInputWithKey).key === 'string'); +}; +const isInputWithPrefix = ( + input?: StorageInput, +): input is StorageOperationInputWithPrefix => { + return !!( + typeof (input as StorageOperationInputWithPrefix).prefix === 'string' + ); +}; +const isInputWithCopySourceOrDestination = ( + input?: StorageInput, +): input is CopyInput => { + return !!( + typeof (input as CopyInput).source?.key === 'string' || + typeof (input as CopyInput).destination?.key === 'string' + ); +}; diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts index 585701c81e9..fa423b45913 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInput.ts @@ -7,6 +7,7 @@ import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { isInputWithPath } from './isInputWithPath'; import { STORAGE_INPUT_KEY, STORAGE_INPUT_PATH } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; export const validateStorageOperationInput = ( input: Input, @@ -22,7 +23,10 @@ export const validateStorageOperationInput = ( if (isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); assertValidationError( !objectKey.startsWith('/'), diff --git a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts index da1068af010..1c2efce19f7 100644 --- a/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts +++ b/packages/storage/src/providers/s3/utils/validateStorageOperationInputWithPrefix.ts @@ -9,6 +9,7 @@ import { assertValidationError } from '../../../errors/utils/assertValidationErr import { StorageValidationErrorCode } from '../../../errors/types/validation'; import { STORAGE_INPUT_PATH, STORAGE_INPUT_PREFIX } from './constants'; +import { resolveIdentityId } from './resolveIdentityId'; // Local assertion function with StorageOperationInputWithPrefixPath as Input const _isInputWithPath = ( @@ -28,7 +29,10 @@ export const validateStorageOperationInputWithPrefix = ( ); if (_isInputWithPath(input)) { const { path } = input; - const objectKey = typeof path === 'string' ? path : path({ identityId }); + const objectKey = + typeof path === 'string' + ? path + : path({ identityId: resolveIdentityId(identityId) }); // Assert on no leading slash in the path parameter assertValidationError( diff --git a/packages/storage/src/types/inputs.ts b/packages/storage/src/types/inputs.ts index 403a2a14332..4e369824ca4 100644 --- a/packages/storage/src/types/inputs.ts +++ b/packages/storage/src/types/inputs.ts @@ -90,7 +90,8 @@ export interface StorageCopyInputWithKey< destination: DestinationOptions; } -export interface StorageCopyInputWithPath { +export interface StorageCopyInputWithPath + extends StorageOperationOptionsInput { source: StorageOperationInputWithPath; destination: StorageOperationInputWithPath; }