Skip to content

Commit 1018471

Browse files
committed
fix: cognito users in a group cannot access their own files in s3
1 parent b402da9 commit 1018471

File tree

3 files changed

+219
-8
lines changed

3 files changed

+219
-8
lines changed

.changeset/wicked-experts-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@aws-amplify/backend-storage': patch
3+
---
4+
5+
fix: users in Cognito groups could not access their own files

packages/backend-storage/src/access_builder.test.ts

Lines changed: 145 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,13 @@ import { roleAccessBuilder } from './access_builder.js';
44
import {
55
ConstructContainer,
66
ConstructFactoryGetInstanceProps,
7+
ResourceAccessAcceptor,
78
ResourceAccessAcceptorFactory,
89
ResourceProvider,
10+
SsmEnvironmentEntry,
911
} from '@aws-amplify/plugin-types';
12+
import { App, Stack } from 'aws-cdk-lib';
13+
import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
1014

1115
void describe('storageAccessBuilder', () => {
1216
const resourceAccessAcceptorMock = mock.fn();
@@ -115,13 +119,21 @@ void describe('storageAccessBuilder', () => {
115119
accessDefinition.idSubstitution,
116120
'${cognito-identity.amazonaws.com:sub}',
117121
);
118-
assert.deepStrictEqual(
119-
accessDefinition.getResourceAccessAcceptors.map(
120-
(getResourceAccessAcceptor) =>
121-
getResourceAccessAcceptor(stubGetInstanceProps),
122-
),
123-
[resourceAccessAcceptorMock],
124-
);
122+
123+
// Should have 2 resource access acceptors now: authenticated role + compound group acceptor
124+
assert.equal(accessDefinition.getResourceAccessAcceptors.length, 2);
125+
126+
// First acceptor should be the authenticated role
127+
const authAcceptor =
128+
accessDefinition.getResourceAccessAcceptors[0](stubGetInstanceProps);
129+
assert.equal(authAcceptor, resourceAccessAcceptorMock);
130+
131+
// Second acceptor should be the compound group acceptor
132+
const groupAcceptor =
133+
accessDefinition.getResourceAccessAcceptors[1](stubGetInstanceProps);
134+
assert.equal(typeof groupAcceptor.identifier, 'string');
135+
assert.equal(typeof groupAcceptor.acceptResourceAccess, 'function');
136+
125137
assert.equal(
126138
getConstructFactoryMock.mock.calls[0].arguments[0],
127139
'AuthResources',
@@ -172,4 +184,130 @@ void describe('storageAccessBuilder', () => {
172184
[group1AccessAcceptorMock, group2AccessAcceptorMock],
173185
);
174186
});
187+
188+
void it('entity permissions apply to all group roles when groups are defined', () => {
189+
// Mock acceptResourceAccess calls to verify they're made
190+
const adminAcceptResourceAccessMock = mock.fn();
191+
const usersAcceptResourceAccessMock = mock.fn();
192+
const adminAcceptorMock: ResourceAccessAcceptor = {
193+
identifier: 'adminAcceptor',
194+
acceptResourceAccess: adminAcceptResourceAccessMock,
195+
};
196+
const usersAcceptorMock: ResourceAccessAcceptor = {
197+
identifier: 'usersAcceptor',
198+
acceptResourceAccess: usersAcceptResourceAccessMock,
199+
};
200+
201+
// Create a group-specific mock that returns the correct types
202+
const groupSpecificMock = mock.fn((roleName: string) => {
203+
if (roleName === 'Admins') return adminAcceptorMock;
204+
if (roleName === 'Users') return usersAcceptorMock;
205+
return resourceAccessAcceptorMock;
206+
});
207+
208+
// Mock auth resources with groups
209+
const mockAuthResources = {
210+
resources: {
211+
groups: {
212+
Admins: { role: {} },
213+
Users: { role: {} },
214+
},
215+
},
216+
getResourceAccessAcceptor: groupSpecificMock,
217+
};
218+
219+
const mockConstructFactory = mock.fn(
220+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
221+
<T extends ResourceProvider>(_: string) => ({
222+
getInstance: () => mockAuthResources as unknown as T,
223+
}),
224+
);
225+
226+
const mockGetInstanceProps: ConstructFactoryGetInstanceProps = {
227+
constructContainer: {
228+
getConstructFactory: mockConstructFactory,
229+
} as unknown as ConstructContainer,
230+
} as unknown as ConstructFactoryGetInstanceProps;
231+
232+
const accessDefinition = roleAccessBuilder
233+
.entity('identity')
234+
.to(['read', 'write', 'delete']);
235+
236+
// Should have 2 resource access acceptors: authenticated role + compound group acceptor
237+
assert.equal(accessDefinition.getResourceAccessAcceptors.length, 2);
238+
239+
// Get the compound group acceptor
240+
const groupAcceptor =
241+
accessDefinition.getResourceAccessAcceptors[1](mockGetInstanceProps);
242+
243+
// Verify it has the expected structure
244+
assert.ok(groupAcceptor.identifier.includes('entityAccessForAllGroups'));
245+
assert.ok(groupAcceptor.identifier.includes('Admins'));
246+
assert.ok(groupAcceptor.identifier.includes('Users'));
247+
assert.equal(typeof groupAcceptor.acceptResourceAccess, 'function');
248+
249+
// Call acceptResourceAccess on the compound acceptor
250+
const stack = new Stack(new App(), 'TestStack');
251+
const mockPolicy = new Policy(stack, 'TestPolicy', {
252+
statements: [new PolicyStatement()],
253+
});
254+
const mockSsmEntries: SsmEnvironmentEntry[] = [];
255+
groupAcceptor.acceptResourceAccess(mockPolicy, mockSsmEntries);
256+
257+
// Verify both group roles had the policy applied
258+
assert.equal(adminAcceptResourceAccessMock.mock.callCount(), 1);
259+
assert.equal(usersAcceptResourceAccessMock.mock.callCount(), 1);
260+
assert.deepStrictEqual(
261+
adminAcceptResourceAccessMock.mock.calls[0].arguments,
262+
[mockPolicy, mockSsmEntries],
263+
);
264+
assert.deepStrictEqual(
265+
usersAcceptResourceAccessMock.mock.calls[0].arguments,
266+
[mockPolicy, mockSsmEntries],
267+
);
268+
});
269+
270+
void it('entity permissions handle case when no groups are defined', () => {
271+
// Mock auth resources without groups
272+
const mockAuthResources = {
273+
resources: {
274+
groups: undefined,
275+
},
276+
getResourceAccessAcceptor: getResourceAccessAcceptorMock,
277+
};
278+
279+
const mockConstructFactory = mock.fn(
280+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
281+
<T extends ResourceProvider>(_: string) => ({
282+
getInstance: () => mockAuthResources as unknown as T,
283+
}),
284+
);
285+
286+
const mockGetInstanceProps: ConstructFactoryGetInstanceProps = {
287+
constructContainer: {
288+
getConstructFactory: mockConstructFactory,
289+
} as unknown as ConstructContainer,
290+
} as unknown as ConstructFactoryGetInstanceProps;
291+
292+
const accessDefinition = roleAccessBuilder
293+
.entity('identity')
294+
.to(['read', 'write', 'delete']);
295+
296+
// Should still have 2 resource access acceptors
297+
assert.equal(accessDefinition.getResourceAccessAcceptors.length, 2);
298+
299+
// Get the compound group acceptor (should be empty/no-op)
300+
const groupAcceptor =
301+
accessDefinition.getResourceAccessAcceptors[1](mockGetInstanceProps);
302+
assert.equal(groupAcceptor.identifier, 'entityAccessForAllGroups-empty');
303+
304+
// Should not throw when called (no-op behavior)
305+
const stack = new Stack(new App(), 'TestStack');
306+
const emptyPolicy = new Policy(stack, 'EmptyPolicy', {
307+
statements: [new PolicyStatement()],
308+
});
309+
assert.doesNotThrow(() => {
310+
groupAcceptor.acceptResourceAccess(emptyPolicy, []);
311+
});
312+
});
175313
});

packages/backend-storage/src/access_builder.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ export const roleAccessBuilder: StorageAccessBuilder = {
5959
}),
6060
entity: (entityId) => ({
6161
to: (actions) => ({
62-
getResourceAccessAcceptors: [getAuthRoleResourceAccessAcceptor],
62+
getResourceAccessAcceptors: [
63+
getAuthRoleResourceAccessAcceptor,
64+
getEntityAccessForAllGroups,
65+
],
6366
uniqueDefinitionIdValidations: [
6467
{
6568
uniqueDefinitionId: `entity${entityId}`,
@@ -102,6 +105,71 @@ const getUnauthRoleResourceAccessAcceptor = (
102105
'unauthenticatedUserIamRole',
103106
);
104107

108+
const getEntityAccessForAllGroups = (
109+
getInstanceProps: ConstructFactoryGetInstanceProps,
110+
) => {
111+
const authResources = getInstanceProps.constructContainer
112+
.getConstructFactory<
113+
ResourceProvider & ResourceAccessAcceptorFactory<AuthRoleName | string>
114+
>('AuthResources')
115+
?.getInstance(getInstanceProps);
116+
117+
if (!authResources) {
118+
// If no auth resources, return a no-op acceptor
119+
return {
120+
identifier: 'entityAccessForAllGroups-empty',
121+
acceptResourceAccess: () => {
122+
// No groups to apply permissions to
123+
},
124+
};
125+
}
126+
127+
const resources =
128+
'resources' in authResources ? authResources.resources : undefined;
129+
const groups =
130+
resources && typeof resources === 'object' && 'groups' in resources
131+
? (resources as { groups: Record<string, unknown> }).groups
132+
: undefined;
133+
134+
if (!groups) {
135+
// If no groups defined, return a no-op acceptor
136+
return {
137+
identifier: 'entityAccessForAllGroups-empty',
138+
acceptResourceAccess: () => {
139+
// No groups to apply permissions to
140+
},
141+
};
142+
}
143+
144+
// Create a compound acceptor that applies to all group roles
145+
const groupNames = Object.keys(groups);
146+
147+
return {
148+
identifier: `entityAccessForAllGroups-${groupNames.join('-')}`,
149+
acceptResourceAccess: (policy: unknown, ssmEnvironmentEntries: unknown) => {
150+
// Apply the policy to each group role
151+
groupNames.forEach((groupName) => {
152+
try {
153+
const groupAcceptor = getUserRoleResourceAccessAcceptor(
154+
getInstanceProps,
155+
groupName,
156+
);
157+
groupAcceptor.acceptResourceAccess(
158+
policy as Parameters<typeof groupAcceptor.acceptResourceAccess>[0],
159+
ssmEnvironmentEntries as Parameters<
160+
typeof groupAcceptor.acceptResourceAccess
161+
>[1],
162+
);
163+
} catch (error) {
164+
// Group role might not exist, ignore silently
165+
// This is expected behavior when a group role hasn't been created yet
166+
void error; // Acknowledge the error parameter to satisfy ESLint
167+
}
168+
});
169+
},
170+
};
171+
};
172+
105173
const getUserRoleResourceAccessAcceptor = (
106174
getInstanceProps: ConstructFactoryGetInstanceProps,
107175
roleName: AuthRoleName | string,

0 commit comments

Comments
 (0)