diff --git a/packages/aws-amplify/package.json b/packages/aws-amplify/package.json index 30a90ed816d..67d3cff5473 100644 --- a/packages/aws-amplify/package.json +++ b/packages/aws-amplify/package.json @@ -461,7 +461,7 @@ "name": "[Storage] copy (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ copy }", - "limit": "16.05 kB" + "limit": "16.08 kB" }, { "name": "[Storage] downloadData (S3)", @@ -491,7 +491,7 @@ "name": "[Storage] remove (S3)", "path": "./dist/esm/storage/index.mjs", "import": "{ remove }", - "limit": "15.50 kB" + "limit": "15.52 kB" }, { "name": "[Storage] uploadData (S3)", diff --git a/packages/core/src/parseAmplifyOutputs.ts b/packages/core/src/parseAmplifyOutputs.ts index 21e52752812..22ef98d1ffe 100644 --- a/packages/core/src/parseAmplifyOutputs.ts +++ b/packages/core/src/parseAmplifyOutputs.ts @@ -5,7 +5,6 @@ /* eslint-disable camelcase */ /* Does not like exhaustive checks */ -/* eslint-disable no-case-declarations */ import { APIConfig, @@ -87,12 +86,14 @@ function parseAuth( oauth, username_attributes, standard_required_attributes, + groups, } = amplifyOutputsAuthProperties; const authConfig = { Cognito: { userPoolId: user_pool_id, userPoolClientId: user_pool_client_id, + groups, }, } as AuthConfig; diff --git a/packages/core/src/singleton/AmplifyOutputs/types.ts b/packages/core/src/singleton/AmplifyOutputs/types.ts index 244c58a9451..d154f04c418 100644 --- a/packages/core/src/singleton/AmplifyOutputs/types.ts +++ b/packages/core/src/singleton/AmplifyOutputs/types.ts @@ -13,7 +13,8 @@ export type AmplifyOutputsAuthMFAConfiguration = | 'NONE'; export type AmplifyOutputsAuthMFAMethod = 'SMS' | 'TOTP'; - +type UserGroupName = string; +type UserGroupPrecedence = Record; export interface AmplifyOutputsAuthProperties { aws_region: string; authentication_flow_type?: 'USER_SRP_AUTH' | 'CUSTOM_AUTH'; @@ -41,6 +42,7 @@ export interface AmplifyOutputsAuthProperties { unauthenticated_identities_enabled?: boolean; mfa_configuration?: string; mfa_methods?: string[]; + groups?: Record[]; } export interface AmplifyOutputsStorageBucketProperties { diff --git a/packages/core/src/singleton/Auth/types.ts b/packages/core/src/singleton/Auth/types.ts index fd7bc788472..70d784a91e3 100644 --- a/packages/core/src/singleton/Auth/types.ts +++ b/packages/core/src/singleton/Auth/types.ts @@ -108,6 +108,9 @@ export type LegacyUserAttributeKey = Uppercase; export type AuthVerifiableAttributeKey = 'email' | 'phone_number'; +type UserGroupName = string; +type UserGroupPrecedence = Record; + export type AuthConfigUserAttributes = Partial< Record >; @@ -130,6 +133,7 @@ export interface AuthIdentityPoolConfig { userAttributes?: never; mfa?: never; passwordFormat?: never; + groups?: never; }; } @@ -171,6 +175,7 @@ export interface CognitoUserPoolConfig { requireNumbers?: boolean; requireSpecialCharacters?: boolean; }; + groups?: Record[]; } export interface OAuthConfig { diff --git a/packages/storage/__tests__/internals/amplifyAuthAdapter/createAmplifyAuthConfigAdapter.test.ts b/packages/storage/__tests__/internals/amplifyAuthAdapter/createAmplifyAuthConfigAdapter.test.ts index 5f2a4bbb446..171210f8599 100644 --- a/packages/storage/__tests__/internals/amplifyAuthAdapter/createAmplifyAuthConfigAdapter.test.ts +++ b/packages/storage/__tests__/internals/amplifyAuthAdapter/createAmplifyAuthConfigAdapter.test.ts @@ -33,12 +33,24 @@ const mockFetchAuthSession = fetchAuthSession as jest.Mock; const mockResolveLocationsFromCurrentSession = resolveLocationsForCurrentSession as jest.Mock; +const mockAuthConfig = { + Auth: { + Cognito: { + userPoolClientId: 'userPoolClientId', + userPoolId: 'userPoolId', + identityPoolId: 'identityPoolId', + groups: [{ admin: { precedence: 0 } }], + }, + }, +}; + describe('createAmplifyAuthConfigAdapter', () => { beforeEach(() => { jest.clearAllMocks(); }); mockGetConfig.mockReturnValue({ + ...mockAuthConfig, Storage: { S3: { bucket: 'bucket1', @@ -70,7 +82,10 @@ describe('createAmplifyAuthConfigAdapter', () => { }); it('should return empty locations when buckets are not defined', async () => { - mockGetConfig.mockReturnValue({ Storage: { S3: { buckets: undefined } } }); + mockGetConfig.mockReturnValue({ + ...mockAuthConfig, + Storage: { S3: { buckets: undefined } }, + }); const adapter = createAmplifyAuthConfigAdapter(); const result = await adapter.listLocations(); @@ -93,16 +108,15 @@ describe('createAmplifyAuthConfigAdapter', () => { }; mockGetConfig.mockReturnValue({ + ...mockAuthConfig, Storage: { S3: { buckets: mockBuckets } }, }); mockResolveLocationsFromCurrentSession.mockReturnValue([ { type: 'PREFIX', permission: ['read', 'write'], - scope: { - bucketName: 'bucket1', - path: '/path1', - }, + bucket: 'bucket1', + prefix: '/path1', }, ]); @@ -114,10 +128,8 @@ describe('createAmplifyAuthConfigAdapter', () => { { type: 'PREFIX', permission: ['read', 'write'], - scope: { - bucketName: 'bucket1', - path: '/path1', - }, + bucket: 'bucket1', + prefix: '/path1', }, ], }); diff --git a/packages/storage/__tests__/internals/amplifyAuthAdapter/getHighestPrecedenceUserGroup.test.ts b/packages/storage/__tests__/internals/amplifyAuthAdapter/getHighestPrecedenceUserGroup.test.ts new file mode 100644 index 00000000000..bf1055797a0 --- /dev/null +++ b/packages/storage/__tests__/internals/amplifyAuthAdapter/getHighestPrecedenceUserGroup.test.ts @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + UserGroupConfig, + getHighestPrecedenceUserGroup, +} from '../../../src/internals/amplifyAuthConfigAdapter/getHighestPrecedenceUserGroup'; + +const userGroupsFromConfig: UserGroupConfig = [ + { + editor: { + precedence: 0, + }, + }, + { + admin: { + precedence: 1, + }, + }, + { + auditor: { + precedence: 2, + }, + }, +]; +const currentUserGroups = ['guest', 'user', 'admin']; + +describe('getHighestPrecedenceUserGroup', () => { + it('should return the user group with the highest precedence', () => { + const result = getHighestPrecedenceUserGroup( + userGroupsFromConfig, + currentUserGroups, + ); + expect(result).toBe('admin'); + }); + + it('should return undefined if userGroupsFromConfig is undefined', () => { + const result = getHighestPrecedenceUserGroup(undefined, currentUserGroups); + expect(result).toBeUndefined(); + }); + + it('should return undefined if currentUserGroups is undefined', () => { + const result = getHighestPrecedenceUserGroup( + userGroupsFromConfig, + undefined, + ); + expect(result).toBeUndefined(); + }); + + it('should handle currentUserGroups containing groups not present in userGroupsFromConfig', () => { + const result = getHighestPrecedenceUserGroup(userGroupsFromConfig, [ + 'unknown', + 'user', + ]); + expect(result).toBe(undefined); + }); +}); diff --git a/packages/storage/__tests__/internals/amplifyAuthAdapter/getPaginatedLocations.test.ts b/packages/storage/__tests__/internals/amplifyAuthAdapter/getPaginatedLocations.test.ts new file mode 100644 index 00000000000..15809d88085 --- /dev/null +++ b/packages/storage/__tests__/internals/amplifyAuthAdapter/getPaginatedLocations.test.ts @@ -0,0 +1,90 @@ +import { getPaginatedLocations } from '../../../src/internals/amplifyAuthConfigAdapter/getPaginatedLocations'; +import { PathAccess } from '../../../src/internals/types/credentials'; + +describe('getPaginatedLocations', () => { + const mockLocations: PathAccess[] = [ + { + type: 'PREFIX', + permission: ['read'], + bucket: 'bucket1', + prefix: 'path1/', + }, + { + type: 'PREFIX', + permission: ['write'], + bucket: 'bucket2', + prefix: 'path2/', + }, + { + type: 'PREFIX', + permission: ['read', 'write'], + bucket: 'bucket3', + prefix: 'path3/', + }, + ]; + + it('should return all locations when no pagination is specified', () => { + const result = getPaginatedLocations({ locations: mockLocations }); + expect(result).toEqual({ locations: mockLocations }); + }); + + it('should return paginated locations when pageSize is specified', () => { + const result = getPaginatedLocations({ + locations: mockLocations, + pageSize: 2, + }); + expect(result).toEqual({ + locations: mockLocations.slice(0, 2), + nextToken: '1', + }); + }); + + it('should return paginated locations when pageSize and nextToken are specified', () => { + const result = getPaginatedLocations({ + locations: mockLocations, + pageSize: 1, + nextToken: '2', + }); + expect(result).toEqual({ + locations: mockLocations.slice(1, 2), + nextToken: '1', + }); + }); + + it('should return empty locations when locations array is empty', () => { + const result = getPaginatedLocations({ locations: [], pageSize: 2 }); + expect(result).toEqual({ locations: [] }); + }); + + it('should return empty location when nextToken is beyond array length', () => { + const result = getPaginatedLocations({ + locations: mockLocations, + pageSize: 2, + nextToken: '5', + }); + expect(result).toEqual({ locations: [], nextToken: undefined }); + }); + + it('should return all remaining location when page size is greater than remaining locations length', () => { + const result = getPaginatedLocations({ + locations: mockLocations, + pageSize: 5, + nextToken: '2', + }); + expect(result).toEqual({ + locations: mockLocations.slice(-2), + nextToken: undefined, + }); + }); + + it('should return undefined nextToken when end of array is reached', () => { + const result = getPaginatedLocations({ + locations: mockLocations, + pageSize: 5, + }); + expect(result).toEqual({ + locations: mockLocations.slice(0, 3), + nextToken: undefined, + }); + }); +}); diff --git a/packages/storage/__tests__/internals/amplifyAuthAdapter/resolveLocationsForCurrentSession.test.ts b/packages/storage/__tests__/internals/amplifyAuthAdapter/resolveLocationsForCurrentSession.test.ts index bebfa85ef77..14119e9e80b 100644 --- a/packages/storage/__tests__/internals/amplifyAuthAdapter/resolveLocationsForCurrentSession.test.ts +++ b/packages/storage/__tests__/internals/amplifyAuthAdapter/resolveLocationsForCurrentSession.test.ts @@ -37,7 +37,6 @@ describe('resolveLocationsForCurrentSession', () => { buckets: mockBuckets, isAuthenticated: true, identityId: '12345', - userGroup: 'admin', }); expect(result).toEqual([ @@ -47,12 +46,6 @@ describe('resolveLocationsForCurrentSession', () => { bucket: 'bucket1', prefix: 'path1/*', }, - { - type: 'PREFIX', - permission: ['get', 'list', 'write', 'delete'], - bucket: 'bucket1', - prefix: 'path2/*', - }, { type: 'PREFIX', permission: ['get', 'list', 'write', 'delete'], @@ -62,30 +55,35 @@ describe('resolveLocationsForCurrentSession', () => { ]); }); - it('should generate locations correctly when tokens are true & bad userGroup', () => { + it('should generate locations correctly when tokens are true & userGroup', () => { const result = resolveLocationsForCurrentSession({ buckets: mockBuckets, isAuthenticated: true, identityId: '12345', - userGroup: 'editor', + userGroup: 'admin', }); expect(result).toEqual([ - { - type: 'PREFIX', - permission: ['get', 'list', 'write'], - bucket: 'bucket1', - prefix: 'path1/*', - }, { type: 'PREFIX', permission: ['get', 'list', 'write', 'delete'], bucket: 'bucket1', - prefix: 'profile-pictures/12345/*', + prefix: 'path2/*', }, ]); }); + it('should return empty locations when tokens are true & bad userGroup', () => { + const result = resolveLocationsForCurrentSession({ + buckets: mockBuckets, + isAuthenticated: true, + identityId: '12345', + userGroup: 'editor', + }); + + expect(result).toEqual([]); + }); + it('should continue to next bucket when paths are not defined', () => { const result = resolveLocationsForCurrentSession({ buckets: { @@ -107,7 +105,6 @@ describe('resolveLocationsForCurrentSession', () => { }, isAuthenticated: true, identityId: '12345', - userGroup: 'admin', }); expect(result).toEqual([ diff --git a/packages/storage/src/internals/amplifyAuthConfigAdapter/createAmplifyListLocationsHandler.ts b/packages/storage/src/internals/amplifyAuthConfigAdapter/createAmplifyListLocationsHandler.ts index 844e3427071..f8150a34d04 100644 --- a/packages/storage/src/internals/amplifyAuthConfigAdapter/createAmplifyListLocationsHandler.ts +++ b/packages/storage/src/internals/amplifyAuthConfigAdapter/createAmplifyListLocationsHandler.ts @@ -3,28 +3,59 @@ import { Amplify, fetchAuthSession } from '@aws-amplify/core'; -import { ListPaths } from '../types/credentials'; +import { ListPaths, PathAccess } from '../types/credentials'; +import { getPaginatedLocations } from './getPaginatedLocations'; import { resolveLocationsForCurrentSession } from './resolveLocationsForCurrentSession'; +import { getHighestPrecedenceUserGroup } from './getHighestPrecedenceUserGroup'; export const createAmplifyListLocationsHandler = (): ListPaths => { const { buckets } = Amplify.getConfig().Storage!.S3!; + const { groups } = Amplify.getConfig().Auth!.Cognito; - return async function listLocations() { + let cachedResult: Record | null = null; + + return async function listLocations(input = {}) { if (!buckets) { return { locations: [] }; } + const { pageSize, nextToken } = input; const { tokens, identityId } = await fetchAuthSession(); - const userGroups = tokens?.accessToken.payload['cognito:groups']; + const currentUserGroups = tokens?.accessToken.payload['cognito:groups'] as + | string[] + | undefined; + + const userGroupToUse = getHighestPrecedenceUserGroup( + groups, + currentUserGroups, + ); + + const cacheKey = + JSON.stringify({ identityId, userGroup: userGroupToUse }) + `${!!tokens}`; + + if (cachedResult && cachedResult[cacheKey]) + return getPaginatedLocations({ + locations: cachedResult[cacheKey].locations, + pageSize, + nextToken, + }); + + cachedResult = {}; const locations = resolveLocationsForCurrentSession({ buckets, isAuthenticated: !!tokens, identityId, - userGroup: userGroups && (userGroups as any)[0], // TODO: fix this edge case + userGroup: userGroupToUse, }); - return { locations }; + cachedResult[cacheKey] = { locations }; + + return getPaginatedLocations({ + locations: cachedResult[cacheKey].locations, + pageSize, + nextToken, + }); }; }; diff --git a/packages/storage/src/internals/amplifyAuthConfigAdapter/getHighestPrecedenceUserGroup.ts b/packages/storage/src/internals/amplifyAuthConfigAdapter/getHighestPrecedenceUserGroup.ts new file mode 100644 index 00000000000..82303a9d0d8 --- /dev/null +++ b/packages/storage/src/internals/amplifyAuthConfigAdapter/getHighestPrecedenceUserGroup.ts @@ -0,0 +1,42 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type UserGroupConfig = Record>[]; + +/** + * Given the Cognito user groups associated to current user session + * and all the user group configurations defined by backend. + * This function returns the user group with the highest precedence. + * Reference: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html#assigning-precedence-values-to-groups + * + * @param {UserGroupConfig} userGroupsFromConfig - User groups with their precedence values based on Amplify outputs. + * @param {string[]} currentUserGroups - The list of current user's groups. + * @returns {string | undefined} - The user group with the highest precedence (0), or undefined if no matching group is found. + */ +export const getHighestPrecedenceUserGroup = ( + userGroupsFromConfig?: UserGroupConfig, + currentUserGroups?: string[], +): string | undefined => { + if (userGroupsFromConfig && currentUserGroups) { + const precedenceMap = userGroupsFromConfig.reduce( + (acc, group) => { + Object.entries(group).forEach(([key, value]) => { + acc[key] = value.precedence; + }); + + return acc; + }, + {} as Record, + ); + + const sortedUserGroup = currentUserGroups + .filter(group => + Object.prototype.hasOwnProperty.call(precedenceMap, group), + ) + .sort((a, b) => precedenceMap[a] - precedenceMap[b]); + + return sortedUserGroup[0]; + } + + return undefined; +}; diff --git a/packages/storage/src/internals/amplifyAuthConfigAdapter/getPaginatedLocations.ts b/packages/storage/src/internals/amplifyAuthConfigAdapter/getPaginatedLocations.ts new file mode 100644 index 00000000000..93c501a92dd --- /dev/null +++ b/packages/storage/src/internals/amplifyAuthConfigAdapter/getPaginatedLocations.ts @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { PathAccess } from '../types/credentials'; + +export const getPaginatedLocations = ({ + locations, + pageSize, + nextToken, +}: { + locations: PathAccess[]; + pageSize?: number; + nextToken?: string; +}) => { + if (pageSize) { + if (nextToken) { + if (Number(nextToken) > locations.length) { + return { locations: [], nextToken: undefined }; + } + const start = -nextToken; + const end = start + pageSize < 0 ? start + pageSize : undefined; + + return { + locations: locations.slice(start, end), + nextToken: end ? `${-end}` : undefined, + }; + } + + return { + locations: locations.slice(0, pageSize), + nextToken: + locations.length > pageSize + ? `${locations.length - pageSize}` + : undefined, + }; + } + + return { + locations, + }; +}; diff --git a/packages/storage/src/internals/amplifyAuthConfigAdapter/resolveLocationsForCurrentSession.ts b/packages/storage/src/internals/amplifyAuthConfigAdapter/resolveLocationsForCurrentSession.ts index 16e63066f1e..9071c2386ae 100644 --- a/packages/storage/src/internals/amplifyAuthConfigAdapter/resolveLocationsForCurrentSession.ts +++ b/packages/storage/src/internals/amplifyAuthConfigAdapter/resolveLocationsForCurrentSession.ts @@ -16,8 +16,8 @@ const resolvePermissions = ( }; } if (groups) { - const selectedKey = Object.keys(accessRule).find( - access => access.includes(groups) || access.includes('authenticated'), + const selectedKey = Object.keys(accessRule).find(access => + access.includes(groups), ); return { @@ -50,7 +50,13 @@ export const resolveLocationsForCurrentSession = ({ } for (const [path, accessRules] of Object.entries(paths)) { - if (path.includes(ENTITY_IDENTITY_URL) && isAuthenticated && identityId) { + const shouldIncludeEntityIdPath = + !userGroup && + path.includes(ENTITY_IDENTITY_URL) && + isAuthenticated && + identityId; + + if (shouldIncludeEntityIdPath) { locations.push({ type: 'PREFIX', permission: accessRules.entityidentity as StorageAccess[], @@ -58,12 +64,14 @@ export const resolveLocationsForCurrentSession = ({ prefix: path.replace(ENTITY_IDENTITY_URL, identityId), }); } + const location = { type: 'PREFIX', ...resolvePermissions(accessRules, isAuthenticated, userGroup), bucket: bucketName, prefix: path, }; + if (location.permission) locations.push(location as PathAccess); } }