Skip to content

Commit bec01ce

Browse files
authored
Merge pull request #42 from erezrokah/feat/custom_roles
Feat: add support for specifying custom roles
2 parents 346b6a8 + 5378205 commit bec01ce

23 files changed

+542
-61
lines changed

README.md

+26
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,32 @@ resources:
327327

328328
Source: [AWS::ApiGateway::Method docs](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apigateway-method.html#cfn-apigateway-method-authorizationtype)
329329

330+
### Using a Custom IAM Role
331+
332+
By default, the plugin will generate a role with the required permissions for each service type that is configured.
333+
334+
You can configure your own role by setting the `roleArn` attribute:
335+
336+
```yaml
337+
custom:
338+
apiGatewayServiceProxies:
339+
- sqs:
340+
path: /sqs
341+
method: post
342+
queueName: { 'Fn::GetAtt': ['SQSQueue', 'QueueName'] }
343+
cors: true
344+
roleArn: # Optional. A default role is created when not configured
345+
Fn::GetAtt: [CustomS3Role, Arn]
346+
347+
resources:
348+
Resources:
349+
SQSQueue:
350+
Type: 'AWS::SQS::Queue'
351+
CustomS3Role:
352+
# Custom Role definition
353+
Type: 'AWS::IAM::Role'
354+
```
355+
330356
### Customizing request body mapping templates
331357

332358
#### Kinesis
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
service: custom-role-proxy
2+
3+
provider:
4+
name: aws
5+
runtime: nodejs10.x
6+
7+
plugins:
8+
localPath: './../../../../../../'
9+
modules:
10+
- serverless-apigateway-service-proxy
11+
12+
custom:
13+
apiGatewayServiceProxies:
14+
- s3:
15+
path: /s3-custom-role/{key}
16+
method: post
17+
action: PutObject
18+
bucket:
19+
Ref: S3Bucket
20+
key:
21+
pathParam: key
22+
cors: true
23+
roleArn:
24+
Fn::GetAtt: [CustomS3Role, Arn]
25+
26+
resources:
27+
Resources:
28+
S3Bucket:
29+
Type: 'AWS::S3::Bucket'
30+
CustomS3Role:
31+
Type: 'AWS::IAM::Role'
32+
Properties:
33+
AssumeRolePolicyDocument:
34+
Version: '2012-10-17'
35+
Statement:
36+
- Effect: 'Allow'
37+
Principal: {
38+
Service: 'apigateway.amazonaws.com'
39+
}
40+
Action: 'sts:AssumeRole'
41+
Policies:
42+
- PolicyName: 'apigatewaytos3'
43+
PolicyDocument:
44+
Version: '2012-10-17'
45+
Statement:
46+
- Effect: 'Allow'
47+
Action: 's3:PutObject*'
48+
Resource:
49+
Fn::Join:
50+
- ''
51+
- - Fn::GetAtt: [S3Bucket, Arn]
52+
- '/*'
53+
Outputs:
54+
S3BucketName:
55+
Value:
56+
Ref: S3Bucket
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use strict'
2+
3+
const expect = require('chai').expect
4+
const fetch = require('node-fetch')
5+
const {
6+
deployWithRandomStage,
7+
removeService,
8+
getS3Object,
9+
deleteS3Object
10+
} = require('../../../utils')
11+
12+
describe('Proxy With Custom Role Integration Test', () => {
13+
let endpoint
14+
let stage
15+
let bucket
16+
const config = '__tests__/integration/common/custom-role/service/serverless.yml'
17+
const key = 'my-test-object.json'
18+
19+
beforeAll(async () => {
20+
const result = await deployWithRandomStage(config)
21+
22+
stage = result.stage
23+
endpoint = result.endpoint
24+
bucket = result.outputs.S3BucketName
25+
})
26+
27+
afterAll(async () => {
28+
await deleteS3Object(bucket, key)
29+
removeService(stage, config)
30+
})
31+
32+
it('should get correct response from s3 proxy endpoint with custom role', async () => {
33+
const testEndpoint = `${endpoint}/s3-custom-role/${key}`
34+
35+
const response = await fetch(testEndpoint, {
36+
method: 'POST',
37+
headers: { 'Content-Type': 'application/json' },
38+
body: JSON.stringify({ message: 'test' })
39+
})
40+
expect(response.headers.get('access-control-allow-origin')).to.deep.equal('*')
41+
expect(response.status).to.be.equal(200)
42+
43+
const uploadedObject = await getS3Object(bucket, key)
44+
expect(uploadedObject.toString()).to.equal(JSON.stringify({ message: 'test' }))
45+
})
46+
})

lib/apiGateway/schema.js

+26-23
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,38 @@ const authorizationType = Joi.alternatives().when('authorizerId', {
6565
// https://hapi.dev/family/joi/?v=15.1.0#objectpatternpattern-schema
6666
const requestParameters = Joi.object().pattern(Joi.string(), Joi.string().required())
6767

68+
const stringOrGetAtt = (propertyName, attributeName) => {
69+
return Joi.alternatives().try([
70+
Joi.string(),
71+
Joi.object({
72+
'Fn::GetAtt': Joi.array()
73+
.length(2)
74+
.ordered(
75+
Joi.string().required(),
76+
Joi.string()
77+
.valid(attributeName)
78+
.required()
79+
)
80+
.required()
81+
}).error(
82+
customErrorBuilder(
83+
'object.child',
84+
`"${propertyName}" must be in the format "{ 'Fn::GetAtt': ['<ResourceId>', '${attributeName}'] }"`
85+
)
86+
)
87+
])
88+
}
89+
90+
const roleArn = stringOrGetAtt('roleArn', 'Arn')
91+
6892
const proxy = Joi.object({
6993
path,
7094
method,
7195
cors,
7296
authorizationType,
7397
authorizerId,
74-
authorizationScopes
98+
authorizationScopes,
99+
roleArn
75100
})
76101
.oxor('authorizerId', 'authorizationScopes') // can have one of them, but not required
77102
.error(
@@ -119,28 +144,6 @@ const partitionKey = Joi.alternatives().try([
119144
)
120145
])
121146

122-
const stringOrGetAtt = (propertyName, attributeName) => {
123-
return Joi.alternatives().try([
124-
Joi.string(),
125-
Joi.object({
126-
'Fn::GetAtt': Joi.array()
127-
.length(2)
128-
.ordered(
129-
Joi.string().required(),
130-
Joi.string()
131-
.valid(attributeName)
132-
.required()
133-
)
134-
.required()
135-
}).error(
136-
customErrorBuilder(
137-
'object.child',
138-
`"${propertyName}" must be in the format "{ 'Fn::GetAtt': ['<ResourceId>', '${attributeName}'] }"`
139-
)
140-
)
141-
])
142-
}
143-
144147
const request = Joi.object({
145148
template: Joi.object().required()
146149
})

lib/apiGateway/validate.test.js

+97-4
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,100 @@ describe('#validateServiceProxies()', () => {
579579
})
580580
})
581581

582+
it('should throw if "roleArn" is not a string or an AWS intrinsic "Fn::GetAtt" function', () => {
583+
serverlessApigatewayServiceProxy.serverless.service.custom = {
584+
apiGatewayServiceProxies: [
585+
{
586+
kinesis: {
587+
path: '/kinesis',
588+
streamName: 'streamName',
589+
method: 'post',
590+
roleArn: []
591+
}
592+
}
593+
]
594+
}
595+
596+
expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw(
597+
serverless.classes.Error,
598+
'child "kinesis" fails because [child "roleArn" fails because ["roleArn" must be a string, "roleArn" must be an object]]'
599+
)
600+
})
601+
602+
it('should throw if "roleArn" is an AWS intrinsic function other than "Fn::GetAtt"', () => {
603+
serverlessApigatewayServiceProxy.serverless.service.custom = {
604+
apiGatewayServiceProxies: [
605+
{
606+
kinesis: {
607+
path: '/kinesis',
608+
streamName: 'streamName',
609+
method: 'post',
610+
roleArn: { Ref: 'KinesisCustomRoleId' }
611+
}
612+
}
613+
]
614+
}
615+
616+
expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw(
617+
serverless.classes.Error,
618+
`child "kinesis" fails because [child "roleArn" fails because ["roleArn" must be a string, "roleArn" must be in the format "{ 'Fn::GetAtt': ['<ResourceId>', 'Arn'] }"]]`
619+
)
620+
})
621+
622+
it('should throw if "roleArn" is an AWS intrinsic "Fn::GetAtt" function for an attribute other than Arn', () => {
623+
serverlessApigatewayServiceProxy.serverless.service.custom = {
624+
apiGatewayServiceProxies: [
625+
{
626+
kinesis: {
627+
path: '/kinesis',
628+
streamName: 'streamName',
629+
method: 'post',
630+
roleArn: { 'Fn::GetAtt': ['KinesisCustomRoleId', 'RoleId'] }
631+
}
632+
}
633+
]
634+
}
635+
636+
expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.throw(
637+
serverless.classes.Error,
638+
`child "kinesis" fails because [child "roleArn" fails because ["roleArn" must be a string, "roleArn" must be in the format "{ 'Fn::GetAtt': ['<ResourceId>', 'Arn'] }"]]`
639+
)
640+
})
641+
642+
it('should not throw error if "roleArn" is a string', () => {
643+
serverlessApigatewayServiceProxy.serverless.service.custom = {
644+
apiGatewayServiceProxies: [
645+
{
646+
kinesis: {
647+
path: '/kinesis',
648+
streamName: 'streamName',
649+
method: 'post',
650+
roleArn: 'roleArn'
651+
}
652+
}
653+
]
654+
}
655+
656+
expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.not.throw()
657+
})
658+
659+
it('should not throw error if "roleArn" is valid intrinsic function', () => {
660+
serverlessApigatewayServiceProxy.serverless.service.custom = {
661+
apiGatewayServiceProxies: [
662+
{
663+
kinesis: {
664+
path: '/kinesis',
665+
streamName: 'streamName',
666+
method: 'post',
667+
roleArn: { 'Fn::GetAtt': ['KinesisCustomRoleId', 'Arn'] }
668+
}
669+
}
670+
]
671+
}
672+
673+
expect(() => serverlessApigatewayServiceProxy.validateServiceProxies()).to.not.throw()
674+
})
675+
582676
const proxiesToTest = [
583677
{ proxy: 'kinesis', props: { streamName: 'streamName' } },
584678
{ proxy: 'sns', props: { topicName: 'topicName' } }
@@ -1280,11 +1374,10 @@ describe('#validateServiceProxies()', () => {
12801374
})
12811375

12821376
it('should not show error if queueName is a string', () => {
1283-
serverlessApigatewayServiceProxy.validated = {
1284-
events: [
1377+
serverlessApigatewayServiceProxy.serverless.service.custom = {
1378+
apiGatewayServiceProxies: [
12851379
{
1286-
serviceName: 'sqs',
1287-
http: {
1380+
sqs: {
12881381
queueName: 'yourQueue',
12891382
path: 'sqs',
12901383
method: 'post'

lib/package/kinesis/compileIamRoleToKinesis.js

+7-5
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
'use strict'
22
const _ = require('lodash')
33

4+
const SERVICE_NAME = 'kinesis'
5+
46
module.exports = {
57
compileIamRoleToKinesis() {
8+
if (!this.shouldCreateDefaultRole(SERVICE_NAME)) {
9+
return
10+
}
11+
612
const kinesisStreamNames = this.getAllServiceProxies()
7-
.filter((serviceProxy) => this.getServiceName(serviceProxy) === 'kinesis')
13+
.filter((serviceProxy) => this.getServiceName(serviceProxy) === SERVICE_NAME)
814
.map((serviceProxy) => {
915
const serviceName = this.getServiceName(serviceProxy)
1016
const { streamName } = serviceProxy[serviceName]
1117
return streamName
1218
})
1319

14-
if (kinesisStreamNames.length <= 0) {
15-
return
16-
}
17-
1820
const policyResource = kinesisStreamNames.map((streamName) => ({
1921
'Fn::Sub': [
2022
'arn:aws:kinesis:${AWS::Region}:${AWS::AccountId}:stream/${streamName}',

lib/package/kinesis/compileIamRoleToKinesis.test.js

+28-1
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('#compileIamRoleToKinesis()', () => {
109109
apiGatewayServiceProxies: [
110110
{
111111
sqs: {
112-
path: '/kinesis',
112+
path: '/sqs',
113113
method: 'post'
114114
}
115115
}
@@ -120,4 +120,31 @@ describe('#compileIamRoleToKinesis()', () => {
120120

121121
expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.be.empty
122122
})
123+
124+
it('should not create default role if all proxies have a custom role', () => {
125+
serverlessApigatewayServiceProxy.serverless.service.custom = {
126+
apiGatewayServiceProxies: [
127+
{
128+
kinesis: {
129+
path: '/kinesis1',
130+
method: 'post',
131+
streamName: { Ref: 'KinesisStream1' },
132+
roleArn: 'roleArn1'
133+
}
134+
},
135+
{
136+
kinesis: {
137+
path: '/kinesis2',
138+
method: 'post',
139+
streamName: { Ref: 'KinesisStream2' },
140+
roleArn: 'roleArn2'
141+
}
142+
}
143+
]
144+
}
145+
146+
serverlessApigatewayServiceProxy.compileIamRoleToKinesis()
147+
148+
expect(serverless.service.provider.compiledCloudFormationTemplate.Resources).to.be.empty
149+
})
123150
})

0 commit comments

Comments
 (0)