Skip to content

Commit 04b12cd

Browse files
feat(storage): support S3 IAM role credentials
1 parent 06a4461 commit 04b12cd

4 files changed

Lines changed: 96 additions & 14 deletions

File tree

apps/nestjs-backend/src/configs/storage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const storageConfig = registerAs('storage', () => ({
4646
internalForcePathStyle: process.env.BACKEND_STORAGE_S3_INTERNAL_FORCE_PATH_STYLE
4747
? process.env.BACKEND_STORAGE_S3_INTERNAL_FORCE_PATH_STYLE === 'true'
4848
: undefined,
49+
useIAMRole: process.env.BACKEND_STORAGE_S3_USE_IAM_ROLE === 'true',
4950
},
5051
uploadMethod: process.env.BACKEND_STORAGE_UPLOAD_METHOD ?? 'put',
5152
encryption: {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { S3Client } from '@aws-sdk/client-s3';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import type { IStorageConfig } from '../../../configs/storage';
5+
import { S3Storage } from './s3';
6+
7+
vi.mock('@aws-sdk/client-s3', async (importOriginal) => {
8+
const actual = await importOriginal();
9+
return {
10+
...(actual as object),
11+
S3Client: vi.fn(function s3Client(this: unknown) {
12+
return this;
13+
}),
14+
};
15+
});
16+
17+
type StorageConfigOverrides = Partial<Omit<IStorageConfig, 's3'>> & {
18+
s3?: Partial<IStorageConfig['s3']>;
19+
};
20+
21+
const createConfig = (overrides: StorageConfigOverrides = {}): IStorageConfig => {
22+
const { s3: s3Overrides, ...configOverrides } = overrides;
23+
return {
24+
provider: 's3',
25+
publicBucket: 'public',
26+
privateBucket: 'private',
27+
s3: {
28+
region: 'us-east-1',
29+
endpoint: 'https://s3.us-east-1.amazonaws.com',
30+
accessKey: 'access-key',
31+
secretKey: 'secret-key',
32+
maxSockets: 100,
33+
forcePathStyle: false,
34+
...s3Overrides,
35+
},
36+
uploadMethod: 'put',
37+
tokenExpireIn: '6d',
38+
urlExpireIn: '6d',
39+
...configOverrides,
40+
} as IStorageConfig;
41+
};
42+
43+
describe('S3Storage IAM role credentials', () => {
44+
beforeEach(() => {
45+
vi.mocked(S3Client).mockClear();
46+
});
47+
48+
it('uses configured static credentials by default', () => {
49+
new S3Storage(createConfig());
50+
51+
expect(S3Client).toHaveBeenCalledWith(
52+
expect.objectContaining({
53+
credentials: {
54+
accessKeyId: 'access-key',
55+
secretAccessKey: 'secret-key',
56+
},
57+
})
58+
);
59+
});
60+
61+
it('omits credentials when S3 IAM role support is enabled', () => {
62+
new S3Storage(
63+
createConfig({
64+
privateBucketEndpoint: 'https://private.example.com',
65+
s3: {
66+
useIAMRole: true,
67+
internalEndpoint: 'https://s3.internal.example.com',
68+
accessKey: undefined as any,
69+
secretKey: undefined as any,
70+
},
71+
})
72+
);
73+
74+
for (const [clientConfig] of vi.mocked(S3Client).mock.calls) {
75+
expect(clientConfig).not.toHaveProperty('credentials');
76+
}
77+
});
78+
});

apps/nestjs-backend/src/features/attachments/plugins/s3.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export class S3Storage implements StorageAdapter {
5656
forcePathStyle,
5757
internalForcePathStyle,
5858
} = this.config.s3;
59+
const useIAMRole = this.config.provider === 's3' && this.config.s3.useIAMRole;
5960
this.checkConfig();
6061
this.httpAgent = new http.Agent({
6162
maxSockets,
@@ -74,15 +75,20 @@ export class S3Storage implements StorageAdapter {
7475
httpsAgent: this.httpsAgent,
7576
})
7677
: undefined;
78+
const s3Credentials = useIAMRole
79+
? {}
80+
: {
81+
credentials: {
82+
accessKeyId: accessKey,
83+
secretAccessKey: secretKey,
84+
},
85+
};
7786
this.s3Client = new S3Client({
7887
region,
7988
endpoint,
8089
forcePathStyle,
8190
requestHandler,
82-
credentials: {
83-
accessKeyId: accessKey,
84-
secretAccessKey: secretKey,
85-
},
91+
...s3Credentials,
8692
});
8793
// Reuse the same requestHandler (shared http/https agents) so the
8894
// maxSockets limit governs both public and internal endpoint traffic.
@@ -99,10 +105,7 @@ export class S3Storage implements StorageAdapter {
99105
endpoint: internalEndpoint ?? endpoint,
100106
forcePathStyle: internalPathStyle,
101107
requestHandler,
102-
credentials: {
103-
accessKeyId: accessKey,
104-
secretAccessKey: secretKey,
105-
},
108+
...s3Credentials,
106109
})
107110
: this.s3Client;
108111
fse.ensureDirSync(StorageAdapter.TEMPORARY_DIR);
@@ -113,10 +116,7 @@ export class S3Storage implements StorageAdapter {
113116
endpoint,
114117
bucketEndpoint: true,
115118
requestHandler,
116-
credentials: {
117-
accessKeyId: accessKey,
118-
secretAccessKey: secretKey,
119-
},
119+
...s3Credentials,
120120
})
121121
: this.s3Client;
122122

@@ -199,14 +199,15 @@ export class S3Storage implements StorageAdapter {
199199
},
200200
});
201201
}
202-
if (!this.config.s3.accessKey) {
202+
const useIAMRole = this.config.provider === 's3' && this.config.s3.useIAMRole;
203+
if (!useIAMRole && !this.config.s3.accessKey) {
203204
throw new CustomHttpException('S3 access key is required', HttpErrorCode.VALIDATION_ERROR, {
204205
localization: {
205206
i18nKey: 'httpErrors.attachment.s3AccessKeyRequired',
206207
},
207208
});
208209
}
209-
if (!this.config.s3.secretKey) {
210+
if (!useIAMRole && !this.config.s3.secretKey) {
210211
throw new CustomHttpException('S3 secret key is required', HttpErrorCode.VALIDATION_ERROR, {
211212
localization: {
212213
i18nKey: 'httpErrors.attachment.s3SecretKeyRequired',

apps/nextjs-app/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ BACKEND_STORAGE_S3_ENDPOINT=https://s3.us-east-2.amazonaws.com
1616
BACKEND_STORAGE_PRIVATE_BUCKET_ENDPOINT=https://custom.domain
1717
# s3 internal endpoint, optional
1818
BACKEND_STORAGE_S3_INTERNAL_ENDPOINT=https://s3.us-east-2.amazonaws-internal.com
19+
# Set to true to use the AWS SDK default credential provider chain, such as an EC2/ECS IAM role
20+
BACKEND_STORAGE_S3_USE_IAM_ROLE=false
1921
BACKEND_STORAGE_S3_ACCESS_KEY=your_access_key
2022
BACKEND_STORAGE_S3_SECRET_KEY=your_secret_key
2123

0 commit comments

Comments
 (0)