clear-skies-aws 1.10.2__py3-none-any.whl → 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +36 -35
- clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
- {clear_skies_aws-1.10.2.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +1 -1
- clear_skies_aws-2.0.2.dist-info/licenses/LICENSE +21 -0
- clearskies_aws/__init__.py +15 -2
- clearskies_aws/actions/__init__.py +13 -106
- clearskies_aws/actions/action_aws.py +74 -57
- clearskies_aws/actions/assume_role.py +43 -30
- clearskies_aws/actions/ses.py +82 -73
- clearskies_aws/actions/sns.py +27 -30
- clearskies_aws/actions/sqs.py +32 -33
- clearskies_aws/actions/step_function.py +38 -31
- clearskies_aws/backends/__init__.py +11 -4
- clearskies_aws/backends/backend.py +106 -0
- clearskies_aws/backends/dynamo_db_backend.py +150 -155
- clearskies_aws/backends/dynamo_db_condition_parser.py +40 -80
- clearskies_aws/backends/dynamo_db_parti_ql_backend.py +179 -337
- clearskies_aws/backends/sqs_backend.py +32 -51
- clearskies_aws/configs/__init__.py +0 -0
- clearskies_aws/contexts/__init__.py +23 -10
- clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
- clearskies_aws/contexts/lambda_alb.py +76 -0
- clearskies_aws/contexts/lambda_api_gateway.py +75 -28
- clearskies_aws/contexts/lambda_api_gateway_web_socket.py +56 -29
- clearskies_aws/contexts/lambda_invocation.py +15 -44
- clearskies_aws/contexts/lambda_sns.py +8 -33
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +14 -36
- clearskies_aws/di/__init__.py +6 -1
- clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
- clearskies_aws/di/inject/__init__.py +6 -0
- clearskies_aws/di/inject/boto3.py +15 -0
- clearskies_aws/di/inject/boto3_session.py +13 -0
- clearskies_aws/di/inject/parameter_store.py +15 -0
- clearskies_aws/{handlers → endpoints}/secrets_manager_rotation.py +76 -55
- clearskies_aws/endpoints/simple_body_routing.py +41 -0
- clearskies_aws/input_outputs/__init__.py +21 -8
- clearskies_aws/input_outputs/{cli_websocket_mock.py → cli_web_socket_mock.py} +9 -3
- clearskies_aws/input_outputs/lambda_alb.py +53 -0
- clearskies_aws/input_outputs/lambda_api_gateway.py +106 -88
- clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +69 -6
- clearskies_aws/input_outputs/lambda_input_output.py +87 -0
- clearskies_aws/input_outputs/lambda_invocation.py +77 -26
- clearskies_aws/input_outputs/lambda_sns.py +66 -39
- clearskies_aws/input_outputs/lambda_sqs_standard.py +70 -40
- clearskies_aws/mocks/actions/ses.py +25 -19
- clearskies_aws/mocks/actions/sns.py +18 -12
- clearskies_aws/mocks/actions/sqs.py +18 -12
- clearskies_aws/mocks/actions/step_function.py +19 -13
- clearskies_aws/models/__init__.py +0 -0
- clearskies_aws/models/web_socket_connection_model.py +182 -0
- clearskies_aws/secrets/__init__.py +13 -7
- clearskies_aws/secrets/additional_configs/__init__.py +10 -2
- clearskies_aws/secrets/additional_configs/iam_db_auth.py +26 -16
- clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +43 -39
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +30 -31
- clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +70 -49
- clearskies_aws/secrets/akeyless_with_ssm_cache.py +32 -18
- clearskies_aws/secrets/parameter_store.py +34 -32
- clearskies_aws/secrets/secrets.py +16 -0
- clearskies_aws/secrets/secrets_manager.py +78 -57
- clear_skies_aws-1.10.2.dist-info/LICENSE +0 -7
- clear_skies_aws-1.10.2.dist-info/RECORD +0 -71
- clearskies_aws/actions/assume_role_test.py +0 -72
- clearskies_aws/actions/ses_test.py +0 -89
- clearskies_aws/actions/sns_test.py +0 -77
- clearskies_aws/actions/sqs_test.py +0 -127
- clearskies_aws/actions/step_function_test.py +0 -103
- clearskies_aws/backends/dynamo_db_backend_test.py +0 -300
- clearskies_aws/backends/dynamo_db_condition_parser_test.py +0 -266
- clearskies_aws/backends/dynamo_db_parti_ql_backend_test.py +0 -544
- clearskies_aws/backends/sqs_backend_test.py +0 -31
- clearskies_aws/contexts/cli.py +0 -19
- clearskies_aws/contexts/cli_websocket_mock.py +0 -33
- clearskies_aws/contexts/lambda_elb.py +0 -30
- clearskies_aws/contexts/lambda_http_gateway.py +0 -30
- clearskies_aws/contexts/lambda_sqs_standard_partial_batch_test.py +0 -66
- clearskies_aws/contexts/wsgi.py +0 -19
- clearskies_aws/di/standard_dependencies.py +0 -60
- clearskies_aws/handlers/simple_body_routing.py +0 -39
- clearskies_aws/input_outputs/lambda_api_gateway_test.py +0 -87
- clearskies_aws/input_outputs/lambda_elb.py +0 -21
- clearskies_aws/input_outputs/lambda_http_gateway.py +0 -12
- clearskies_aws/secrets/parameter_store_test.py +0 -18
- clearskies_aws/secrets/secrets_manager_test.py +0 -18
- clearskies_aws/web_socket_connection_model.py +0 -43
- clearskies_aws/{handlers → endpoints}/__init__.py +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
|
|
5
|
+
from clearskies.di.injectable import Injectable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Boto3(Injectable):
|
|
9
|
+
def __init__(self, cache: bool = True):
|
|
10
|
+
self.cache = cache
|
|
11
|
+
|
|
12
|
+
def __get__(self, instance, parent) -> ModuleType:
|
|
13
|
+
if instance is None:
|
|
14
|
+
return self # type: ignore
|
|
15
|
+
return self._di.build_from_name("boto3", cache=self.cache)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from types import ModuleType
|
|
2
|
+
|
|
3
|
+
from clearskies.di.injectable import Injectable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Boto3Session(Injectable):
|
|
7
|
+
def __init__(self, cache: bool = True):
|
|
8
|
+
self.cache = cache
|
|
9
|
+
|
|
10
|
+
def __get__(self, instance, parent) -> ModuleType:
|
|
11
|
+
if instance is None:
|
|
12
|
+
return self # type: ignore
|
|
13
|
+
return self._di.build_from_name("boto3_session", cache=self.cache)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from clearskies.di.injectable import Injectable
|
|
2
|
+
|
|
3
|
+
from clearskies_aws.secrets.parameter_store import (
|
|
4
|
+
ParameterStore as ParameterStoreDependency,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParameterStore(Injectable):
|
|
9
|
+
def __init__(self, cache: bool = True):
|
|
10
|
+
self.cache = cache
|
|
11
|
+
|
|
12
|
+
def __get__(self, instance, parent) -> ParameterStoreDependency:
|
|
13
|
+
if instance is None:
|
|
14
|
+
return self # type: ignore
|
|
15
|
+
return self._di.build_from_name("parameter_store", cache=self.cache)
|
|
@@ -1,56 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
2
6
|
|
|
3
|
-
import clearskies
|
|
4
|
-
from clearskies.handlers.exceptions import ClientError
|
|
5
|
-
from clearskies.handlers.base import Base
|
|
6
7
|
import botocore
|
|
8
|
+
import clearskies
|
|
9
|
+
from clearskies import Endpoint
|
|
10
|
+
from clearskies.configs import Callable as CallableConfig
|
|
11
|
+
from clearskies.configs import Schema as SchemaConfig
|
|
12
|
+
from clearskies.configs import StringList
|
|
13
|
+
from clearskies.decorators import parameters_to_properties
|
|
14
|
+
from clearskies.di.inject import Di
|
|
15
|
+
from clearskies.exceptions import ClientError
|
|
16
|
+
from clearskies.input_outputs import InputOutput
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
_steps = ["createSecret", "setSecret", "testSecret", "finishSecret"]
|
|
18
|
+
from clearskies_aws.di import inject
|
|
10
19
|
|
|
11
|
-
current = "AWSCURRENT"
|
|
12
|
-
pending = "AWSPENDING"
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
"createSecret": None,
|
|
16
|
-
"setSecret": None,
|
|
17
|
-
"testSecret": None,
|
|
18
|
-
"finishSecret": None,
|
|
19
|
-
"schema": [],
|
|
20
|
-
}
|
|
21
|
+
class SecretsManagerRotation(Endpoint):
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
self.boto3 = boto3
|
|
23
|
+
di = Di()
|
|
24
|
+
boto3 = inject.Boto3()
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
current = "AWSCURRENT"
|
|
27
|
+
pending = "AWSPENDING"
|
|
28
|
+
|
|
29
|
+
steps = StringList(default=["createSecret", "setSecret", "testSecret", "finishSecret"])
|
|
30
|
+
create_secret = CallableConfig(default=None)
|
|
31
|
+
set_secret = CallableConfig(default=None)
|
|
32
|
+
test_secret = CallableConfig(default=None)
|
|
33
|
+
finish_secret = CallableConfig(default=None)
|
|
34
|
+
schema = SchemaConfig(default=None)
|
|
35
|
+
|
|
36
|
+
@parameters_to_properties
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
steps: list[str] | None,
|
|
40
|
+
create_secret: Callable | None,
|
|
41
|
+
set_secret: Callable | None,
|
|
42
|
+
test_secret: Callable | None,
|
|
43
|
+
finish_secret: Callable | None,
|
|
44
|
+
schema: list[dict] | None,
|
|
45
|
+
):
|
|
46
|
+
super().__init__()
|
|
47
|
+
|
|
48
|
+
def configure(self):
|
|
49
|
+
self.finalize_and_validate_configuration()
|
|
28
50
|
class_name = self.__class__.__name__
|
|
29
|
-
if not
|
|
51
|
+
if not self.create_secret:
|
|
30
52
|
raise KeyError(f"Missing required configuration 'createSecret' for handler {class_name}")
|
|
31
53
|
|
|
32
|
-
for config_name in self.
|
|
33
|
-
config =
|
|
54
|
+
for config_name in self.steps:
|
|
55
|
+
config = getattr(self, config_name)
|
|
34
56
|
if config is None:
|
|
35
57
|
continue
|
|
36
|
-
if not callable(config):
|
|
37
|
-
raise ValueError(f"Misconfiguration for handler {class_name}: configuration '{config_name}' is not callable")
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
59
|
+
def handle(self, input_output: InputOutput):
|
|
60
|
+
request_data = json.loads(input_output.get_body())
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
if configuration.get('schema'):
|
|
44
|
-
configuration['schema'] = self._schema_to_columns(configuration['schema'])
|
|
45
|
-
return super()._finalize_configuration(configuration)
|
|
62
|
+
self.find_input_errors(request_data, input_output, self.schema)
|
|
46
63
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
request_token = request_data.get('ClientRequestToken')
|
|
52
|
-
step = request_data.get('Step')
|
|
53
|
-
secretsmanager = self.boto3.client('secretsmanager')
|
|
64
|
+
arn = request_data.get("SecretId")
|
|
65
|
+
request_token = request_data.get("ClientRequestToken")
|
|
66
|
+
step = request_data.get("Step")
|
|
67
|
+
secretsmanager = self.boto3.client("secretsmanager")
|
|
54
68
|
metadata = secretsmanager.describe_secret(SecretId=arn)
|
|
55
69
|
|
|
56
70
|
self._validate_secret_and_request(step, arn, metadata, request_token)
|
|
@@ -59,7 +73,7 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
|
|
|
59
73
|
pending_secret_data = {}
|
|
60
74
|
|
|
61
75
|
current_secret = secretsmanager.get_secret_value(SecretId=arn, VersionStage=self.current)
|
|
62
|
-
current_secret_data = json.loads(current_secret[
|
|
76
|
+
current_secret_data = json.loads(current_secret["SecretString"])
|
|
63
77
|
|
|
64
78
|
# validate the current secret
|
|
65
79
|
secret_errors = {
|
|
@@ -72,10 +86,12 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
|
|
|
72
86
|
# check for a pending secret. Note that this is not always available. In the event that we are retrying a failed
|
|
73
87
|
# rotation it will already be set, in which case we need to skip the createSecret step.
|
|
74
88
|
try:
|
|
75
|
-
pending_secret = secretsmanager.get_secret_value(
|
|
76
|
-
|
|
89
|
+
pending_secret = secretsmanager.get_secret_value(
|
|
90
|
+
SecretId=arn, VersionId=request_token, VersionStage=self.pending
|
|
91
|
+
)
|
|
92
|
+
pending_secret_data = json.loads(pending_secret["SecretString"])
|
|
77
93
|
except botocore.exceptions.ClientError as error:
|
|
78
|
-
if error.response[
|
|
94
|
+
if error.response["Error"]["Code"] == "ResourceNotFoundException":
|
|
79
95
|
pending_secret_data = None
|
|
80
96
|
else:
|
|
81
97
|
raise error
|
|
@@ -94,12 +110,12 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
|
|
|
94
110
|
arn=arn,
|
|
95
111
|
)
|
|
96
112
|
|
|
97
|
-
def _validate_secret_and_request(self, step, arn, metadata, request_token):
|
|
98
|
-
"""
|
|
99
|
-
if step not in self.
|
|
113
|
+
def _validate_secret_and_request(self, step: str, arn: str, metadata: dict[str, Any], request_token: str):
|
|
114
|
+
"""Perform basic checks suggested by AWS of both the request and the secret to ensure validity."""
|
|
115
|
+
if step not in self.steps:
|
|
100
116
|
raise ClientError(f"Invalid step: {step}")
|
|
101
117
|
|
|
102
|
-
if not metadata.get(
|
|
118
|
+
if not metadata.get("RotationEnabled"):
|
|
103
119
|
raise ValueError("Secret %s is not enabled for rotation" % arn)
|
|
104
120
|
|
|
105
121
|
versions = metadata["VersionIdsToStages"]
|
|
@@ -107,23 +123,31 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
|
|
|
107
123
|
if request_token not in versions:
|
|
108
124
|
raise ValueError(f"{prefix} we don't have a stage for rotation")
|
|
109
125
|
if self.current in versions[request_token]:
|
|
110
|
-
raise ValueError(
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{prefix} it's already the current version, which shouldn't happen. I'm quitting with prejudice."
|
|
128
|
+
)
|
|
111
129
|
elif self.pending not in versions[request_token]:
|
|
112
130
|
raise ValueError(f"{prefix} it hasn't been set to pending yet, which makes no sense!")
|
|
113
131
|
|
|
114
132
|
def createSecret(self, **kwargs):
|
|
115
|
-
new_secret_data = self.
|
|
133
|
+
new_secret_data = self.di.call_function(self.create_secret, **kwargs)
|
|
116
134
|
if new_secret_data is None:
|
|
117
|
-
raise ValueError(
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"I called the configured createSecret function but it didn't return anything. It has to return the new secret data."
|
|
137
|
+
)
|
|
118
138
|
if not isinstance(new_secret_data, dict):
|
|
119
|
-
raise ValueError(
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"I called the configured createSecret function but it didn't return a dictionary. The createSecret function must return a dictionary."
|
|
141
|
+
)
|
|
120
142
|
|
|
121
143
|
secret_errors = {
|
|
122
144
|
**self._extra_column_errors(new_secret_data),
|
|
123
|
-
**self.
|
|
145
|
+
**self.find_input_errors(new_secret_data),
|
|
124
146
|
}
|
|
125
147
|
if secret_errors:
|
|
126
|
-
raise ValueError(
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"The secret data returned by the call to createSecret did not match the configured schema: {secret_errors}"
|
|
150
|
+
)
|
|
127
151
|
|
|
128
152
|
# if we get this far we can store the new data
|
|
129
153
|
secretsmanager = kwargs["secretsmanager"]
|
|
@@ -167,8 +191,5 @@ class SecretsManagerRotation(Base, clearskies.handlers.SchemaHelper):
|
|
|
167
191
|
|
|
168
192
|
# finish the rotation by taking the new version and making it current.
|
|
169
193
|
secretsmanager.update_secret_version_stage(
|
|
170
|
-
SecretId=arn,
|
|
171
|
-
VersionStage=self.current,
|
|
172
|
-
MoveToVersionId=request_token,
|
|
173
|
-
RemoveFromVersionId=current_version
|
|
194
|
+
SecretId=arn, VersionStage=self.current, MoveToVersionId=request_token, RemoveFromVersionId=current_version
|
|
174
195
|
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from clearskies import Endpoint
|
|
7
|
+
from clearskies.configs import AnyDict, String
|
|
8
|
+
from clearskies.di.inject import Di
|
|
9
|
+
from clearskies.input_outputs import InputOutput
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SimpleBodyRouting(Endpoint):
|
|
13
|
+
di = Di()
|
|
14
|
+
|
|
15
|
+
route_key = String(default="route")
|
|
16
|
+
routes = AnyDict(default={})
|
|
17
|
+
|
|
18
|
+
def __init__(self, routes: dict[str, Any], route_key: str = "route"):
|
|
19
|
+
self.routes = routes
|
|
20
|
+
self.route_key = route_key
|
|
21
|
+
|
|
22
|
+
def handle(self, input_output: InputOutput) -> Any:
|
|
23
|
+
body = json.loads(input_output.get_body()) if input_output.has_body() else {}
|
|
24
|
+
|
|
25
|
+
if not body or not body.get(self.route_key):
|
|
26
|
+
return self.error(input_output, "Not Found", 404)
|
|
27
|
+
|
|
28
|
+
route = body[self.route_key]
|
|
29
|
+
if route not in self.routes:
|
|
30
|
+
return self.error(input_output, "Not Found", 404)
|
|
31
|
+
return input_output.respond(
|
|
32
|
+
self.di.call_function(
|
|
33
|
+
self.routes[route],
|
|
34
|
+
request_data=body,
|
|
35
|
+
**input_output.context_specifics(),
|
|
36
|
+
),
|
|
37
|
+
200,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def documentation(self):
|
|
41
|
+
return []
|
|
@@ -1,8 +1,21 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from .
|
|
4
|
-
from .
|
|
5
|
-
from .
|
|
6
|
-
from .
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from clearskies_aws.input_outputs.cli_web_socket_mock import CliWebSocketMock
|
|
4
|
+
from clearskies_aws.input_outputs.lambda_alb import LambdaAlb
|
|
5
|
+
from clearskies_aws.input_outputs.lambda_api_gateway import LambdaApiGateway
|
|
6
|
+
from clearskies_aws.input_outputs.lambda_api_gateway_web_socket import (
|
|
7
|
+
LambdaApiGatewayWebSocket,
|
|
8
|
+
)
|
|
9
|
+
from clearskies_aws.input_outputs.lambda_invocation import LambdaInvocation
|
|
10
|
+
from clearskies_aws.input_outputs.lambda_sns import LambdaSns
|
|
11
|
+
from clearskies_aws.input_outputs.lambda_sqs_standard import LambdaSqsStandard
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CliWebSocketMock",
|
|
15
|
+
"LambdaApiGateway",
|
|
16
|
+
"LambdaApiGatewayWebSocket",
|
|
17
|
+
"LambdaAlb",
|
|
18
|
+
"LambdaInvocation",
|
|
19
|
+
"LambdaSns",
|
|
20
|
+
"LambdaSqsStandard",
|
|
21
|
+
]
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
1
5
|
import clearskies
|
|
2
|
-
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CliWebSocketMock(clearskies.input_outputs.Cli):
|
|
3
9
|
def context_specifics(self):
|
|
4
|
-
connection_id = self.
|
|
10
|
+
connection_id = json.loads(self.get_body()).get("connection_id")
|
|
5
11
|
if not connection_id:
|
|
6
|
-
raise KeyError("When using the
|
|
12
|
+
raise KeyError("When using the CliWebsocketMock you must provide connection_id in the request body")
|
|
7
13
|
|
|
8
14
|
return {
|
|
9
15
|
"event": {},
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies.configs import String
|
|
6
|
+
from clearskies.input_outputs import Headers
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LambdaAlb(lambda_input_output.LambdaInputOutput):
|
|
12
|
+
"""Application Load Balancer specific Lambda input/output handler."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, event: dict[str, Any], context: dict[str, Any]):
|
|
15
|
+
# Call parent constructor
|
|
16
|
+
super().__init__(event, context)
|
|
17
|
+
|
|
18
|
+
# ALB specific initialization
|
|
19
|
+
self.request_method = event.get("httpMethod", "GET").upper()
|
|
20
|
+
self.path = event.get("path", "/")
|
|
21
|
+
|
|
22
|
+
# Extract query parameters (ALB only has single value query parameters)
|
|
23
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
24
|
+
|
|
25
|
+
# Extract headers (ALB only has single value headers)
|
|
26
|
+
headers_dict = {}
|
|
27
|
+
for key, value in event.get("headers", {}).items():
|
|
28
|
+
headers_dict[key.lower()] = str(value)
|
|
29
|
+
|
|
30
|
+
self.request_headers = Headers(headers_dict)
|
|
31
|
+
|
|
32
|
+
def get_client_ip(self) -> str:
|
|
33
|
+
"""Get the client IP address from ALB headers."""
|
|
34
|
+
# ALB always provides client IP via X-Forwarded-For header
|
|
35
|
+
forwarded_for = self.request_headers.get("x-forwarded-for")
|
|
36
|
+
if not forwarded_for:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
"The x-forwarded-for header wasn't present in the request, and it should always exist for anything behind an ALB. You are probably using the wrong context."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# X-Forwarded-For can contain multiple IPs, take the first one
|
|
42
|
+
return forwarded_for.split(",")[0].strip()
|
|
43
|
+
|
|
44
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
45
|
+
"""Provide ALB specific context data."""
|
|
46
|
+
request_context = self.event.get("requestContext", {})
|
|
47
|
+
elb = request_context.get("elb", {})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
**super().context_specifics(),
|
|
51
|
+
"path": self.path,
|
|
52
|
+
"target_group_arn": elb.get("targetGroupArn"),
|
|
53
|
+
}
|
|
@@ -1,105 +1,123 @@
|
|
|
1
|
-
from
|
|
2
|
-
import json
|
|
3
|
-
import base64
|
|
4
|
-
import urllib
|
|
5
|
-
class LambdaAPIGateway(InputOutput):
|
|
6
|
-
_event = None
|
|
7
|
-
_context = None
|
|
8
|
-
_request_headers = None
|
|
9
|
-
_request_method = None
|
|
10
|
-
_path = None
|
|
11
|
-
_resource = None
|
|
12
|
-
_query_parameters = None
|
|
13
|
-
_path_parameters = None
|
|
14
|
-
_cached_body = None
|
|
15
|
-
_body_was_cached = False
|
|
16
|
-
|
|
17
|
-
def __init__(self, event, context):
|
|
18
|
-
self._event = event
|
|
19
|
-
self._context = context
|
|
20
|
-
self._request_method = event.get('httpMethod', '').upper()
|
|
21
|
-
self._path = event.get('path')
|
|
22
|
-
self._resource = event.get('resource')
|
|
23
|
-
self._query_parameters = event.get('queryStringParameters', {})
|
|
24
|
-
self._path_parameters = event.get('pathParameters', {})
|
|
25
|
-
self._request_headers = {}
|
|
26
|
-
for (key, value) in event.get('headers', {}).items():
|
|
27
|
-
self._request_headers[key.lower()] = value
|
|
28
|
-
|
|
29
|
-
def respond(self, body, status_code=200):
|
|
30
|
-
if not self.has_header('content-type'):
|
|
31
|
-
self.set_header('content-type', 'application/json; charset=UTF-8')
|
|
32
|
-
|
|
33
|
-
is_base64 = False
|
|
34
|
-
if type(body) == bytes:
|
|
35
|
-
is_base64 = True
|
|
36
|
-
final_body = base64.encodebytes(body).decode('utf8')
|
|
37
|
-
elif type(body) == str:
|
|
38
|
-
final_body = body
|
|
39
|
-
else:
|
|
40
|
-
final_body = json.dumps(body)
|
|
1
|
+
from __future__ import annotations
|
|
41
2
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from clearskies.configs import String
|
|
6
|
+
from clearskies.input_outputs import Headers
|
|
7
|
+
|
|
8
|
+
from clearskies_aws.input_outputs import lambda_input_output
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LambdaApiGateway(lambda_input_output.LambdaInputOutput):
|
|
12
|
+
"""API Gateway v1 and v2 Lambda input/output handler."""
|
|
13
|
+
|
|
14
|
+
resource = String(default="")
|
|
15
|
+
|
|
16
|
+
def __init__(self, event: dict, context: dict[str, Any]):
|
|
17
|
+
# Call parent constructor
|
|
18
|
+
super().__init__(event, context)
|
|
19
|
+
|
|
20
|
+
# Determine API Gateway version and parse accordingly
|
|
21
|
+
version = self._detect_version(event)
|
|
22
|
+
if version == "1.0":
|
|
23
|
+
self._parse_event_v1(event)
|
|
24
|
+
elif version == "2.0":
|
|
25
|
+
self._parse_event_v2(event)
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError(f"Unsupported API Gateway event version: {version}")
|
|
28
|
+
|
|
29
|
+
def _detect_version(self, event: dict) -> str:
|
|
30
|
+
"""Detect API Gateway version from event structure."""
|
|
31
|
+
if "version" in event:
|
|
32
|
+
return event["version"]
|
|
33
|
+
elif "httpMethod" in event:
|
|
34
|
+
return "1.0" # v1 has httpMethod at root level
|
|
35
|
+
elif "requestContext" in event and "http" in event["requestContext"]:
|
|
36
|
+
return "2.0" # v2 has http in requestContext
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError("Unable to determine API Gateway version from event structure")
|
|
39
|
+
|
|
40
|
+
def _parse_event_v1(self, event: dict) -> None:
|
|
41
|
+
"""Parse API Gateway v1 event structure."""
|
|
42
|
+
self.request_method = event.get("httpMethod", "GET").upper()
|
|
43
|
+
self.path = event.get("path", "/")
|
|
44
|
+
self.resource = event.get("resource", "")
|
|
45
|
+
|
|
46
|
+
# Extract query parameters (v1 has both single and multi-value)
|
|
47
|
+
self.query_parameters = {
|
|
48
|
+
**(event.get("queryStringParameters") or {}),
|
|
49
|
+
**(event.get("multiValueQueryStringParameters") or {}),
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
# Extract headers (v1 has both single and multi-value)
|
|
53
|
+
headers_dict = {}
|
|
54
|
+
for key, value in {
|
|
55
|
+
**event.get("headers", {}),
|
|
56
|
+
**event.get("multiValueHeaders", {}),
|
|
57
|
+
}.items():
|
|
58
|
+
headers_dict[key.lower()] = str(value)
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
if not self._body_was_cached:
|
|
54
|
-
self._cached_body = self._event.get('body')
|
|
55
|
-
if self._cached_body is not None and self._event.get('isBase64Encoded'):
|
|
56
|
-
self._cached_body = base64.decodebytes(self._cached_body.encode('utf-8')).decode('utf-8')
|
|
57
|
-
return self._cached_body
|
|
60
|
+
self.request_headers = Headers(headers_dict)
|
|
58
61
|
|
|
59
|
-
def
|
|
60
|
-
|
|
62
|
+
def _parse_event_v2(self, event: dict) -> None:
|
|
63
|
+
"""Parse API Gateway v2 event structure."""
|
|
64
|
+
request_context = event.get("requestContext", {})
|
|
65
|
+
http_context = request_context.get("http", {})
|
|
61
66
|
|
|
62
|
-
|
|
63
|
-
|
|
67
|
+
self.request_method = http_context.get("method", "GET").upper()
|
|
68
|
+
self.path = http_context.get("path", "/")
|
|
69
|
+
# v2 doesn't have resource field
|
|
70
|
+
self.resource = ""
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
# Extract query parameters (v2 only has single values)
|
|
73
|
+
self.query_parameters = event.get("queryStringParameters") or {}
|
|
67
74
|
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
# Extract headers (v2 only has single value headers)
|
|
76
|
+
headers_dict = {}
|
|
77
|
+
for key, value in event.get("headers", {}).items():
|
|
78
|
+
headers_dict[key.lower()] = str(value)
|
|
70
79
|
|
|
71
|
-
|
|
72
|
-
return self.get_request_header('content-type', True)
|
|
80
|
+
self.request_headers = Headers(headers_dict)
|
|
73
81
|
|
|
74
|
-
def
|
|
75
|
-
|
|
82
|
+
def get_client_ip(self) -> str:
|
|
83
|
+
"""Get the client IP address from API Gateway event."""
|
|
84
|
+
request_context = self.event.get("requestContext", {})
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
# Try v1 format first (identity.sourceIp)
|
|
87
|
+
identity = request_context.get("identity", {})
|
|
88
|
+
if "sourceIp" in identity:
|
|
89
|
+
return identity["sourceIp"]
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return ''
|
|
85
|
-
return self._request_headers[header_name.lower()]
|
|
91
|
+
# Try v2 format (http.sourceIp)
|
|
92
|
+
http_context = request_context.get("http", {})
|
|
93
|
+
if "sourceIp" in http_context:
|
|
94
|
+
return http_context["sourceIp"]
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
return self._query_parameters[key] if key in self._query_parameters else []
|
|
96
|
+
raise ValueError("Unable to find the client ip inside the API Gateway")
|
|
89
97
|
|
|
90
|
-
def
|
|
91
|
-
|
|
98
|
+
def get_protocol(self) -> str:
|
|
99
|
+
"""Get the protocol from API Gateway request context."""
|
|
100
|
+
request_context = self.event.get("requestContext", {})
|
|
92
101
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
|
|
102
|
+
# Try v2 format first (has explicit protocol)
|
|
103
|
+
http_context = request_context.get("http", {})
|
|
104
|
+
if "protocol" in http_context:
|
|
105
|
+
protocol = http_context["protocol"]
|
|
106
|
+
return "https" if protocol.upper().startswith("HTTPS") else "http"
|
|
107
|
+
|
|
108
|
+
# v1 defaults to HTTPS
|
|
109
|
+
return "https"
|
|
98
110
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return sourceIp
|
|
111
|
+
def context_specifics(self) -> dict[str, Any]:
|
|
112
|
+
"""Provide API Gateway specific context data."""
|
|
113
|
+
request_context = self.event.get("requestContext", {})
|
|
114
|
+
http_context = request_context.get("http", {})
|
|
104
115
|
|
|
105
|
-
return
|
|
116
|
+
return {
|
|
117
|
+
**super().context_specifics(),
|
|
118
|
+
"resource": self.resource,
|
|
119
|
+
"stage": request_context.get("stage"),
|
|
120
|
+
"request_id": request_context.get("requestId"),
|
|
121
|
+
"api_id": request_context.get("apiId"),
|
|
122
|
+
"api_version": self._detect_version(self.event),
|
|
123
|
+
}
|