-
Notifications
You must be signed in to change notification settings - Fork 57
/
Copy pathindex.ts
405 lines (380 loc) · 13.9 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
import _ from 'lodash';
import util from 'util';
const PLUGIN_NAME = 'serverless-iam-roles-per-function';
interface Statement {
Effect: 'Allow' | 'Deny';
Action: string | string[];
Resource: string | any[];
}
class ServerlessIamPerFunctionPlugin {
hooks: {[i: string]: () => void};
serverless: any;
awsPackagePlugin: any;
defaultInherit: boolean;
readonly PROVIDER_AWS = 'aws';
/**
*
* @param {Serverless} serverless - serverless host object
* @param {Object} _options
*/
constructor(serverless: any, _options?: any) {
this.serverless = serverless;
if (this.serverless.service.provider.name !== this.PROVIDER_AWS) {
throw new this.serverless.classes.Error(`${PLUGIN_NAME} plugin supports only AWS`);
}
// Added: Schema based validation of service config
// https://github.com/serverless/serverless/releases/tag/v1.78.0
if (this.serverless.configSchemaHandler) {
const newCustomPropSchema = {
type: 'object',
properties: {
[PLUGIN_NAME]: {
type: 'object',
properties: {
defaultInherit: {
type: 'boolean',
},
},
additionalProperties: false,
},
},
};
serverless.configSchemaHandler.defineCustomProperties(newCustomPropSchema);
// Added: defineFunctionProperties schema extension method
// https://github.com/serverless/serverless/releases/tag/v2.10.0
if (this.serverless.configSchemaHandler.defineFunctionProperties) {
this.serverless.configSchemaHandler.defineFunctionProperties(this.PROVIDER_AWS, {
properties: {
iamRoleStatementsInherit: { type: 'boolean' },
iamRoleStatementsName: { type: 'string' },
iamRoleStatements: { $ref: '#/definitions/awsIamPolicyStatements' },
},
});
}
}
this.hooks = {
'before:package:finalize': this.createRolesPerFunction.bind(this),
};
this.defaultInherit = _.get(this.serverless.service, `custom.${PLUGIN_NAME}.defaultInherit`, false);
}
/**
* Utility function which throws an error. The msg will be formatted with args using util.format.
* Error message will be prefixed with ${PLUGIN_NAME}: ERROR:
* @param {string} msg
* @param {*[]} args
* @returns void
*/
throwError(msg: string, ...args: any[]) {
if (!_.isEmpty(args)) {
msg = util.format(msg, args);
}
const errMsg = `${PLUGIN_NAME}: ERROR: ${msg}`;
throw new this.serverless.classes.Error(errMsg);
}
/**
* @param {*} statements
* @returns void
*/
validateStatements(statements: any): void {
// Verify that iamRoleStatements (if present) is an array of { Effect: ...,
// Action: ..., Resource: ... } objects.
if (_.isEmpty(statements)) {
return;
}
let violationsFound;
if (!Array.isArray(statements)) {
violationsFound = 'it is not an array';
} else {
const descriptions = statements.map((statement, i) => {
const missing = [
['Effect'],
['Action', 'NotAction'],
['Resource', 'NotResource'],
].filter((props) => props.every((prop) => !statement[prop]));
return missing.length === 0
? null
: `statement ${i} is missing the following properties: ${missing.map((m) => m.join(' / ')).join(', ')}`;
});
const flawed = descriptions.filter((curr) => curr);
if (flawed.length) {
violationsFound = flawed.join('; ');
}
}
if (violationsFound) {
const errorMessage = [
'iamRoleStatements should be an array of objects,',
' where each object has Effect, Action / NotAction, Resource / NotResource fields.',
` Specifically, ${violationsFound}`,
].join('');
this.throwError(errorMessage);
}
}
/**
* @param {*[]} nameParts
* @returns void
*/
getRoleNameLength(nameParts: any[]) {
let length = 0; // calculate the expected length. Sum the length of each part
for (const part of nameParts) {
if (part.Ref) {
if (part.Ref === 'AWS::Region') {
length += this.serverless.service.provider.region.length;
} else {
length += part.Ref.length;
}
} else {
length += part.length;
}
}
length += (nameParts.length - 1); // take into account the dashes between parts
return length;
}
/**
* @param {string} functionName
* @returns {string}
*/
getFunctionRoleName(functionName: string) {
const roleName = this.serverless.providers.aws.naming.getRoleName();
const fnJoin = roleName['Fn::Join'];
if (!_.isArray(fnJoin) || fnJoin.length !== 2 || !_.isArray(fnJoin[1]) || fnJoin[1].length < 2) {
this.throwError('Global Role Name is not in expected format. Got name: ' + JSON.stringify(roleName));
}
fnJoin[1].splice(2, 0, functionName); // insert the function name
if (this.getRoleNameLength(fnJoin[1]) > 64 && fnJoin[1][fnJoin[1].length - 1] === 'lambdaRole') {
// Remove lambdaRole from name to give more space for function name.
fnJoin[1].pop();
}
if (this.getRoleNameLength(fnJoin[1]) > 64) { // aws limits to 64 chars the role name
this.throwError(`auto generated role name for function: ${functionName} is too long (over 64 chars).
Try setting a custom role name using the property: iamRoleStatementsName.`);
}
return roleName;
}
/**
* @param {string} functionName
* @param {string} roleName
* @param {string} globalRoleName
* @return the function resource name
*/
updateFunctionResourceRole(functionName: string, roleName: string, globalRoleName: string): string {
const functionResourceName = this.serverless.providers.aws.naming.getLambdaLogicalId(functionName);
const functionResource = this.serverless.service.provider
.compiledCloudFormationTemplate.Resources[functionResourceName];
if (_.isEmpty(functionResource)
|| _.isEmpty(functionResource.Properties)
|| _.isEmpty(functionResource.Properties.Role)
|| !_.isArray(functionResource.Properties.Role['Fn::GetAtt'])
|| !_.isArray(functionResource.DependsOn)
) {
this.throwError('Function Resource is not in expected format. For function name: ' + functionName);
}
functionResource.DependsOn = [roleName].concat(
functionResource.DependsOn.filter(((val: any) => val !== globalRoleName )),
);
functionResource.Properties.Role['Fn::GetAtt'][0] = roleName;
return functionResourceName;
}
/**
* Get the necessary statement permissions if there are SQS event sources.
* @param {*} functionObject
* @return statement (possibly null)
*/
getSqsStatement(functionObject: any) {
const sqsStatement: Statement = {
Effect: 'Allow',
Action: [
'sqs:ReceiveMessage',
'sqs:DeleteMessage',
'sqs:GetQueueAttributes',
],
Resource: [],
};
for (const event of functionObject.events) {
if (event.sqs) {
const sqsArn = event.sqs.arn || event.sqs;
(sqsStatement.Resource as any[]).push(sqsArn);
}
}
return sqsStatement.Resource.length ? sqsStatement : null;
}
/**
* Get the necessary statement permissions if there are stream event sources of dynamo or kinesis.
* @param {*} functionObject
* @return array of statements (possibly empty)
*/
getStreamStatements(functionObject: any) {
const res: any[] = [];
if (_.isEmpty(functionObject.events)) { // no events
return res;
}
const dynamodbStreamStatement: Statement = {
Effect: 'Allow',
Action: [
'dynamodb:GetRecords',
'dynamodb:GetShardIterator',
'dynamodb:DescribeStream',
'dynamodb:ListStreams',
],
Resource: [],
};
const kinesisStreamStatement: Statement = {
Effect: 'Allow',
Action: [
'kinesis:GetRecords',
'kinesis:GetShardIterator',
'kinesis:DescribeStream',
'kinesis:ListStreams',
],
Resource: [],
};
for (const event of functionObject.events) {
if (event.stream) {
const streamArn = event.stream.arn || event.stream;
const streamType = event.stream.type || streamArn.split(':')[2];
switch (streamType) {
case 'dynamodb':
(dynamodbStreamStatement.Resource as any[]).push(streamArn);
break;
case 'kinesis':
(kinesisStreamStatement.Resource as any[]).push(streamArn);
break;
default:
this.throwError(`Unsupported stream type: ${streamType} for function: `, functionObject);
}
}
}
if (dynamodbStreamStatement.Resource.length) {
res.push(dynamodbStreamStatement);
}
if (kinesisStreamStatement.Resource.length) {
res.push(kinesisStreamStatement);
}
return res;
}
/**
* Will check if function has a definition of iamRoleStatements.
* If so will create a new Role for the function based on these statements.
* @param {string} functionName
* @param {Map} functionToRoleMap - populate the map with a mapping from function resource name to role resource name
* @returns void
*/
createRoleForFunction(functionName: string, functionToRoleMap: Map<string, string>) {
const functionObject = this.serverless.service.getFunction(functionName);
if (functionObject.iamRoleStatements === undefined) {
return;
}
if (functionObject.role) {
this.throwError(
'Define function with both \'role\' and \'iamRoleStatements\' is not supported. Function name: '
+ functionName,
);
}
this.validateStatements(functionObject.iamRoleStatements);
// we use the configured role as a template
const globalRoleName = this.serverless.providers.aws.naming.getRoleLogicalId();
const globalIamRole = this.serverless.service.provider.compiledCloudFormationTemplate.Resources[globalRoleName];
const functionIamRole = _.cloneDeep(globalIamRole);
// remove the statements
const policyStatements: Statement[] = [];
functionIamRole.Properties.Policies[0].PolicyDocument.Statement = policyStatements;
// set log statements
policyStatements[0] = {
Effect: 'Allow',
Action: ['logs:CreateLogStream', 'logs:PutLogEvents'],
Resource: [
{
'Fn::Sub': 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}' +
`:log-group:${this.serverless.providers.aws.naming.getLogGroupName(functionObject.name)}:*:*`,
},
],
};
// remove managed policies
functionIamRole.Properties.ManagedPolicyArns = [];
// set vpc if needed
if (!_.isEmpty(functionObject.vpc) || !_.isEmpty(this.serverless.service.provider.vpc)) {
functionIamRole.Properties.ManagedPolicyArns = [{
'Fn::Join': ['',
[
'arn:',
{ Ref: 'AWS::Partition' },
':iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole',
],
],
}];
}
for (const s of this.getStreamStatements(functionObject)) { // set stream statements (if needed)
policyStatements.push(s);
}
const sqsStatement = this.getSqsStatement(functionObject); // set sqs statement (if needed)
if (sqsStatement) {
policyStatements.push(sqsStatement);
}
// set sns publish for DLQ if needed
// currently only sns is supported: https://serverless.com/framework/docs/providers/aws/events/sns#dlq-with-sqs
if (!_.isEmpty(functionObject.onError)) {
policyStatements.push({
Effect: 'Allow',
Action: [
'sns:Publish',
],
Resource: functionObject.onError,
});
}
const isInherit = functionObject.iamRoleStatementsInherit
|| (this.defaultInherit && functionObject.iamRoleStatementsInherit !== false);
if (isInherit && !_.isEmpty(this.serverless.service.provider.iamRoleStatements)) { // add global statements
for (const s of this.serverless.service.provider.iamRoleStatements) {
policyStatements.push(s);
}
}
// add iamRoleStatements
if (_.isArray(functionObject.iamRoleStatements)) {
for (const s of functionObject.iamRoleStatements) {
policyStatements.push(s);
}
}
functionIamRole.Properties.RoleName = functionObject.iamRoleStatementsName
|| this.getFunctionRoleName(functionName);
const roleResourceName = this.serverless.providers.aws.naming.getNormalizedFunctionName(functionName)
+ globalRoleName;
this.serverless.service.provider.compiledCloudFormationTemplate.Resources[roleResourceName] = functionIamRole;
const functionResourceName = this.updateFunctionResourceRole(functionName, roleResourceName, globalRoleName);
functionToRoleMap.set(functionResourceName, roleResourceName);
}
/**
* Go over each EventSourceMapping and if it is for a function with a function level iam role
* then adjust the DependsOn
* @param {Map} functionToRoleMap
* @returns void
*/
setEventSourceMappings(functionToRoleMap: Map<string, string>) {
for (const mapping of _.values(this.serverless.service.provider.compiledCloudFormationTemplate.Resources)) {
if (mapping.Type && mapping.Type === 'AWS::Lambda::EventSourceMapping') {
const functionNameFn = _.get(mapping, 'Properties.FunctionName.Fn::GetAtt');
if (!_.isArray(functionNameFn)) {
continue;
}
const functionName = functionNameFn[0];
const roleName = functionToRoleMap.get(functionName);
if (roleName) {
mapping.DependsOn = roleName;
}
}
}
}
/**
* @returns void
*/
createRolesPerFunction() {
const allFunctions = this.serverless.service.getAllFunctions();
if (_.isEmpty(allFunctions)) {
return;
}
const functionToRoleMap: Map<string, string> = new Map();
for (const func of allFunctions) {
this.createRoleForFunction(func, functionToRoleMap);
}
this.setEventSourceMappings(functionToRoleMap);
}
}
export = ServerlessIamPerFunctionPlugin;