Skip to content

Commit fcb59cc

Browse files
authored
Merge pull request #16 from route06/issue-6-add-rbac
Enable use case with RBAC to improve security
2 parents 053e643 + 7be9167 commit fcb59cc

8 files changed

+1225
-24
lines changed

Makefile

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,25 @@
1-
install:
1+
BUILD_DIR = ./dist
2+
SRC_DIR = ./src
3+
NODE_MODULES = ./node_modules
4+
SRC_FILES := $(shell find $(SRC_DIR) -type f)
5+
6+
.PHONY: all install build run
7+
8+
all: build
9+
10+
$(NODE_MODULES):
211
pnpm install
312

13+
install: $(NODE_MODULES)
14+
15+
$(BUILD_DIR): $(SRC_FILES) $(NODE_MODULES)
16+
pnpm build
17+
18+
build: $(BUILD_DIR)
19+
20+
run: build
21+
node ./bin/index.js --profile "${AWS_PROFILE}" --access-key "${ACCESS_KEY}" --secret-key "${SECRET_KEY}" --role-arn "${ROLE_ARN}" --region "${AWS_REGION}" -S $(SLACK_TOKEN_AWS_COST_CLI) -C $(SLACK_CHANNEL_ID)
22+
423
test:
524
pnpm test
625

jest.config.cjs

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ var esmModules = [
99
'ansi-regex',
1010
'is-interactive',
1111
'stdin-discarder',
12+
'aws-sdk-client-mock',
1213
];
1314

1415
module.exports = {

package.json

+4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"homepage": "https://github.com/kamranahmedse/aws-cost-cli#readme",
5454
"dependencies": {
55+
"@aws-sdk/client-sts": "^3.521.0",
5556
"@aws-sdk/shared-ini-file-loader": "^3.254.0",
5657
"aws-sdk": "^2.1299.0",
5758
"chalk": "^5.2.0",
@@ -66,11 +67,14 @@
6667
"@babel/preset-env": "^7.24.6",
6768
"@babel/preset-typescript": "^7.24.6",
6869
"@eslint/js": "^9.4.0",
70+
"@smithy/types": "^3.0.0",
6971
"@types/eslint__js": "^8.42.3",
7072
"@types/jest": "^29.5.12",
7173
"@types/node": "^18.11.18",
7274
"@typescript-eslint/eslint-plugin": "^7.11.0",
7375
"@typescript-eslint/parser": "^7.11.0",
76+
"aws-sdk-client-mock": "^2.2.0",
77+
"aws-sdk-client-mock-jest": "^4.0.0",
7478
"babel-jest": "^29.7.0",
7579
"eslint": "^9.4.0",
7680
"jest": "^29.7.0",

pnpm-lock.yaml

+1,023-5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

readme.md

+54-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ $ aws-cost --help
3535
-k, --access-key [key] AWS access key
3636
-s, --secret-key [key] AWS secret key
3737
-r, --region [region] AWS region (default: us-east-1)
38+
-a, --role-arn [arn] AWS role ARN to assume
3839

3940
-p, --profile [profile] AWS profile to use (default: "default")
4041

@@ -63,6 +64,12 @@ aws-cost
6364

6465
To configure the credentials using aws-cli, have a look at the [aws-cli docs](https://github.com/aws/aws-cli#configuration) for more information.
6566

67+
If you need to assume a role, you can pass the `role-arn` option:
68+
69+
```bash
70+
aws-cost -a arn:aws:iam::123456789012:role/your-role-arn
71+
```
72+
6673
## Detailed Breakdown
6774

6875
> The default usage is to get the cost breakdown by service
@@ -197,7 +204,53 @@ Regarding the credentials, you need to have the following permissions in order t
197204
}
198205
```
199206

200-
Also, please note that this tool uses AWS Cost Explorer under the hood which [costs $0.01 per request](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
207+
If you need to use Role-Based Access Control (RBAC), you will need to configure two IAM roles: the provider role and the consumer role.
208+
209+
1. **Provider Role**
210+
211+
This role provides the necessary permissions for `aws-cost-cli`. It requires the above permissions policy and the following trust policy.
212+
213+
**Trust Policy**
214+
215+
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole` with the ARN of the consumer role.
216+
217+
```json
218+
{
219+
"Version": "2012-10-17",
220+
"Statement": [
221+
{
222+
"Effect": "Allow",
223+
"Principal": {
224+
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole"
225+
},
226+
"Action": "sts:AssumeRole"
227+
}
228+
]
229+
}
230+
```
231+
232+
2. **Consumer Role**
233+
234+
This role is used by the user or service (such as GitHub Actions) that needs to assume the provider role to access cost information. It requires the following permissions policy.
235+
236+
**Permissions Policy**
237+
238+
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole` with the ARN of the provider role.
239+
240+
```json
241+
{
242+
"Version": "2012-10-17",
243+
"Statement": [
244+
{
245+
"Effect": "Allow",
246+
"Action": "sts:AssumeRole",
247+
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole"
248+
}
249+
]
250+
}
251+
```
252+
253+
Please also note that this tool uses AWS Cost Explorer under the hood which [costs $0.01 per request](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
201254

202255
## License
203256

src/config.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
STSClient,
3+
AssumeRoleCommand,
4+
AssumeRoleCommandOutput,
5+
Credentials,
6+
} from '@aws-sdk/client-sts';
7+
import 'aws-sdk-client-mock-jest';
8+
import { AwsClientStub, mockClient } from 'aws-sdk-client-mock';
9+
import { getAwsConfigFromOptionsOrFile } from './config';
10+
11+
let stsMock: AwsClientStub<STSClient>;
12+
13+
beforeEach(() => {
14+
stsMock = mockClient(STSClient) as AwsClientStub<STSClient>;
15+
});
16+
17+
afterEach(() => {
18+
stsMock.restore();
19+
});
20+
21+
describe('should assume role if roleArn is provided', (): void => {
22+
const mockCredentials: Credentials = {
23+
AccessKeyId: 'mockAccessKeyId',
24+
SecretAccessKey: 'mockSecretAccessKey',
25+
SessionToken: 'mockSessionToken',
26+
Expiration: new Date(),
27+
};
28+
29+
it('should assume role if `roleArn` is provided', async () => {
30+
const roleArn = 'arn:aws:iam::123456789012:role/test-role';
31+
stsMock.on(AssumeRoleCommand).resolves({
32+
Credentials: mockCredentials,
33+
$metadata: {},
34+
} as AssumeRoleCommandOutput);
35+
36+
const awsConfig = await getAwsConfigFromOptionsOrFile({
37+
profile: 'default',
38+
accessKey: '',
39+
secretKey: '',
40+
sessionToken: '',
41+
region: 'us-east-1',
42+
roleArn,
43+
});
44+
45+
expect(stsMock).toHaveReceivedCommandWith(AssumeRoleCommand, {
46+
RoleArn: roleArn,
47+
});
48+
49+
expect(awsConfig.credentials).toEqual({
50+
accessKeyId: mockCredentials.AccessKeyId,
51+
secretAccessKey: mockCredentials.SecretAccessKey,
52+
sessionToken: mockCredentials.SessionToken,
53+
});
54+
});
55+
56+
it('should allow ABAC if `{accessKey, secretKey, sessionToken}` provided', async () => {
57+
const accessKey = 'testAccessKey';
58+
const secretKey = 'testSecretKey';
59+
const sessionToken = 'testSessionToken';
60+
61+
const awsConfig = await getAwsConfigFromOptionsOrFile({
62+
profile: 'default',
63+
accessKey: accessKey,
64+
secretKey: secretKey,
65+
sessionToken: sessionToken,
66+
region: 'us-east-1',
67+
roleArn: '',
68+
});
69+
70+
expect(stsMock).toHaveReceivedCommandTimes(AssumeRoleCommand, 0);
71+
72+
expect(awsConfig.credentials).toEqual({
73+
accessKeyId: accessKey,
74+
secretAccessKey: secretKey,
75+
sessionToken: sessionToken,
76+
});
77+
});
78+
});

src/config.ts

+42-17
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { loadSharedConfigFiles } from '@aws-sdk/shared-ini-file-loader';
2+
import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts';
23
import chalk from 'chalk';
34
import { printFatalError } from './logger';
45

@@ -23,8 +24,10 @@ export async function getAwsConfigFromOptionsOrFile(options: {
2324
secretKey;
2425
sessionToken;
2526
region: string;
27+
roleArn?: string;
2628
}): Promise<AWSConfig> {
27-
const { profile, accessKey, secretKey, sessionToken, region } = options;
29+
const { profile, accessKey, secretKey, sessionToken, region, roleArn } =
30+
options;
2831

2932
if (accessKey || secretKey) {
3033
if (!accessKey || !secretKey) {
@@ -46,7 +49,7 @@ export async function getAwsConfigFromOptionsOrFile(options: {
4649
}
4750

4851
return {
49-
credentials: await loadAwsCredentials(profile),
52+
credentials: await loadAwsCredentials(profile, region, roleArn),
5053
region: region,
5154
};
5255
}
@@ -57,6 +60,8 @@ export async function getAwsConfigFromOptionsOrFile(options: {
5760
*/
5861
async function loadAwsCredentials(
5962
profile: string = 'default',
63+
region: string,
64+
roleArn = '',
6065
): Promise<AWSConfig['credentials'] | undefined> {
6166
const configFiles = await loadSharedConfigFiles();
6267

@@ -71,34 +76,54 @@ async function loadAwsCredentials(
7176
// https://github.com/kamranahmedse/aws-cost-cli/issues/1
7277
// const configFile = configFiles.configFile;
7378
// const region: string = configFile?.[profile]?.region;
79+
if (accessKey && secretKey) {
80+
return {
81+
accessKeyId: accessKey,
82+
secretAccessKey: secretKey,
83+
sessionToken: sessionToken,
84+
};
85+
} else {
86+
try {
87+
const stsClient = new STSClient({ region: region });
88+
const assumeRoleCommand = new AssumeRoleCommand({
89+
RoleArn: roleArn,
90+
RoleSessionName: 'aws-cost-cli',
91+
});
92+
93+
const { Credentials } = await stsClient.send(assumeRoleCommand);
94+
95+
if (Credentials) {
96+
return {
97+
accessKeyId: Credentials.AccessKeyId,
98+
secretAccessKey: Credentials.SecretAccessKey,
99+
sessionToken: Credentials.SessionToken,
100+
};
101+
}
102+
} catch (error) {
103+
console.error('Error fetching temporary credentials:', error);
104+
}
74105

75-
if (!accessKey || !secretKey) {
76106
const sharedCredentialsFile =
77107
process.env.AWS_SHARED_CREDENTIALS_FILE || '~/.aws/credentials';
78108
const sharedConfigFile = process.env.AWS_CONFIG_FILE || '~/.aws/config';
79109

80110
printFatalError(`
81111
Could not find the AWS credentials in the following files for the profile "${profile}":
82-
${chalk.bold(sharedCredentialsFile)}
83-
${chalk.bold(sharedConfigFile)}
112+
${chalk.bold(sharedCredentialsFile)}
113+
${chalk.bold(sharedConfigFile)}
84114
85115
If the config files exist at different locations, set the following environment variables:
86-
${chalk.bold(`AWS_SHARED_CREDENTIALS_FILE`)}
87-
${chalk.bold(`AWS_CONFIG_FILE`)}
116+
${chalk.bold(`AWS_SHARED_CREDENTIALS_FILE`)}
117+
${chalk.bold(`AWS_CONFIG_FILE`)}
88118
89119
You can also configure the credentials via the following command:
90-
${chalk.bold(`aws configure --profile ${profile}`)}
120+
${chalk.bold(`aws configure --profile ${profile}`)}
91121
92122
You can also provide the credentials via the following options:
93-
${chalk.bold(`--access-key`)}
94-
${chalk.bold(`--secret-key`)}
95-
${chalk.bold(`--region`)}
123+
${chalk.bold(`--access-key`)}
124+
${chalk.bold(`--secret-key`)}
125+
${chalk.bold(`--region`)}
126+
${chalk.bold(`--role-arn`)}
96127
`);
97128
}
98-
99-
return {
100-
accessKeyId: accessKey,
101-
secretAccessKey: secretKey,
102-
sessionToken: sessionToken,
103-
};
104129
}

src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ program
2424
.option('-s, --secret-key [key]', 'AWS secret key')
2525
.option('-t, --session-Token [key]', 'AWS session Token')
2626
.option('-r, --region [region]', 'AWS region', 'us-east-1')
27+
.option('--role-arn [arn]', 'ARN of IAM role')
2728
// Output variants
2829
.option('-j, --json', 'Get the output as JSON')
2930
.option('-u, --summary', 'Get only the summary without service breakdown')
@@ -45,6 +46,7 @@ type OptionsType = {
4546
secretKey: string;
4647
sessionToken: string;
4748
region: string;
49+
roleArn: string;
4850
// AWS profile to use
4951
profile: string;
5052
// Output variants
@@ -71,6 +73,7 @@ const awsConfig = await getAwsConfigFromOptionsOrFile({
7173
secretKey: options.secretKey,
7274
sessionToken: options.sessionToken,
7375
region: options.region,
76+
roleArn: options.roleArn,
7477
});
7578

7679
const alias = await getAccountAlias(awsConfig);

0 commit comments

Comments
 (0)