Skip to content

Commit 2506c94

Browse files
refactor(data-classes): clean up internal logic for APIGatewayAuthorizerResponse (aws-powertools#643)
1 parent 42abbc8 commit 2506c94

File tree

4 files changed

+142
-43
lines changed

4 files changed

+142
-43
lines changed

.pylintrc

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[MESSAGES CONTROL]
2+
disable=
3+
too-many-arguments,
4+
too-many-instance-attributes,
5+
too-few-public-methods,
6+
anomalous-backslash-in-string,
7+
missing-class-docstring,
8+
missing-module-docstring,
9+
missing-function-docstring,
10+
11+
[FORMAT]
12+
max-line-length=120

aws_lambda_powertools/utilities/data_classes/api_gateway_authorizer_event.py

+80-29
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,12 @@ def raw_query_string(self) -> str:
234234

235235
@property
236236
def cookies(self) -> List[str]:
237+
"""Cookies"""
237238
return self["cookies"]
238239

239240
@property
240241
def headers(self) -> Dict[str, str]:
242+
"""Http headers"""
241243
return self["headers"]
242244

243245
@property
@@ -314,6 +316,8 @@ def asdict(self) -> dict:
314316

315317

316318
class HttpVerb(enum.Enum):
319+
"""Enum of http methods / verbs"""
320+
317321
GET = "GET"
318322
POST = "POST"
319323
PUT = "PUT"
@@ -324,15 +328,32 @@ class HttpVerb(enum.Enum):
324328
ALL = "*"
325329

326330

331+
DENY_ALL_RESPONSE = {
332+
"principalId": "deny-all-user",
333+
"policyDocument": {
334+
"Version": "2012-10-17",
335+
"Statement": [
336+
{
337+
"Action": "execute-api:Invoke",
338+
"Effect": "Deny",
339+
"Resource": ["*"],
340+
}
341+
],
342+
},
343+
}
344+
345+
327346
class APIGatewayAuthorizerResponse:
328-
"""Api Gateway HTTP API V1 payload or Rest api authorizer response helper
347+
"""The IAM Policy Response required for API Gateway REST APIs and HTTP APIs.
329348
330349
Based on: - https://github.com/awslabs/aws-apigateway-lambda-authorizer-blueprints/blob/\
331350
master/blueprints/python/api-gateway-authorizer-python.py
332-
"""
333351
334-
version = "2012-10-17"
335-
"""The policy version used for the evaluation. This should always be '2012-10-17'"""
352+
Documentation:
353+
-------------
354+
- https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
355+
- https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html
356+
"""
336357

337358
path_regex = r"^[/.a-zA-Z0-9-\*]+$"
338359
"""The regular expression used to validate resource paths for the policy"""
@@ -345,6 +366,7 @@ def __init__(
345366
api_id: str,
346367
stage: str,
347368
context: Optional[Dict] = None,
369+
usage_identifier_key: Optional[str] = None,
348370
):
349371
"""
350372
Parameters
@@ -373,32 +395,57 @@ def __init__(
373395
context : Dict, optional
374396
Optional, context.
375397
Note: only names of type string and values of type int, string or boolean are supported
398+
usage_identifier_key: str, optional
399+
If the API uses a usage plan (the apiKeySource is set to `AUTHORIZER`), the Lambda authorizer function
400+
must return one of the usage plan's API keys as the usageIdentifierKey property value.
401+
> **Note:** This only applies for REST APIs.
376402
"""
377403
self.principal_id = principal_id
378404
self.region = region
379405
self.aws_account_id = aws_account_id
380406
self.api_id = api_id
381407
self.stage = stage
382408
self.context = context
409+
self.usage_identifier_key = usage_identifier_key
383410
self._allow_routes: List[Dict] = []
384411
self._deny_routes: List[Dict] = []
412+
self._resource_pattern = re.compile(self.path_regex)
385413

386-
def _add_route(self, effect: str, verb: str, resource: str, conditions: List[Dict]):
414+
@staticmethod
415+
def from_route_arn(
416+
arn: str,
417+
principal_id: str,
418+
context: Optional[Dict] = None,
419+
usage_identifier_key: Optional[str] = None,
420+
) -> "APIGatewayAuthorizerResponse":
421+
parsed_arn = parse_api_gateway_arn(arn)
422+
return APIGatewayAuthorizerResponse(
423+
principal_id,
424+
parsed_arn.region,
425+
parsed_arn.aws_account_id,
426+
parsed_arn.api_id,
427+
parsed_arn.stage,
428+
context,
429+
usage_identifier_key,
430+
)
431+
432+
def _add_route(self, effect: str, http_method: str, resource: str, conditions: Optional[List[Dict]] = None):
387433
"""Adds a route to the internal lists of allowed or denied routes. Each object in
388434
the internal list contains a resource ARN and a condition statement. The condition
389435
statement can be null."""
390-
if verb != "*" and verb not in HttpVerb.__members__:
436+
if http_method != "*" and http_method not in HttpVerb.__members__:
391437
allowed_values = [verb.value for verb in HttpVerb]
392-
raise ValueError(f"Invalid HTTP verb: '{verb}'. Use either '{allowed_values}'")
438+
raise ValueError(f"Invalid HTTP verb: '{http_method}'. Use either '{allowed_values}'")
393439

394-
resource_pattern = re.compile(self.path_regex)
395-
if not resource_pattern.match(resource):
440+
if not self._resource_pattern.match(resource):
396441
raise ValueError(f"Invalid resource path: {resource}. Path should match {self.path_regex}")
397442

398443
if resource[:1] == "/":
399444
resource = resource[1:]
400445

401-
resource_arn = APIGatewayRouteArn(self.region, self.aws_account_id, self.api_id, self.stage, verb, resource).arn
446+
resource_arn = APIGatewayRouteArn(
447+
self.region, self.aws_account_id, self.api_id, self.stage, http_method, resource
448+
).arn
402449

403450
route = {"resourceArn": resource_arn, "conditions": conditions}
404451

@@ -412,24 +459,27 @@ def _get_empty_statement(effect: str) -> Dict[str, Any]:
412459
"""Returns an empty statement object prepopulated with the correct action and the desired effect."""
413460
return {"Action": "execute-api:Invoke", "Effect": effect.capitalize(), "Resource": []}
414461

415-
def _get_statement_for_effect(self, effect: str, methods: List) -> List:
416-
"""This function loops over an array of objects containing a resourceArn and
417-
conditions statement and generates the array of statements for the policy."""
418-
if len(methods) == 0:
462+
def _get_statement_for_effect(self, effect: str, routes: List[Dict]) -> List[Dict]:
463+
"""This function loops over an array of objects containing a `resourceArn` and
464+
`conditions` statement and generates the array of statements for the policy."""
465+
if not routes:
419466
return []
420467

421-
statements = []
422-
468+
statements: List[Dict] = []
423469
statement = self._get_empty_statement(effect)
424-
for method in methods:
425-
if method["conditions"] is None or len(method["conditions"]) == 0:
426-
statement["Resource"].append(method["resourceArn"])
427-
else:
470+
471+
for route in routes:
472+
resource_arn = route["resourceArn"]
473+
conditions = route.get("conditions")
474+
if conditions is not None and len(conditions) > 0:
428475
conditional_statement = self._get_empty_statement(effect)
429-
conditional_statement["Resource"].append(method["resourceArn"])
430-
conditional_statement["Condition"] = method["conditions"]
476+
conditional_statement["Resource"].append(resource_arn)
477+
conditional_statement["Condition"] = conditions
431478
statements.append(conditional_statement)
432479

480+
else:
481+
statement["Resource"].append(resource_arn)
482+
433483
if len(statement["Resource"]) > 0:
434484
statements.append(statement)
435485

@@ -442,7 +492,7 @@ def allow_all_routes(self, http_method: str = HttpVerb.ALL.value):
442492
----------
443493
http_method: str
444494
"""
445-
self._add_route(effect="Allow", verb=http_method, resource="*", conditions=[])
495+
self._add_route(effect="Allow", http_method=http_method, resource="*")
446496

447497
def deny_all_routes(self, http_method: str = HttpVerb.ALL.value):
448498
"""Adds a '*' allow to the policy to deny access to all methods of an API
@@ -452,25 +502,23 @@ def deny_all_routes(self, http_method: str = HttpVerb.ALL.value):
452502
http_method: str
453503
"""
454504

455-
self._add_route(effect="Deny", verb=http_method, resource="*", conditions=[])
505+
self._add_route(effect="Deny", http_method=http_method, resource="*")
456506

457507
def allow_route(self, http_method: str, resource: str, conditions: Optional[List[Dict]] = None):
458508
"""Adds an API Gateway method (Http verb + Resource path) to the list of allowed
459509
methods for the policy.
460510
461511
Optionally includes a condition for the policy statement. More on AWS policy
462512
conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
463-
conditions = conditions or []
464-
self._add_route(effect="Allow", verb=http_method, resource=resource, conditions=conditions)
513+
self._add_route(effect="Allow", http_method=http_method, resource=resource, conditions=conditions)
465514

466515
def deny_route(self, http_method: str, resource: str, conditions: Optional[List[Dict]] = None):
467516
"""Adds an API Gateway method (Http verb + Resource path) to the list of denied
468517
methods for the policy.
469518
470519
Optionally includes a condition for the policy statement. More on AWS policy
471520
conditions here: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html#Condition"""
472-
conditions = conditions or []
473-
self._add_route(effect="Deny", verb=http_method, resource=resource, conditions=conditions)
521+
self._add_route(effect="Deny", http_method=http_method, resource=resource, conditions=conditions)
474522

475523
def asdict(self) -> Dict[str, Any]:
476524
"""Generates the policy document based on the internal lists of allowed and denied
@@ -482,12 +530,15 @@ def asdict(self) -> Dict[str, Any]:
482530

483531
response: Dict[str, Any] = {
484532
"principalId": self.principal_id,
485-
"policyDocument": {"Version": self.version, "Statement": []},
533+
"policyDocument": {"Version": "2012-10-17", "Statement": []},
486534
}
487535

488536
response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Allow", self._allow_routes))
489537
response["policyDocument"]["Statement"].extend(self._get_statement_for_effect("Deny", self._deny_routes))
490538

539+
if self.usage_identifier_key:
540+
response["usageIdentifierKey"] = self.usage_identifier_key
541+
491542
if self.context:
492543
response["context"] = self.context
493544

docs/utilities/data_classes.md

+16-13
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,10 @@ Use **`APIGatewayAuthorizerRequestEvent`** for type `REQUEST` and **`APIGatewayA
9696

9797
When the user is found, it includes the user details in the request context that will be available to the back-end, and returns a full access policy for admin users.
9898

99-
```python hl_lines="2-5 26-31 36-37 40 44 46"
99+
```python hl_lines="2-6 29 36-42 47 49"
100100
from aws_lambda_powertools.utilities.data_classes import event_source
101101
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (
102+
DENY_ALL_RESPONSE,
102103
APIGatewayAuthorizerRequestEvent,
103104
APIGatewayAuthorizerResponse,
104105
HttpVerb,
@@ -108,9 +109,9 @@ Use **`APIGatewayAuthorizerRequestEvent`** for type `REQUEST` and **`APIGatewayA
108109

109110
def get_user_by_token(token):
110111
if compare_digest(token, "admin-foo"):
111-
return {"isAdmin": True, "name": "Admin"}
112+
return {"id": 0, "name": "Admin", "isAdmin": True}
112113
elif compare_digest(token, "regular-foo"):
113-
return {"name": "Joe"}
114+
return {"id": 1, "name": "Joe"}
114115
else:
115116
return None
116117

@@ -119,25 +120,27 @@ Use **`APIGatewayAuthorizerRequestEvent`** for type `REQUEST` and **`APIGatewayA
119120
def handler(event: APIGatewayAuthorizerRequestEvent, context):
120121
user = get_user_by_token(event.get_header_value("Authorization"))
121122

123+
if user is None:
124+
# No user was found
125+
# to return 401 - `{"message":"Unauthorized"}`, but pollutes lambda error count metrics
126+
# raise Exception("Unauthorized")
127+
# to return 403 - `{"message":"Forbidden"}`
128+
return DENY_ALL_RESPONSE
129+
122130
# parse the `methodArn` as an `APIGatewayRouteArn`
123131
arn = event.parsed_arn
132+
124133
# Create the response builder from parts of the `methodArn`
134+
# and set the logged in user id and context
125135
policy = APIGatewayAuthorizerResponse(
126-
principal_id="user",
136+
principal_id=user["id"],
137+
context=user,
127138
region=arn.region,
128139
aws_account_id=arn.aws_account_id,
129140
api_id=arn.api_id,
130-
stage=arn.stage
141+
stage=arn.stage,
131142
)
132143

133-
if user is None:
134-
# No user was found, so we return not authorized
135-
policy.deny_all_routes()
136-
return policy.asdict()
137-
138-
# Found the user and setting the details in the context
139-
policy.context = user
140-
141144
# Conditional IAM Policy
142145
if user.get("isAdmin", False):
143146
policy.allow_all_routes()

tests/functional/data_classes/test_api_gateway_authorizer.py

+34-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from aws_lambda_powertools.utilities.data_classes.api_gateway_authorizer_event import (
4+
DENY_ALL_RESPONSE,
45
APIGatewayAuthorizerResponse,
56
HttpVerb,
67
)
@@ -36,7 +37,8 @@ def test_authorizer_response_invalid_resource(builder: APIGatewayAuthorizerRespo
3637

3738

3839
def test_authorizer_response_allow_all_routes_with_context():
39-
builder = APIGatewayAuthorizerResponse("foo", "us-west-1", "123456789", "fantom", "dev", {"name": "Foo"})
40+
arn = "arn:aws:execute-api:us-west-1:123456789:fantom/dev/GET/foo"
41+
builder = APIGatewayAuthorizerResponse.from_route_arn(arn, principal_id="foo", context={"name": "Foo"})
4042
builder.allow_all_routes()
4143
assert builder.asdict() == {
4244
"principalId": "foo",
@@ -54,6 +56,26 @@ def test_authorizer_response_allow_all_routes_with_context():
5456
}
5557

5658

59+
def test_authorizer_response_allow_all_routes_with_usage_identifier_key():
60+
arn = "arn:aws:execute-api:us-east-1:1111111111:api/dev/ANY/y"
61+
builder = APIGatewayAuthorizerResponse.from_route_arn(arn, principal_id="cow", usage_identifier_key="key")
62+
builder.allow_all_routes()
63+
assert builder.asdict() == {
64+
"principalId": "cow",
65+
"policyDocument": {
66+
"Version": "2012-10-17",
67+
"Statement": [
68+
{
69+
"Action": "execute-api:Invoke",
70+
"Effect": "Allow",
71+
"Resource": ["arn:aws:execute-api:us-east-1:1111111111:api/dev/*/*"],
72+
}
73+
],
74+
},
75+
"usageIdentifierKey": "key",
76+
}
77+
78+
5779
def test_authorizer_response_deny_all_routes(builder: APIGatewayAuthorizerResponse):
5880
builder.deny_all_routes()
5981
assert builder.asdict() == {
@@ -145,3 +167,14 @@ def test_authorizer_response_deny_route_with_conditions(builder: APIGatewayAutho
145167
],
146168
},
147169
}
170+
171+
172+
def test_deny_all():
173+
# CHECK we always explicitly deny all
174+
statements = DENY_ALL_RESPONSE["policyDocument"]["Statement"]
175+
assert len(statements) == 1
176+
assert statements[0] == {
177+
"Action": "execute-api:Invoke",
178+
"Effect": "Deny",
179+
"Resource": ["*"],
180+
}

0 commit comments

Comments
 (0)