Skip to content

Commit 6f1aa92

Browse files
author
Michael Brewer
committed
feat(data-classes): add authorizer response builder
1 parent cc364b0 commit 6f1aa92

File tree

3 files changed

+337
-0
lines changed

3 files changed

+337
-0
lines changed

aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py

+178
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from typing import Any, Dict, List, Optional
23

34
from aws_lambda_powertools.utilities.data_classes.common import (
@@ -309,3 +310,180 @@ def asdict(self) -> dict:
309310
response["context"] = self.context
310311

311312
return response
313+
314+
315+
class HttpVerb:
316+
GET = "GET"
317+
POST = "POST"
318+
PUT = "PUT"
319+
PATCH = "PATCH"
320+
HEAD = "HEAD"
321+
DELETE = "DELETE"
322+
OPTIONS = "OPTIONS"
323+
ALL = "*"
324+
325+
326+
class APIGatewayAuthorizerResponse:
327+
"""Api Gateway HTTP API V1 payload or Rest api authorizer response helper
328+
329+
Based on: - https://github.com/awslabs/aws-apigateway-lambda-authorizer-blueprints/blob/master/blueprints/python
330+
/api-gateway-authorizer-python.py
331+
"""
332+
333+
version = "2012-10-17"
334+
"""The policy version used for the evaluation. This should always be '2012-10-17'"""
335+
336+
path_regex = r"^[/.a-zA-Z0-9-\*]+$"
337+
"""The regular expression used to validate resource paths for the policy"""
338+
339+
def __init__(
340+
self,
341+
principal_id: str,
342+
region: str,
343+
aws_account_id: str,
344+
api_id: str,
345+
stage: str,
346+
context: Optional[Dict] = None,
347+
):
348+
"""
349+
Parameters
350+
----------
351+
principal_id : str
352+
The principal used for the policy, this should be a unique identifier for the end user
353+
region : str
354+
AWS Regions. Beware of using '*' since it will not simply mean any region, because stars will greedily
355+
expand over '/' or other separators.
356+
See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more
357+
details.
358+
aws_account_id : str
359+
The AWS account id the policy will be generated for. This is used to create the method ARNs.
360+
api_id : str
361+
The API Gateway API id to be used in the policy.
362+
Beware of using '*' since it will not simply mean any API Gateway API id, because stars will greedily
363+
expand over '/' or other separators.
364+
See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more
365+
details.
366+
stage : str
367+
The default stage to be used in the policy. Replace the placeholder value with a default stage to be
368+
used in the policy. Beware of using '*' since it will not simply mean any stage, because stars will
369+
greedily expand over '/' or other separators.
370+
See https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_resource.html for more
371+
details.
372+
context : Dict, optional
373+
Optional, context.
374+
Note: only names of type string and values of type int, string or boolean are supported
375+
"""
376+
self.principal_id = principal_id
377+
self.region = region
378+
self.aws_account_id = aws_account_id
379+
self.api_id = api_id
380+
self.stage = stage
381+
self.context = context
382+
383+
"""these are the internal lists of allowed and denied methods. These are lists
384+
of objects and each object has 2 properties: A resource ARN and a nullable
385+
conditions statement.
386+
the build method processes these lists and generates the appropriate
387+
statements for the final policy"""
388+
self._allow_methods: List[Dict] = []
389+
self._deny_methods: List[Dict] = []
390+
391+
def _add_method(self, effect: str, verb: str, resource: str, conditions: List[Dict]):
392+
"""Adds a method to the internal lists of allowed or denied methods. Each object in
393+
the internal list contains a resource ARN and a condition statement. The condition
394+
statement can be null."""
395+
if verb != "*" and not hasattr(HttpVerb, verb):
396+
raise NameError(f"Invalid HTTP verb {verb}. Allowed verbs in HttpVerb class")
397+
398+
resource_pattern = re.compile(self.path_regex)
399+
if not resource_pattern.match(resource):
400+
raise NameError(f"Invalid resource path: {resource}. Path should match {self.path_regex}")
401+
if resource[:1] == "/":
402+
resource = resource[1:]
403+
404+
resource_arn = APIGatewayRouteArn(self.region, self.aws_account_id, self.api_id, self.stage, verb, resource).arn
405+
406+
method = {"resourceArn": resource_arn, "conditions": conditions}
407+
if effect.lower() == "allow":
408+
self._allow_methods.append(method)
409+
else: # deny
410+
self._deny_methods.append(method)
411+
412+
@staticmethod
413+
def _get_empty_statement(effect: str) -> Dict[str, Any]:
414+
"""Returns an empty statement object prepopulated with the correct action and the desired effect."""
415+
return {"Action": "execute-api:Invoke", "Effect": effect.capitalize(), "Resource": []}
416+
417+
def _get_statement_for_effect(self, effect: str, methods: List) -> List:
418+
"""This function loops over an array of objects containing a resourceArn and
419+
conditions statement and generates the array of statements for the policy."""
420+
if len(methods) == 0:
421+
return []
422+
423+
statements = []
424+
425+
statement = self._get_empty_statement(effect)
426+
for method in methods:
427+
if method["conditions"] is None or len(method["conditions"]) == 0:
428+
statement["Resource"].append(method["resourceArn"])
429+
else:
430+
conditional_statement = self._get_empty_statement(effect)
431+
conditional_statement["Resource"].append(method["resourceArn"])
432+
conditional_statement["Condition"] = method["conditions"]
433+
statements.append(conditional_statement)
434+
435+
if len(statement["Resource"]) > 0:
436+
statements.append(statement)
437+
438+
return statements
439+
440+
def allow_all_methods(self):
441+
"""Adds a '*' allow to the policy to authorize access to all methods of an API"""
442+
self._add_method("Allow", HttpVerb.ALL, "*", [])
443+
444+
def deny_all_methods(self):
445+
"""Adds a '*' allow to the policy to deny access to all methods of an API"""
446+
self._add_method("Deny", HttpVerb.ALL, "*", [])
447+
448+
def allow_method(self, verb, resource: str):
449+
"""Adds an API Gateway method (Http verb + Resource path) to the list of allowed
450+
methods for the policy"""
451+
self._add_method("Allow", verb, resource, [])
452+
453+
def deny_method(self, verb: str, resource: str):
454+
"""Adds an API Gateway method (Http verb + Resource path) to the list of denied
455+
methods for the policy"""
456+
self._add_method("Deny", verb, resource, [])
457+
458+
def allow_method_with_conditions(self, verb: str, resource: str, conditions: List[Dict]):
459+
"""Adds an API Gateway method (Http verb + Resource path) to the list of allowed
460+
methods and includes a condition for the policy statement. More on AWS policy
461+
conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
462+
self._add_method("Allow", verb, resource, conditions)
463+
464+
def deny_method_with_conditions(self, verb: str, resource: str, conditions: List[Dict]):
465+
"""Adds an API Gateway method (Http verb + Resource path) to the list of denied
466+
methods and includes a condition for the policy statement. More on AWS policy
467+
conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
468+
self._add_method("Deny", verb, resource, conditions)
469+
470+
def build(self) -> Dict[str, Any]:
471+
"""Generates the policy document based on the internal lists of allowed and denied
472+
conditions. This will generate a policy with two main statements for the effect:
473+
one statement for Allow and one statement for Deny.
474+
Methods that includes conditions will have their own statement in the policy."""
475+
if len(self._allow_methods) == 0 and len(self._deny_methods) == 0:
476+
raise NameError("No statements defined for the policy")
477+
478+
response: Dict[str, Any] = {
479+
"principalId": self.principal_id,
480+
"policyDocument": {"Version": self.version, "Statement": []},
481+
}
482+
483+
response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Allow", self._allow_methods))
484+
response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Deny", self._deny_methods))
485+
486+
if self.context:
487+
response["context"] = self.context
488+
489+
return response

tests/functional/data_classes/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import pytest
2+
3+
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (
4+
APIGatewayAuthorizerResponse,
5+
HttpVerb,
6+
)
7+
8+
9+
@pytest.fixture
10+
def builder():
11+
return APIGatewayAuthorizerResponse("foo", "us-west-1", "123456789", "fantom", "dev")
12+
13+
14+
def test_authorizer_response_no_statement(builder: APIGatewayAuthorizerResponse):
15+
# GIVEN a builder with no statements
16+
with pytest.raises(NameError) as ex:
17+
# WHEN calling build
18+
builder.build()
19+
20+
# THEN raise a name error for not statements
21+
assert str(ex.value) == "No statements defined for the policy"
22+
23+
24+
def test_authorizer_response_invalid_verb(builder: APIGatewayAuthorizerResponse):
25+
with pytest.raises(NameError) as ex:
26+
# GIVEN a invalid http_method
27+
# WHEN calling deny_method
28+
builder.deny_method("INVALID", "foo")
29+
30+
# THEN raise a name error for invalid http verb
31+
assert str(ex.value) == "Invalid HTTP verb INVALID. Allowed verbs in HttpVerb class"
32+
33+
34+
def test_authorizer_response_invalid_resource(builder: APIGatewayAuthorizerResponse):
35+
with pytest.raises(NameError) as ex:
36+
# GIVEN a invalid resource path "$"
37+
# WHEN calling deny_method
38+
builder.deny_method(HttpVerb.GET, "$")
39+
40+
# THEN raise a name error for invalid resource
41+
assert "Invalid resource path: $" in str(ex.value)
42+
43+
44+
def test_authorizer_response_allow_all_methods_with_context():
45+
builder = APIGatewayAuthorizerResponse("foo", "us-west-1", "123456789", "fantom", "dev", {"name": "Foo"})
46+
builder.allow_all_methods()
47+
assert builder.build() == {
48+
"principalId": "foo",
49+
"policyDocument": {
50+
"Version": "2012-10-17",
51+
"Statement": [
52+
{
53+
"Action": "execute-api:Invoke",
54+
"Effect": "Allow",
55+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/*/*"],
56+
}
57+
],
58+
},
59+
"context": {"name": "Foo"},
60+
}
61+
62+
63+
def test_authorizer_response_deny_all_methods(builder: APIGatewayAuthorizerResponse):
64+
builder.deny_all_methods()
65+
assert builder.build() == {
66+
"principalId": "foo",
67+
"policyDocument": {
68+
"Version": "2012-10-17",
69+
"Statement": [
70+
{
71+
"Action": "execute-api:Invoke",
72+
"Effect": "Deny",
73+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/*/*"],
74+
}
75+
],
76+
},
77+
}
78+
79+
80+
def test_authorizer_response_allow_method(builder: APIGatewayAuthorizerResponse):
81+
builder.allow_method(HttpVerb.GET, "/foo")
82+
assert builder.build() == {
83+
"policyDocument": {
84+
"Version": "2012-10-17",
85+
"Statement": [
86+
{
87+
"Action": "execute-api:Invoke",
88+
"Effect": "Allow",
89+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/GET/foo"],
90+
}
91+
],
92+
},
93+
"principalId": "foo",
94+
}
95+
96+
97+
def test_authorizer_response_deny_method(builder: APIGatewayAuthorizerResponse):
98+
builder.deny_method(HttpVerb.PUT, "foo")
99+
assert builder.build() == {
100+
"principalId": "foo",
101+
"policyDocument": {
102+
"Version": "2012-10-17",
103+
"Statement": [
104+
{
105+
"Action": "execute-api:Invoke",
106+
"Effect": "Deny",
107+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/PUT/foo"],
108+
}
109+
],
110+
},
111+
}
112+
113+
114+
def test_authorizer_response_allow_method_with_conditions(builder: APIGatewayAuthorizerResponse):
115+
builder.allow_method_with_conditions(
116+
HttpVerb.POST,
117+
"/foo",
118+
[
119+
{"StringEquals": {"method.request.header.Content-Type": "text/html"}},
120+
],
121+
)
122+
assert builder.build() == {
123+
"principalId": "foo",
124+
"policyDocument": {
125+
"Version": "2012-10-17",
126+
"Statement": [
127+
{
128+
"Action": "execute-api:Invoke",
129+
"Effect": "Allow",
130+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/POST/foo"],
131+
"Condition": [{"StringEquals": {"method.request.header.Content-Type": "text/html"}}],
132+
}
133+
],
134+
},
135+
}
136+
137+
138+
def test_authorizer_response_deny_method_with_conditions(builder: APIGatewayAuthorizerResponse):
139+
builder.deny_method_with_conditions(
140+
HttpVerb.POST,
141+
"/foo",
142+
[
143+
{"StringEquals": {"method.request.header.Content-Type": "application/json"}},
144+
],
145+
)
146+
assert builder.build() == {
147+
"principalId": "foo",
148+
"policyDocument": {
149+
"Version": "2012-10-17",
150+
"Statement": [
151+
{
152+
"Action": "execute-api:Invoke",
153+
"Effect": "Deny",
154+
"Resource": ["arn:aws:execute-api:us-west-1:123456789:fantom/dev/POST/foo"],
155+
"Condition": [{"StringEquals": {"method.request.header.Content-Type": "application/json"}}],
156+
}
157+
],
158+
},
159+
}

0 commit comments

Comments
 (0)