Skip to content

Commit 01ab52b

Browse files
committed
feat: add resource access support for Lambda functions and other constructs
- Extend StorageAccessRule type to support 'resource' access type - Add resource parameter to StorageAccessRule for specifying target construct - Implement extractRoleFromResource method in AuthRoleResolver - Support Lambda functions and constructs with IAM roles - Add comprehensive test coverage for resource access scenarios - Update grantAccess method to handle resource role resolution - Add documentation and usage examples Enables storage-construct to grant AWS resources access to S3 buckets: storage.grantAccess(auth, { 'uploads/*': [ { type: 'resource', actions: ['read'], resource: myFunction } ] }); Achieves functional parity with backend-storage's allow.resource() pattern.
1 parent ffc6fe7 commit 01ab52b

File tree

5 files changed

+245
-2
lines changed

5 files changed

+245
-2
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Resource Access Example
2+
3+
This example demonstrates how to grant Lambda functions access to storage using the new resource access functionality.
4+
5+
## Basic Usage
6+
7+
```typescript
8+
import { AmplifyStorage } from '@aws-amplify/storage-construct';
9+
import { Function } from 'aws-cdk-lib/aws-lambda';
10+
import { Stack } from 'aws-cdk-lib';
11+
12+
// Create a Lambda function
13+
const processFunction = new Function(stack, 'ProcessFunction', {
14+
// ... function configuration
15+
});
16+
17+
// Create storage
18+
const storage = new AmplifyStorage(stack, 'Storage', {
19+
name: 'my-app-storage',
20+
});
21+
22+
// Grant the function access to storage
23+
storage.grantAccess(auth, {
24+
'uploads/*': [
25+
// Users can upload files
26+
{ type: 'authenticated', actions: ['write'] },
27+
// Function can read and process uploaded files
28+
{ type: 'resource', actions: ['read'], resource: processFunction },
29+
],
30+
'processed/*': [
31+
// Function can write processed results
32+
{ type: 'resource', actions: ['write'], resource: processFunction },
33+
// Users can read processed files
34+
{ type: 'authenticated', actions: ['read'] },
35+
],
36+
});
37+
```
38+
39+
## Supported Resource Types
40+
41+
The resource access functionality supports any construct that has an IAM role:
42+
43+
### Lambda Functions
44+
45+
```typescript
46+
{ type: 'resource', actions: ['read'], resource: lambdaFunction }
47+
```
48+
49+
### Custom Constructs with Roles
50+
51+
```typescript
52+
const customResource = {
53+
role: myIamRole // Any IRole instance
54+
};
55+
56+
{ type: 'resource', actions: ['read', 'write'], resource: customResource }
57+
```
58+
59+
## Actions Available
60+
61+
- `'read'`: Grants s3:GetObject and s3:ListBucket permissions
62+
- `'write'`: Grants s3:PutObject permissions
63+
- `'delete'`: Grants s3:DeleteObject permissions
64+
65+
## Path Patterns
66+
67+
Resource access follows the same path patterns as other access types:
68+
69+
- `'public/*'`: Access to all files in public folder
70+
- `'functions/temp/*'`: Access to temporary files for functions
71+
- `'processing/{entity_id}/*'`: Not recommended for resources (entity substitution doesn't apply)
72+
73+
## Complete Example
74+
75+
```typescript
76+
import { AmplifyStorage } from '@aws-amplify/storage-construct';
77+
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda';
78+
import { Stack, App } from 'aws-cdk-lib';
79+
80+
const app = new App();
81+
const stack = new Stack(app, 'MyStack');
82+
83+
// Create processing function
84+
const imageProcessor = new Function(stack, 'ImageProcessor', {
85+
runtime: Runtime.NODEJS_18_X,
86+
handler: 'index.handler',
87+
code: Code.fromInline(`
88+
exports.handler = async (event) => {
89+
// Process S3 events and manipulate files
90+
console.log('Processing:', event);
91+
};
92+
`),
93+
});
94+
95+
// Create storage with triggers and access
96+
const storage = new AmplifyStorage(stack, 'Storage', {
97+
name: 'image-processing-storage',
98+
triggers: {
99+
onUpload: imageProcessor, // Trigger function on upload
100+
},
101+
});
102+
103+
// Configure access permissions
104+
storage.grantAccess(auth, {
105+
'raw-images/*': [
106+
{ type: 'authenticated', actions: ['write'] }, // Users upload raw images
107+
{ type: 'resource', actions: ['read'], resource: imageProcessor }, // Function reads raw images
108+
],
109+
'processed-images/*': [
110+
{ type: 'resource', actions: ['write'], resource: imageProcessor }, // Function writes processed images
111+
{ type: 'authenticated', actions: ['read'] }, // Users read processed images
112+
{ type: 'guest', actions: ['read'] }, // Public access to processed images
113+
],
114+
'temp/*': [
115+
{
116+
type: 'resource',
117+
actions: ['read', 'write', 'delete'],
118+
resource: imageProcessor,
119+
}, // Function manages temp files
120+
],
121+
});
122+
```
123+
124+
This provides the same functionality as backend-storage's `allow.resource(myFunction).to(['read'])` pattern.

packages/storage-construct/src/auth_role_resolver.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ export class AuthRoleResolver {
8181
* @returns The IAM role or undefined if not found
8282
*/
8383
getRoleForAccessType = (
84-
accessType: 'authenticated' | 'guest' | 'owner' | 'groups',
84+
accessType: 'authenticated' | 'guest' | 'owner' | 'groups' | 'resource',
8585
authRoles: AuthRoles,
8686
groups?: string[],
87+
resource?: unknown,
8788
): IRole | undefined => {
8889
switch (accessType) {
8990
case 'authenticated':
@@ -106,8 +107,41 @@ export class AuthRoleResolver {
106107
}
107108
return undefined;
108109

110+
case 'resource':
111+
return this.extractRoleFromResource(resource);
112+
109113
default:
110114
return undefined;
111115
}
112116
};
117+
118+
/**
119+
* Extracts IAM role from a resource construct.
120+
* Supports Lambda functions and other constructs with IAM roles.
121+
*/
122+
private extractRoleFromResource = (resource: unknown): IRole | undefined => {
123+
if (!resource || typeof resource !== 'object') {
124+
return undefined;
125+
}
126+
127+
const resourceObj = resource as Record<string, unknown>;
128+
129+
// Try to extract role from Lambda function
130+
if (resourceObj.role && typeof resourceObj.role === 'object') {
131+
return resourceObj.role as IRole;
132+
}
133+
134+
// Try to extract from resources property (common pattern)
135+
if (resourceObj.resources && typeof resourceObj.resources === 'object') {
136+
const resources = resourceObj.resources as Record<string, unknown>;
137+
if (resources.lambda && typeof resources.lambda === 'object') {
138+
const lambda = resources.lambda as Record<string, unknown>;
139+
if (lambda.role) {
140+
return lambda.role as IRole;
141+
}
142+
}
143+
}
144+
145+
return undefined;
146+
};
113147
}

packages/storage-construct/src/construct.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,33 @@ void describe('AmplifyStorage', () => {
132132
}, /Invalid auth construct/);
133133
});
134134

135+
void it('supports resource access for Lambda functions', () => {
136+
const app = new App();
137+
const stack = new Stack(app);
138+
const storage = new AmplifyStorage(stack, 'test', { name: 'testName' });
139+
140+
const mockAuth = { resources: {} };
141+
const mockFunction = {
142+
role: {
143+
attachInlinePolicy: () => {},
144+
node: { id: 'MockFunctionRole' },
145+
},
146+
};
147+
148+
// Should not throw when granting resource access
149+
assert.doesNotThrow(() => {
150+
storage.grantAccess(mockAuth, {
151+
'functions/*': [
152+
{
153+
type: 'resource' as const,
154+
actions: ['read' as const, 'write' as const],
155+
resource: mockFunction,
156+
},
157+
],
158+
});
159+
});
160+
});
161+
135162
void describe('storage overrides', () => {
136163
void it('can override bucket properties', () => {
137164
const app = new App();

packages/storage-construct/src/construct.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ export type StorageAccessRule = {
125125
* - 'owner': The user who owns the resource (uses entity_id substitution)
126126
* - 'groups': Specific user groups from Cognito User Pool
127127
*/
128-
type: 'authenticated' | 'guest' | 'owner' | 'groups';
128+
type: 'authenticated' | 'guest' | 'owner' | 'groups' | 'resource';
129129

130130
/**
131131
* Array of actions the principal can perform:
@@ -144,6 +144,12 @@ export type StorageAccessRule = {
144144
* ```
145145
*/
146146
groups?: string[];
147+
148+
/**
149+
* Required when type is 'resource'. The AWS resource that should get access.
150+
* Currently supports Lambda functions and other constructs with IAM roles.
151+
*/
152+
resource?: unknown;
147153
};
148154

149155
/**
@@ -424,6 +430,7 @@ export class AmplifyStorage extends Construct {
424430
rule.type,
425431
authRoles,
426432
rule.groups,
433+
rule.resource,
427434
);
428435

429436
if (role) {
@@ -433,6 +440,10 @@ export class AmplifyStorage extends Construct {
433440
// For owner access, substitute with the user's Cognito identity ID
434441
idSubstitution = entityIdSubstitution;
435442
}
443+
// Resource access also uses wildcard (no entity substitution)
444+
if (rule.type === 'resource') {
445+
idSubstitution = '*';
446+
}
436447

437448
// Add the access definition to be processed by the orchestrator
438449
accessDefinitions[storagePath].push({

packages/storage-construct/src/storage_access_orchestrator.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,53 @@ void describe('StorageAccessOrchestrator', () => {
7676
);
7777
});
7878

79+
void it('handles resource access with function role', () => {
80+
const functionRole = new Role(stack, 'FunctionRole', {
81+
assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
82+
});
83+
const attachInlinePolicyMock = mock.method(
84+
functionRole,
85+
'attachInlinePolicy',
86+
);
87+
const storageAccessOrchestrator = new StorageAccessOrchestrator(
88+
storageAccessPolicyFactory,
89+
);
90+
91+
storageAccessOrchestrator.orchestrateStorageAccess({
92+
'uploads/*': [
93+
{
94+
role: functionRole,
95+
actions: ['read', 'write'],
96+
idSubstitution: '*',
97+
},
98+
],
99+
});
100+
101+
// Should create policy for function role
102+
assert.ok(attachInlinePolicyMock.mock.callCount() >= 1);
103+
104+
// Collect all statements from all policy calls
105+
const allStatements = attachInlinePolicyMock.mock.calls
106+
.map((call) => call.arguments[0].document.toJSON().Statement)
107+
.flat();
108+
109+
// Verify GetObject statement
110+
const getStatements = allStatements.filter(
111+
(s: any) => s.Action === 's3:GetObject',
112+
);
113+
assert.ok(getStatements.length >= 1);
114+
const getResources = getStatements.map((s: any) => s.Resource).flat();
115+
assert.ok(getResources.includes(`${bucket.bucketArn}/uploads/*`));
116+
117+
// Verify PutObject statement
118+
const putStatements = allStatements.filter(
119+
(s: any) => s.Action === 's3:PutObject',
120+
);
121+
assert.ok(putStatements.length >= 1);
122+
const putResources = putStatements.map((s: any) => s.Resource).flat();
123+
assert.ok(putResources.includes(`${bucket.bucketArn}/uploads/*`));
124+
});
125+
79126
void it('passes expected policy to role', () => {
80127
const attachInlinePolicyMock = mock.method(
81128
authRole,

0 commit comments

Comments
 (0)