Skip to content

Commit 9b82ef5

Browse files
- recursive get all the Task states and issue policy statements
- consolidate permissions by action and resource to minimise the no. of statements
1 parent e36299d commit 9b82ef5

File tree

2 files changed

+209
-22
lines changed

2 files changed

+209
-22
lines changed

lib/deploy/stepFunctions/compileIamRole.js

Lines changed: 208 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,238 @@ const _ = require('lodash');
33
const BbPromise = require('bluebird');
44
const path = require('path');
55

6+
function getTaskStates(states) {
7+
return _.flatMap(states, state => {
8+
switch (state.Type) {
9+
case 'Task':
10+
return [state];
11+
case 'Parallel':
12+
const parallelStates = _.flatMap(state.Branches, branch => _.values(branch.States));
13+
return getTaskStates(parallelStates);
14+
default:
15+
return [];
16+
}
17+
});
18+
}
19+
20+
function sqsQueueUrlToArn(serverless, queueUrl) {
21+
const regex = /https:\/\/sqs.(.*).amazonaws.com\/(.*)\/(.*)/g;
22+
const match = regex.exec(queueUrl);
23+
if (match) {
24+
const region = match[1];
25+
const accountId = match[2];
26+
const queueName = match[3];
27+
return `arn:aws:sqs:${region}:${accountId}:${queueName}`;
28+
}
29+
serverless.cli.consoleLog(`Unable to parse SQS queue url [${queueUrl}], using '*' instead`);
30+
return '*';
31+
}
32+
33+
function getSqsPermissions(serverless, state) {
34+
if (_.has(state, 'Parameters.QueueUrl') ||
35+
_.has(state, ['Parameters', 'QueueUrl.$'])) {
36+
// if queue URL is provided by input, then need pervasive permissions (i.e. '*')
37+
const queueArn = state.Parameters['QueueUrl.$']
38+
? '*'
39+
: sqsQueueUrlToArn(serverless, state.Parameters.QueueUrl);
40+
return [{ action: 'sqs:SendMessage', resource: queueArn }];
41+
}
42+
serverless.cli.consoleLog('SQS task missing Parameters.QueueUrl or Parameters.QueueUrl.$');
43+
return [];
44+
}
45+
46+
function getSnsPermissions(serverless, state) {
47+
if (_.has(state, 'Parameters.TopicArn') ||
48+
_.has(state, ['Parameters', 'TopicArn.$'])) {
49+
// if topic ARN is provided by input, then need pervasive permissions
50+
const topicArn = state.Parameters['TopicArn.$'] ? '*' : state.Parameters.TopicArn;
51+
return [{ action: 'sns:Publish', resource: topicArn }];
52+
}
53+
serverless.cli.consoleLog('SNS task missing Parameters.TopicArn or Parameters.TopicArn.$');
54+
return [];
55+
}
56+
57+
function getDynamoDBArn(tableName) {
58+
return {
59+
'Fn::Join': [
60+
':',
61+
[
62+
'arn:aws:dynamodb',
63+
{ Ref: 'AWS::Region' },
64+
{ Ref: 'AWS::AccountId' },
65+
`table/${tableName}`,
66+
],
67+
],
68+
};
69+
}
70+
71+
function getBatchPermissions() {
72+
return [{
73+
action: 'batch:SubmitJob,batch:DescribeJobs,batch:TerminateJob',
74+
resource: '*',
75+
}, {
76+
action: 'events:PutTargets,events:PutRule,events:DescribeRule',
77+
resource: {
78+
'Fn::Join': [
79+
':',
80+
[
81+
'arn:aws:events',
82+
{ Ref: 'AWS::Region' },
83+
{ Ref: 'AWS::AccountId' },
84+
'rules/StepFunctionsGetEventsForBatchJobsRule',
85+
],
86+
],
87+
},
88+
}];
89+
}
90+
91+
function getEcsPermissions() {
92+
return [{
93+
action: 'ecs:RunTask,ecs:StopTask,ecs:DescribeTasks',
94+
resource: '*',
95+
}, {
96+
action: 'events:PutTargets,events:PutRule,events:DescribeRule',
97+
resource: {
98+
'Fn::Join': [
99+
':',
100+
[
101+
'arn:aws:events',
102+
{ Ref: 'AWS::Region' },
103+
{ Ref: 'AWS::AccountId' },
104+
'rules/StepFunctionsGetEventsForECSTaskRule',
105+
],
106+
],
107+
},
108+
}];
109+
}
110+
111+
function getDynamoDBPermissions(action, state) {
112+
const tableArn = state.Parameters['TableName.$']
113+
? '*'
114+
: getDynamoDBArn(state.Parameters.TableName);
115+
116+
return [{
117+
action,
118+
resource: tableArn,
119+
}];
120+
}
121+
122+
// if there are multiple permissions with the same action, then collapsed them into one
123+
// permission instead, and collect the resources into an array
124+
function consolidatePermissionsByAction(permissions) {
125+
return _.chain(permissions)
126+
.groupBy(perm => perm.action)
127+
.mapValues(perms => {
128+
// find the unique resources
129+
let resources = _.uniqWith(_.flatMap(perms, p => p.resource), _.isEqual);
130+
if (resources.includes('*')) {
131+
resources = '*';
132+
}
133+
134+
return {
135+
action: perms[0].action,
136+
resource: resources,
137+
};
138+
})
139+
.values()
140+
.value(); // unchain
141+
}
142+
143+
function consolidatePermissionsByResource(permissions) {
144+
return _.chain(permissions)
145+
.groupBy(p => JSON.stringify(p.resource))
146+
.mapValues(perms => {
147+
// find unique actions
148+
const actions = _.uniq(_.flatMap(perms, p => p.action.split(',')));
149+
150+
return {
151+
action: actions.join(','),
152+
resource: perms[0].resource,
153+
};
154+
})
155+
.values()
156+
.value(); // unchain
157+
}
158+
159+
function getIamPermissions(serverless, taskStates) {
160+
return _.flatMap(taskStates, state => {
161+
switch (state.Resource) {
162+
case 'arn:aws:states:::sqs:sendMessage':
163+
return getSqsPermissions(serverless, state);
164+
165+
case 'arn:aws:states:::sns:publish':
166+
return getSnsPermissions(serverless, state);
167+
168+
case 'arn:aws:states:::dynamodb:updateItem':
169+
return getDynamoDBPermissions('dynamodb:UpdateItem', state);
170+
case 'arn:aws:states:::dynamodb:putItem':
171+
return getDynamoDBPermissions('dynamodb:PutItem', state);
172+
case 'arn:aws:states:::dynamodb:getItem':
173+
return getDynamoDBPermissions('dynamodb:GetItem', state);
174+
case 'arn:aws:states:::dynamodb:deleteItem':
175+
return getDynamoDBPermissions('dynamodb:DeleteItem', state);
176+
177+
case 'arn:aws:states:::batch:submitJob.sync':
178+
case 'arn:aws:states:::batch:submitJob':
179+
return getBatchPermissions();
180+
181+
case 'arn:aws:states:::ecs:runTask.sync':
182+
case 'arn:aws:states:::ecs:runTask':
183+
return getEcsPermissions();
184+
185+
default:
186+
if (state.Resource.startsWith('arn:aws:lambda')) {
187+
return [{
188+
action: 'lambda:InvokeFunction',
189+
resource: state.Resource,
190+
}];
191+
}
192+
serverless.cli.consoleLog('Cannot generate IAM policy statement for Task state', state);
193+
return [];
194+
}
195+
});
196+
}
197+
6198
module.exports = {
7199
compileIamRole() {
8200
const customRolesProvided = [];
9-
let functionArns = [];
201+
let iamPermissions = [];
10202
this.getAllStateMachines().forEach((stateMachineName) => {
11203
const stateMachineObj = this.getStateMachine(stateMachineName);
12204
customRolesProvided.push('role' in stateMachineObj);
13205

14-
const stateMachineJson = JSON.stringify(stateMachineObj);
15-
const regex = new RegExp(/"Resource":"([\w\-:*#{}.$]*)"/gi);
16-
let match = regex.exec(stateMachineJson);
17-
while (match !== null) {
18-
functionArns.push(match[1]);
19-
match = regex.exec(stateMachineJson);
20-
}
206+
const taskStates = getTaskStates(stateMachineObj.definition.States);
207+
iamPermissions = iamPermissions.concat(getIamPermissions(this.serverless, taskStates));
21208
});
22209
if (_.isEqual(_.uniq(customRolesProvided), [true])) {
23210
return BbPromise.resolve();
24211
}
25-
functionArns = _.uniq(functionArns);
26212

27-
let iamRoleStateMachineExecutionTemplate = this.serverless.utils.readFileSync(
213+
const iamRoleStateMachineExecutionTemplate = this.serverless.utils.readFileSync(
28214
path.join(__dirname,
29215
'..',
30216
'..',
31217
'iam-role-statemachine-execution-template.txt')
32218
);
33219

34-
iamRoleStateMachineExecutionTemplate =
220+
iamPermissions = consolidatePermissionsByAction(iamPermissions);
221+
iamPermissions = consolidatePermissionsByResource(iamPermissions);
222+
223+
const iamStatements = iamPermissions.map(p => ({
224+
Effect: 'Allow',
225+
Action: p.action.split(','),
226+
Resource: p.resource,
227+
}));
228+
229+
const iamRoleJson =
35230
iamRoleStateMachineExecutionTemplate
36231
.replace('[region]', this.options.region)
37232
.replace('[PolicyName]', this.getStateMachinePolicyName())
38-
.replace('[functions]', JSON.stringify(functionArns));
233+
.replace('[Statements]', JSON.stringify(iamStatements));
39234

40235
const iamRoleStateMachineLogicalId = this.getiamRoleStateMachineLogicalId();
41236
const newIamRoleStateMachineExecutionObject = {
42-
[iamRoleStateMachineLogicalId]: JSON.parse(iamRoleStateMachineExecutionTemplate),
237+
[iamRoleStateMachineLogicalId]: JSON.parse(iamRoleJson),
43238
};
44239

45240
_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources,

lib/iam-role-statemachine-execution-template.txt

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,7 @@
1818
"PolicyName": "[PolicyName]",
1919
"PolicyDocument": {
2020
"Version": "2012-10-17",
21-
"Statement": [
22-
{
23-
"Effect": "Allow",
24-
"Action": [
25-
"lambda:InvokeFunction"
26-
],
27-
"Resource": [functions]
28-
}
29-
]
21+
"Statement": [Statements]
3022
}
3123
}
3224
]

0 commit comments

Comments
 (0)