Skip to content

Commit

Permalink
feat: usage plans support for Api Auth (#1179)
Browse files Browse the repository at this point in the history
  • Loading branch information
Shreya authored Jan 21, 2020
1 parent adca0eb commit 5be0b07
Show file tree
Hide file tree
Showing 16 changed files with 3,937 additions and 3 deletions.
80 changes: 80 additions & 0 deletions examples/2016-10-31/usage_plan/template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
Parameters:
UsagePlanType:
Type: String
Default: PER_API

Globals:
Api:
OpenApiVersion: 3.0.0
Auth:
ApiKeyRequired: true
UsagePlan:
CreateUsagePlan: !Ref UsagePlanType

Resources:
MyApiOne:
Type: AWS::Serverless::Api
Properties:
StageName: Prod

MyApiTwo:
Type: AWS::Serverless::Api
Properties:
StageName: Prod
Auth:
UsagePlan:
CreateUsagePlan: SHARED

MyFunctionOne:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs12.x
InlineCode: |
exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify(event),
headers: {}
}
}
Events:
ApiKey:
Type: Api
Properties:
RestApiId:
Ref: MyApiOne
Method: get
Path: /path/one

MyFunctionTwo:
Type: AWS::Serverless::Function
Properties:
Handler: index.handler
Runtime: nodejs12.x
InlineCode: |
exports.handler = async (event) => {
return {
statusCode: 200,
body: JSON.stringify(event),
headers: {}
}
}
Events:
ApiKey:
Type: Api
Properties:
RestApiId:
Ref: MyApiTwo
Method: get
Path: /path/two
Outputs:
ApiOneUrl:
Description: "API endpoint URL for Prod environment"
Value:
Fn::Sub: 'https://${MyApiOne}.execute-api.${AWS::Region}.amazonaws.com/Prod/'

ApiTwoUrl:
Description: "API endpoint URL for Prod environment"
Value:
Fn::Sub: 'https://${MyApiTwo}.execute-api.${AWS::Region}.amazonaws.com/Prod/'
147 changes: 145 additions & 2 deletions samtranslator/model/api/api_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
ApiGatewayResponse,
ApiGatewayDomainName,
ApiGatewayBasePathMapping,
ApiGatewayUsagePlan,
ApiGatewayUsagePlanKey,
ApiGatewayApiKey,
)
from samtranslator.model.route53 import Route53RecordSetGroup
from samtranslator.model.exceptions import InvalidResourceException
Expand Down Expand Up @@ -37,14 +40,24 @@
"AddDefaultAuthorizerToCorsPreflight",
"ApiKeyRequired",
"ResourcePolicy",
"UsagePlan",
],
)
AuthProperties.__new__.__defaults__ = (None, None, None, True, None, None)
AuthProperties.__new__.__defaults__ = (None, None, None, True, None, None, None)
UsagePlanProperties = namedtuple(
"_UsagePlanProperties", ["CreateUsagePlan", "Description", "Quota", "Tags", "Throttle", "UsagePlanName"]
)
UsagePlanProperties.__new__.__defaults__ = (None, None, None, None, None, None)

GatewayResponseProperties = ["ResponseParameters", "ResponseTemplates", "StatusCode"]


class ApiGenerator(object):
usage_plan_shared = False
stage_keys_shared = list()
api_stages_shared = list()
depends_on_shared = list()

def __init__(
self,
logical_id,
Expand Down Expand Up @@ -392,8 +405,9 @@ def to_cloudformation(self, redeploy_restapi_parameters):

stage = self._construct_stage(deployment, swagger, redeploy_restapi_parameters)
permissions = self._construct_authorizer_lambda_permission()
usage_plan = self._construct_usage_plan(rest_api_stage=stage)

return rest_api, deployment, stage, permissions, domain, basepath_mapping, route53
return rest_api, deployment, stage, permissions, domain, basepath_mapping, route53, usage_plan

def _add_cors(self):
"""
Expand Down Expand Up @@ -518,6 +532,135 @@ def _add_auth(self):

self.definition_body = self._openapi_postprocess(swagger_editor.swagger)

def _construct_usage_plan(self, rest_api_stage=None):
"""Constructs and returns the ApiGateway UsagePlan, ApiGateway UsagePlanKey, ApiGateway ApiKey for Auth.
:param model.apigateway.ApiGatewayStage stage: the stage of rest api
:returns: UsagePlan, UsagePlanKey, ApiKey for this rest Api
:rtype: model.apigateway.ApiGatewayUsagePlan, model.apigateway.ApiGatewayUsagePlanKey,
model.apigateway.ApiGatewayApiKey
"""
create_usage_plans_accepted_values = ["SHARED", "PER_API", "NONE"]
if not self.auth:
return []
auth_properties = AuthProperties(**self.auth)
if auth_properties.UsagePlan is None:
return []
usage_plan_properties = auth_properties.UsagePlan
# throws error if the property invalid/ unsupported for UsagePlan
if not all(key in UsagePlanProperties._fields for key in usage_plan_properties.keys()):
raise InvalidResourceException(self.logical_id, "Invalid property for 'UsagePlan'")

create_usage_plan = usage_plan_properties.get("CreateUsagePlan")
usage_plan = None
api_key = None
usage_plan_key = None

if create_usage_plan is None:
raise InvalidResourceException(self.logical_id, "'CreateUsagePlan' is a required field for UsagePlan")
if create_usage_plan not in create_usage_plans_accepted_values:
raise InvalidResourceException(
self.logical_id, "'CreateUsagePlan' accepts one of {}".format(create_usage_plans_accepted_values)
)

if create_usage_plan == "NONE":
return []

# create usage plan for this api only
elif usage_plan_properties.get("CreateUsagePlan") == "PER_API":
usage_plan_logical_id = self.logical_id + "UsagePlan"
usage_plan = ApiGatewayUsagePlan(logical_id=usage_plan_logical_id, depends_on=[self.logical_id])
api_stages = list()
api_stage = dict()
api_stage["ApiId"] = ref(self.logical_id)
api_stage["Stage"] = ref(rest_api_stage.logical_id)
api_stages.append(api_stage)
usage_plan.ApiStages = api_stages

api_key = self._construct_api_key(usage_plan_logical_id, create_usage_plan, rest_api_stage)
usage_plan_key = self._construct_usage_plan_key(usage_plan_logical_id, create_usage_plan, api_key)

# create a usage plan for all the Apis
elif create_usage_plan == "SHARED":
usage_plan_logical_id = "ServerlessUsagePlan"
ApiGenerator.depends_on_shared.append(self.logical_id)
usage_plan = ApiGatewayUsagePlan(
logical_id=usage_plan_logical_id, depends_on=ApiGenerator.depends_on_shared
)
api_stage = dict()
api_stage["ApiId"] = ref(self.logical_id)
api_stage["Stage"] = ref(rest_api_stage.logical_id)
ApiGenerator.api_stages_shared.append(api_stage)
usage_plan.ApiStages = ApiGenerator.api_stages_shared

api_key = self._construct_api_key(usage_plan_logical_id, create_usage_plan, rest_api_stage)
usage_plan_key = self._construct_usage_plan_key(usage_plan_logical_id, create_usage_plan, api_key)

if usage_plan_properties.get("UsagePlanName"):
usage_plan.UsagePlanName = usage_plan_properties.get("UsagePlanName")
if usage_plan_properties.get("Description"):
usage_plan.Description = usage_plan_properties.get("Description")
if usage_plan_properties.get("Quota"):
usage_plan.Quota = usage_plan_properties.get("Quota")
if usage_plan_properties.get("Tags"):
usage_plan.Tags = usage_plan_properties.get("Tags")
if usage_plan_properties.get("Throttle"):
usage_plan.Throttle = usage_plan_properties.get("Throttle")
return usage_plan, api_key, usage_plan_key

def _construct_api_key(self, usage_plan_logical_id, create_usage_plan, rest_api_stage):
"""
:param usage_plan_logical_id: String
:param create_usage_plan: String
:param rest_api_stage: model.apigateway.ApiGatewayStage stage: the stage of rest api
:return: api_key model.apigateway.ApiGatewayApiKey resource which is created for the given usage plan
"""
if create_usage_plan == "SHARED":
# create an api key resource for all the apis
api_key_logical_id = "ServerlessApiKey"
api_key = ApiGatewayApiKey(logical_id=api_key_logical_id, depends_on=[usage_plan_logical_id])
api_key.Enabled = True
stage_key = dict()
stage_key["RestApiId"] = ref(self.logical_id)
stage_key["StageName"] = ref(rest_api_stage.logical_id)
ApiGenerator.stage_keys_shared.append(stage_key)
api_key.StageKeys = ApiGenerator.stage_keys_shared
# for create_usage_plan = "PER_API"
else:
# create an api key resource for this api
api_key_logical_id = self.logical_id + "ApiKey"
api_key = ApiGatewayApiKey(logical_id=api_key_logical_id, depends_on=[usage_plan_logical_id])
api_key.Enabled = True
stage_keys = list()
stage_key = dict()
stage_key["RestApiId"] = ref(self.logical_id)
stage_key["StageName"] = ref(rest_api_stage.logical_id)
stage_keys.append(stage_key)
api_key.StageKeys = stage_keys
return api_key

def _construct_usage_plan_key(self, usage_plan_logical_id, create_usage_plan, api_key):
"""
:param usage_plan_logical_id: String
:param create_usage_plan: String
:param api_key: model.apigateway.ApiGatewayApiKey resource
:return: model.apigateway.ApiGatewayUsagePlanKey resource that contains the mapping between usage plan and api key
"""
if create_usage_plan == "SHARED":
# create a mapping between api key and the usage plan
usage_plan_key_logical_id = "ServerlessUsagePlanKey"
# for create_usage_plan = "PER_API"
else:
# create a mapping between api key and the usage plan
usage_plan_key_logical_id = self.logical_id + "UsagePlanKey"

usage_plan_key = ApiGatewayUsagePlanKey(logical_id=usage_plan_key_logical_id, depends_on=[api_key.logical_id])
usage_plan_key.KeyId = ref(api_key.logical_id)
usage_plan_key.KeyType = "API_KEY"
usage_plan_key.UsagePlanId = ref(usage_plan_logical_id)

return usage_plan_key

def _add_gateway_responses(self):
"""
Add Gateway Response configuration to the Swagger file, if necessary
Expand Down
37 changes: 37 additions & 0 deletions samtranslator/model/apigateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,43 @@ class ApiGatewayBasePathMapping(Resource):
}


class ApiGatewayUsagePlan(Resource):
resource_type = "AWS::ApiGateway::UsagePlan"
property_types = {
"ApiStages": PropertyType(False, is_type(list)),
"Description": PropertyType(False, is_str()),
"Quota": PropertyType(False, is_type(dict)),
"Tags": PropertyType(False, list_of(dict)),
"Throttle": PropertyType(False, is_type(dict)),
"UsagePlanName": PropertyType(False, is_str()),
}
runtime_attrs = {"usage_plan_id": lambda self: ref(self.logical_id)}


class ApiGatewayUsagePlanKey(Resource):
resource_type = "AWS::ApiGateway::UsagePlanKey"
property_types = {
"KeyId": PropertyType(True, is_str()),
"KeyType": PropertyType(True, is_str()),
"UsagePlanId": PropertyType(True, is_str()),
}


class ApiGatewayApiKey(Resource):
resource_type = "AWS::ApiGateway::ApiKey"
property_types = {
"CustomerId": PropertyType(False, is_str()),
"Description": PropertyType(False, is_str()),
"Enabled": PropertyType(False, is_type(bool)),
"GenerateDistinctId": PropertyType(False, is_type(bool)),
"Name": PropertyType(False, is_str()),
"StageKeys": PropertyType(False, is_type(list)),
"Value": PropertyType(False, is_str()),
}

runtime_attrs = {"api_key_id": lambda self: ref(self.logical_id)}


class ApiGatewayAuthorizer(object):
_VALID_FUNCTION_PAYLOAD_TYPES = [None, "TOKEN", "REQUEST"]

Expand Down
7 changes: 6 additions & 1 deletion samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,8 @@ def to_cloudformation(self, **kwargs):
intrinsics_resolver = kwargs["intrinsics_resolver"]
self.BinaryMediaTypes = intrinsics_resolver.resolve_parameter_refs(self.BinaryMediaTypes)
self.Domain = intrinsics_resolver.resolve_parameter_refs(self.Domain)
self.Auth = intrinsics_resolver.resolve_parameter_refs(self.Auth)

redeploy_restapi_parameters = kwargs.get("redeploy_restapi_parameters")

api_generator = ApiGenerator(
Expand Down Expand Up @@ -797,7 +799,7 @@ def to_cloudformation(self, **kwargs):
domain=self.Domain,
)

rest_api, deployment, stage, permissions, domain, basepath_mapping, route53 = api_generator.to_cloudformation(
rest_api, deployment, stage, permissions, domain, basepath_mapping, route53, usage_plan_resources = api_generator.to_cloudformation(
redeploy_restapi_parameters
)

Expand All @@ -809,6 +811,9 @@ def to_cloudformation(self, **kwargs):
resources.extend(basepath_mapping)
if route53:
resources.extend([route53])
# contains usage plan, api key and usageplan key resources
if usage_plan_resources:
resources.extend(usage_plan_resources)
return resources


Expand Down
Loading

0 comments on commit 5be0b07

Please sign in to comment.