clear-skies-aws 2.0.1__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.
Files changed (64) hide show
  1. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/METADATA +1 -1
  2. clear_skies_aws-2.0.2.dist-info/RECORD +63 -0
  3. clearskies_aws/__init__.py +15 -0
  4. clearskies_aws/actions/__init__.py +15 -0
  5. clearskies_aws/actions/action_aws.py +135 -0
  6. clearskies_aws/actions/assume_role.py +115 -0
  7. clearskies_aws/actions/ses.py +203 -0
  8. clearskies_aws/actions/sns.py +61 -0
  9. clearskies_aws/actions/sqs.py +81 -0
  10. clearskies_aws/actions/step_function.py +73 -0
  11. clearskies_aws/backends/__init__.py +19 -0
  12. clearskies_aws/backends/backend.py +106 -0
  13. clearskies_aws/backends/dynamo_db_backend.py +609 -0
  14. clearskies_aws/backends/dynamo_db_condition_parser.py +325 -0
  15. clearskies_aws/backends/dynamo_db_parti_ql_backend.py +965 -0
  16. clearskies_aws/backends/sqs_backend.py +61 -0
  17. clearskies_aws/configs/__init__.py +0 -0
  18. clearskies_aws/contexts/__init__.py +23 -0
  19. clearskies_aws/contexts/cli_web_socket_mock.py +19 -0
  20. clearskies_aws/contexts/lambda_alb.py +76 -0
  21. clearskies_aws/contexts/lambda_api_gateway.py +77 -0
  22. clearskies_aws/contexts/lambda_api_gateway_web_socket.py +57 -0
  23. clearskies_aws/contexts/lambda_invocation.py +19 -0
  24. clearskies_aws/contexts/lambda_sns.py +18 -0
  25. clearskies_aws/contexts/lambda_sqs_standard_partial_batch.py +29 -0
  26. clearskies_aws/di/__init__.py +6 -0
  27. clearskies_aws/di/aws_additional_config_auto_import.py +37 -0
  28. clearskies_aws/di/inject/__init__.py +6 -0
  29. clearskies_aws/di/inject/boto3.py +15 -0
  30. clearskies_aws/di/inject/boto3_session.py +13 -0
  31. clearskies_aws/di/inject/parameter_store.py +15 -0
  32. clearskies_aws/endpoints/__init__.py +2 -0
  33. clearskies_aws/endpoints/secrets_manager_rotation.py +195 -0
  34. clearskies_aws/endpoints/simple_body_routing.py +41 -0
  35. clearskies_aws/input_outputs/__init__.py +21 -0
  36. clearskies_aws/input_outputs/cli_web_socket_mock.py +18 -0
  37. clearskies_aws/input_outputs/lambda_alb.py +53 -0
  38. clearskies_aws/input_outputs/lambda_api_gateway.py +123 -0
  39. clearskies_aws/input_outputs/lambda_api_gateway_web_socket.py +71 -0
  40. clearskies_aws/input_outputs/lambda_input_output.py +87 -0
  41. clearskies_aws/input_outputs/lambda_invocation.py +85 -0
  42. clearskies_aws/input_outputs/lambda_sns.py +79 -0
  43. clearskies_aws/input_outputs/lambda_sqs_standard.py +84 -0
  44. clearskies_aws/mocks/__init__.py +1 -0
  45. clearskies_aws/mocks/actions/__init__.py +6 -0
  46. clearskies_aws/mocks/actions/ses.py +34 -0
  47. clearskies_aws/mocks/actions/sns.py +29 -0
  48. clearskies_aws/mocks/actions/sqs.py +29 -0
  49. clearskies_aws/mocks/actions/step_function.py +32 -0
  50. clearskies_aws/models/__init__.py +0 -0
  51. clearskies_aws/models/web_socket_connection_model.py +182 -0
  52. clearskies_aws/secrets/__init__.py +13 -0
  53. clearskies_aws/secrets/additional_configs/__init__.py +62 -0
  54. clearskies_aws/secrets/additional_configs/iam_db_auth.py +39 -0
  55. clearskies_aws/secrets/additional_configs/iam_db_auth_with_ssm.py +96 -0
  56. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +80 -0
  57. clearskies_aws/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssm_bastion.py +162 -0
  58. clearskies_aws/secrets/akeyless_with_ssm_cache.py +60 -0
  59. clearskies_aws/secrets/parameter_store.py +52 -0
  60. clearskies_aws/secrets/secrets.py +16 -0
  61. clearskies_aws/secrets/secrets_manager.py +96 -0
  62. clear_skies_aws-2.0.1.dist-info/RECORD +0 -4
  63. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/WHEEL +0 -0
  64. {clear_skies_aws-2.0.1.dist-info → clear_skies_aws-2.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import clearskies
6
+
7
+
8
+ class IAMDBAuth(clearskies.di.AdditionalConfig):
9
+ def provide_boto3(self):
10
+ import boto3
11
+
12
+ return boto3
13
+
14
+ def provide_connection_details(self, environment, boto3):
15
+ """
16
+ Make configuration values and environment variables customizable.
17
+
18
+ Allows both the values and the environment variable names to be set for flexible configuration.
19
+
20
+ Returns:
21
+ dict: Connection details for IAM DB authentication.
22
+ """
23
+ endpoint = environment.get("db_endpoint")
24
+ username = environment.get("db_username")
25
+ database = environment.get("db_database")
26
+ region = environment.get("AWS_REGION")
27
+ ssl_ca_bundle_name = environment.get("ssl_ca_bundle_filename")
28
+ os.environ["LIBMYSQL_ENABLE_CLEARTEXT_PLUGIN"] = "1"
29
+
30
+ rds_api = boto3.Session().client("rds")
31
+ rds_token = rds_api.generate_db_auth_token(DBHostname=endpoint, Port="3306", DBUsername=username, Region=region)
32
+
33
+ return {
34
+ "username": username,
35
+ "password": rds_token,
36
+ "host": endpoint,
37
+ "database": database,
38
+ "ssl_ca": ssl_ca_bundle_name,
39
+ }
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ import clearskies
6
+
7
+
8
+ class IAMDBAuthWithSSM(clearskies.di.AdditionalConfig):
9
+ def provide_subprocess(self):
10
+ import subprocess
11
+
12
+ return subprocess
13
+
14
+ def provide_socket(self):
15
+ import socket
16
+
17
+ return socket
18
+
19
+ def provide_connection_details(self, environment, subprocess, socket, boto3):
20
+ local_port = self.open_tunnel(environment, subprocess, socket, boto3)
21
+
22
+ return {
23
+ "host": "127.0.0.1",
24
+ "database": environment.get("db_database"),
25
+ "username": environment.get("db_username"),
26
+ "password": self.get_password(environment, boto3),
27
+ "ssl_ca": "rds-cert-bundle.pem",
28
+ "port": local_port,
29
+ }
30
+
31
+ def get_password(self, environment, boto3):
32
+ endpoint = environment.get("db_endpoint")
33
+ username = environment.get("db_username")
34
+ region = environment.get("db_region")
35
+
36
+ rds_api = boto3.Session().client("rds", region_name=region)
37
+ return rds_api.generate_db_auth_token(DBHostname=endpoint, Port="3306", DBUsername=username, Region=region)
38
+
39
+ def open_tunnel(self, environment, subprocess, socket, boto3):
40
+ endpoint = environment.get("db_endpoint")
41
+ region = environment.get("db_region")
42
+ instance_name = environment.get("instance_name")
43
+ local_proxy_port = int(environment.get("local_proxy_port", "9000"))
44
+
45
+ ec2_api = boto3.client("ec2", region_name=region)
46
+ running_instances = ec2_api.describe_instances(
47
+ Filters=[
48
+ {"Name": "tag:Name", "Values": [instance_name]},
49
+ {"Name": "instance-state-name", "Values": ["running"]},
50
+ ],
51
+ )
52
+ instance_ids = []
53
+ for reservation in running_instances["Reservations"]:
54
+ for instance in reservation["Instances"]:
55
+ instance_ids.append(instance["InstanceId"])
56
+
57
+ if len(instance_ids) == 0:
58
+ raise ValueError("Failed to launch SSM tunnel! Cannot find bastion!")
59
+
60
+ instance_id = instance_ids.pop()
61
+ self._connect_to_bastion(local_proxy_port, instance_id, endpoint, subprocess, socket)
62
+ return local_proxy_port
63
+
64
+ def _connect_to_bastion(self, local_proxy_port, instance_id, endpoint, subprocess, socket):
65
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
66
+ result = sock.connect_ex(("127.0.0.1", local_proxy_port))
67
+ if result == 0:
68
+ sock.close()
69
+ return
70
+
71
+ tunnel_command = [
72
+ "aws",
73
+ "--region",
74
+ "us-east-1",
75
+ "ssm",
76
+ "start-session",
77
+ "--target",
78
+ "{}".format(instance_id),
79
+ "--document-name",
80
+ "AWS-StartPortForwardingSessionToRemoteHost",
81
+ '--parameters={{"host":["{}"], "portNumber":["3306"],"localPortNumber":["{}"]}}'.format(
82
+ endpoint, local_proxy_port
83
+ ),
84
+ ]
85
+
86
+ subprocess.Popen(tunnel_command)
87
+ connected = False
88
+ attempts = 0
89
+ while not connected and attempts < 6:
90
+ attempts += 1
91
+ time.sleep(0.5)
92
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
93
+ result = sock.connect_ex(("127.0.0.1", local_proxy_port))
94
+ if result == 0:
95
+ return
96
+ raise ValueError("Failed to launch SSM tunnel with command: " + " ".join(tunnel_command))
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from clearskies.secrets.additional_configs import MySQLConnectionDynamicProducerViaSSHCertBastion as Base
10
+
11
+
12
+ class MySQLConnectionDynamicProducerViaSSHCertBastion(Base):
13
+ _config = None
14
+ _boto3 = None
15
+
16
+ def __init__(
17
+ self,
18
+ producer_name=None,
19
+ bastion_region=None,
20
+ bastion_name=None,
21
+ bastion_host=None,
22
+ bastion_username=None,
23
+ public_key_file_path=None,
24
+ local_proxy_port=None,
25
+ cert_issuer_name=None,
26
+ database_host=None,
27
+ database_name=None,
28
+ ):
29
+ # not using kwargs because I want the argument list to be explicit
30
+ self.config = {
31
+ "producer_name": producer_name,
32
+ "bastion_host": bastion_host,
33
+ "bastion_region": bastion_region,
34
+ "bastion_name": bastion_name,
35
+ "bastion_username": bastion_username,
36
+ "public_key_file_path": public_key_file_path,
37
+ "local_proxy_port": local_proxy_port,
38
+ "cert_issuer_name": cert_issuer_name,
39
+ "database_host": database_host,
40
+ "database_name": database_name,
41
+ }
42
+
43
+ def provide_connection_details(self, environment, secrets, boto3):
44
+ self._boto3 = boto3
45
+ return super().provide_connection_details(environment, secrets)
46
+
47
+ def _get_bastion_host(self, environment):
48
+ bastion_host = self._fetch_config(environment, "bastion_host", "akeyless_mysql_bastion_host", default="")
49
+ bastion_name = self._fetch_config(environment, "bastion_name", "akeyless_mysql_bastion_name", default="")
50
+ if bastion_host:
51
+ return bastion_host
52
+ if bastion_name:
53
+ bastion_region = self._fetch_config(environment, "bastion_region", "akeyless_mysql_bastion_region")
54
+ return self._public_ip_from_name(bastion_name, bastion_region)
55
+ raise ValueError(
56
+ f"I was asked to connect to a database via an AKeyless dynamic producer through an SSH bastion with certificate auth, but I'm missing some configuration. I need either the bastion host or the name of the instance in AWS. These can be set in the call to `clearskies.backends.akeyless_aws.mysql_connection_dynamic_producer_via_ssh_cert_bastion()` by providing the 'bastion_host' or 'bastion_name' argument, or by setting an environment variable named 'akeyless_mysql_bastion_host' or 'akeyless_mysql_bastion_name'."
57
+ )
58
+
59
+ def _public_ip_from_name(self, bastion_name, bastion_region):
60
+ ec2 = self._boto3.client("ec2", region_name=bastion_region)
61
+ response = ec2.describe_instances(
62
+ Filters=[
63
+ {"Name": "tag:Name", "Values": [bastion_name]},
64
+ {"Name": "instance-state-name", "Values": ["running"]},
65
+ ],
66
+ )
67
+ if not response.get("Reservations"):
68
+ raise ValueError(
69
+ f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
70
+ )
71
+ if not response.get("Reservations")[0].get("Instances"):
72
+ raise ValueError(
73
+ f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
74
+ )
75
+ instance = response.get("Reservations")[0].get("Instances")[0]
76
+ if not instance.get("PublicIpAddress"):
77
+ raise ValueError(
78
+ f"I found the bastion instance with a name of '{bastion_name}' in region '{bastion_region}', but it doesn't have a public IP address"
79
+ )
80
+ return instance.get("PublicIpAddress")
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import socket
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from .mysql_connection_dynamic_producer_via_ssh_cert_bastion import (
10
+ MySQLConnectionDynamicProducerViaSSHCertBastion as Base,
11
+ )
12
+
13
+
14
+ class MySQLConnectionDynamicProducerViaSSMBastion(Base):
15
+ _config = None
16
+ _boto3 = None
17
+
18
+ def __init__(
19
+ self,
20
+ producer_name=None,
21
+ bastion_region=None,
22
+ bastion_name=None,
23
+ bastion_username=None,
24
+ bastion_instance_id=None,
25
+ public_key_file_path=None,
26
+ local_proxy_port=None,
27
+ database_host=None,
28
+ database_name=None,
29
+ ):
30
+ # not using kwargs because I want the argument list to be explicit
31
+ self.config = {
32
+ "producer_name": producer_name,
33
+ "bastion_instance_id": bastion_instance_id,
34
+ "bastion_region": bastion_region,
35
+ "bastion_name": bastion_name,
36
+ "bastion_username": bastion_username,
37
+ "public_key_file_path": public_key_file_path,
38
+ "local_proxy_port": local_proxy_port,
39
+ "database_host": database_host,
40
+ "database_name": database_name,
41
+ }
42
+
43
+ def provide_connection_details(self, environment, secrets, boto3):
44
+ self._boto3 = boto3
45
+ if not secrets:
46
+ raise ValueError(
47
+ "I was asked to connect to a database via an AKeyless dynamic producer but AKeyless itself wasn't configured. Try setting the AKeyless auth method via clearskies.secrets.akeyless_[jwt|saml|aws_iam]_auth()"
48
+ )
49
+
50
+ producer_name = self._fetch_config(environment, "producer_name", "akeyless_mysql_dynamic_producer")
51
+ bastion_username = self._fetch_config(environment, "bastion_username", "mysql_bastion_username", default="ssm")
52
+ bastion_instance_id = self._get_bastion_instance_id(environment)
53
+ public_key_file_path = self._fetch_config(
54
+ environment, "public_key_file_path", "mysql_bastion_public_key_file_path"
55
+ )
56
+ local_proxy_port = self._fetch_config(
57
+ environment, "local_proxy_port", "akeyless_mysql_bastion_local_proxy_port", default=8888
58
+ )
59
+ database_host = self._fetch_config(environment, "database_host", "db_host")
60
+ database_name = self._fetch_config(environment, "database_name", "db_database")
61
+
62
+ # Create the SSH tunnel (yeah, it's obnoxious)
63
+ self._create_tunnel(
64
+ secrets,
65
+ bastion_instance_id,
66
+ bastion_username,
67
+ bastion_region,
68
+ public_key_file_path,
69
+ local_proxy_port,
70
+ database_host,
71
+ )
72
+
73
+ # and now we can fetch credentials
74
+ credentials = secrets.get_dynamic_secret(producer_name)
75
+
76
+ return {
77
+ "username": credentials["user"],
78
+ "password": credentials["password"],
79
+ "host": "127.0.0.1",
80
+ "database": database_name,
81
+ "port": local_proxy_port,
82
+ }
83
+
84
+ def _get_bastion_instance_id(self, environment):
85
+ bastion_instance_id = self._fetch_config(
86
+ environment, "bastion_instance_id", "mysql_bastion_instance_id", default=""
87
+ )
88
+ bastion_name = self._fetch_config(environment, "bastion_name", "mysql_bastion_name", default="")
89
+ if bastion_instance_id:
90
+ return bastion_instance_id
91
+ if bastion_name:
92
+ bastion_region = self._fetch_config(environment, "bastion_region", "mysql_bastion_region")
93
+ return self._instance_id_from_name(bastion_name, bastion_region)
94
+ raise ValueError(
95
+ f"I was asked to connect to a database via an AKeyless dynamic producer through an SSH bastion with certificate auth, but I'm missing some configuration. I need either the bastion host or the name of the instance in AWS. These can be set in the call to `clearskies.backends.akeyless_aws.mysql_connection_dynamic_producer_via_ssh_cert_bastion()` by providing the 'bastion_host' or 'bastion_name' argument, or by setting an environment variable named 'akeyless_mysql_bastion_host' or 'akeyless_mysql_bastion_name'."
96
+ )
97
+
98
+ def _instance_id_from_name(self, bastion_name, bastion_region):
99
+ ec2 = self._boto3.client("ec2", region_name=bastion_region)
100
+ response = ec2.describe_instances(
101
+ Filters=[
102
+ {"Name": "tag:Name", "Values": [bastion_name]},
103
+ {"Name": "instance-state-name", "Values": ["running"]},
104
+ ],
105
+ )
106
+ if not response.get("Reservations"):
107
+ raise ValueError(
108
+ f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
109
+ )
110
+ if not response.get("Reservations")[0].get("Instances"):
111
+ raise ValueError(
112
+ f"Could not find a running instance with the designated bastion name, '{bastion_name}' in region '{bastion_region}'"
113
+ )
114
+ return response.get("Reservations")[0].get("Instances")[0]["InstanceId"]
115
+
116
+ def _create_tunnel(
117
+ self,
118
+ secrets,
119
+ bastion_instance_id,
120
+ bastion_username,
121
+ bastion_region,
122
+ public_key_file_path,
123
+ local_proxy_port,
124
+ database_host,
125
+ ):
126
+ # first see if the socket is already open, since we don't close it.
127
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
128
+ result = sock.connect_ex(("127.0.0.1", local_proxy_port))
129
+ if result == 0:
130
+ sock.close()
131
+ return
132
+
133
+ # and now we can do this thing.
134
+ tunnel_command = [
135
+ "ssh",
136
+ "-i",
137
+ public_key_file_path,
138
+ "-o",
139
+ "ConnectTimeout=2",
140
+ "-N",
141
+ "-L",
142
+ f"{local_proxy_port}:{database_host}:3306",
143
+ "-p",
144
+ "22",
145
+ f"{bastion_username}@{bastion_instance_id}",
146
+ ]
147
+ my_env = os.environ.copy()
148
+ my_env["AWS_DEFAULT_REGION"] = bastion_region
149
+ subprocess.Popen(tunnel_command)
150
+ connected = False
151
+ attempts = 0
152
+ while not connected and attempts < 6:
153
+ attempts += 1
154
+ time.sleep(0.5)
155
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
156
+ result = sock.connect_ex(("127.0.0.1", local_proxy_port))
157
+ if result == 0:
158
+ connected = True
159
+ if not connected:
160
+ raise ValueError(
161
+ "Failed to open SSH tunnel. The following command was used: \n" + " ".join(tunnel_command)
162
+ )
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from clearskies.secrets.akeyless import Akeyless
7
+ from types_boto3_ssm import SSMClient
8
+
9
+ from clearskies_aws.secrets import parameter_store
10
+
11
+
12
+ class AkeylessWithSsmCache(parameter_store.ParameterStore, Akeyless):
13
+ def get(self, path: str, refresh: bool = False) -> str | None: # type: ignore[override]
14
+ # AWS SSM parameter paths only allow a-z, A-Z, 0-9, -, _, ., /, @, and :
15
+ # Replace any disallowed characters with hyphens
16
+ ssm_name = re.sub(r"[^a-zA-Z0-9\-_\./@:]", "-", path)
17
+ # if we're not forcing a refresh, then see if it is in paramater store
18
+ if not refresh:
19
+ missing = False
20
+ try:
21
+ response = self.ssm.get_parameter(Name=ssm_name, WithDecryption=True)
22
+ except self.ssm.exceptions.ParameterNotFound:
23
+ missing = True
24
+ if not missing:
25
+ value = response["Parameter"].get("Value", "")
26
+ if value:
27
+ return value
28
+
29
+ # otherwise get it out of Akeyless
30
+ value = str(super().get(path))
31
+
32
+ # and make sure and store the new value in parameter store
33
+ if value:
34
+ self.ssm.put_parameter(
35
+ Name=ssm_name,
36
+ Value=value,
37
+ Type="SecureString",
38
+ Overwrite=True,
39
+ )
40
+
41
+ return value
42
+
43
+ def update(self, path: str, value: Any) -> bool: # type: ignore[override]
44
+ res = self._api.update_secret_val(
45
+ self.akeyless.UpdateSecretVal(name=path, value=str(value), token=self._get_token())
46
+ )
47
+ self.ssm.put_parameter(
48
+ Name=re.sub(r"[^a-zA-Z0-9\-_\./@:]", "-", path),
49
+ Value=value,
50
+ Type="SecureString",
51
+ Overwrite=True,
52
+ )
53
+ return True
54
+
55
+ def upsert(self, path: str, value: Any) -> bool: # type: ignore[override]
56
+ try:
57
+ self.update(path, value)
58
+ except Exception as e:
59
+ self.create(path, value)
60
+ return True
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from botocore.exceptions import ClientError
4
+ from clearskies.exceptions.not_found import NotFound
5
+ from types_boto3_ssm import SSMClient
6
+
7
+ from clearskies_aws.secrets import secrets
8
+
9
+
10
+ class ParameterStore(secrets.Secrets):
11
+ ssm: SSMClient
12
+
13
+ def __init__(self):
14
+ super().__init__()
15
+ self.ssm = self.boto3.client("ssm", region_name=self.environment.get("AWS_REGION"))
16
+
17
+ def create(self, path: str, value: str) -> bool:
18
+ return self.update(path, value)
19
+
20
+ def get(self, path: str, silent_if_not_found: bool = False) -> str | None: # type: ignore[override]
21
+ try:
22
+ result = self.ssm.get_parameter(Name=path, WithDecryption=True)
23
+ except ClientError as e:
24
+ error = e.response.get("Error", {})
25
+ if error.get("Code") == "ResourceNotFoundException":
26
+ if silent_if_not_found:
27
+ return None
28
+ raise NotFound(f"Could not find secret '{path}' in parameter store")
29
+ raise e
30
+ return result["Parameter"].get("Value", "")
31
+
32
+ def list_secrets(self, path: str) -> list[str]:
33
+ response = self.ssm.get_parameters_by_path(Path=path, Recursive=False)
34
+ return [parameter["Name"] for parameter in response["Parameters"] if "Name" in parameter]
35
+
36
+ def update(self, path: str, value: str) -> bool: # type: ignore[override]
37
+ response = self.ssm.put_parameter(
38
+ Name=path,
39
+ Value=value,
40
+ Type="String",
41
+ Overwrite=True,
42
+ )
43
+ return True
44
+
45
+ def upsert(self, path: str, value: str) -> bool: # type: ignore[override]
46
+ return self.update(path, value)
47
+
48
+ def list_sub_folders(
49
+ self,
50
+ path: str,
51
+ ) -> list[str]: # type: ignore[override]
52
+ raise NotImplementedError("Parameter store doesn't support list_sub_folders.")
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from clearskies.di.inject import Di, Environment
4
+ from clearskies.secrets import Secrets as BaseSecrets
5
+
6
+ from clearskies_aws.di import inject
7
+
8
+
9
+ class Secrets(BaseSecrets):
10
+ boto3 = inject.Boto3()
11
+ environment = Environment()
12
+
13
+ def __init__(self):
14
+ super().__init__()
15
+ if not self.environment.get("AWS_REGION", True):
16
+ raise ValueError("To use secrets manager you must use set the 'AWS_REGION' environment variable")
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from botocore.exceptions import ClientError
6
+ from clearskies.exceptions.not_found import NotFound
7
+ from types_boto3_secretsmanager import SecretsManagerClient
8
+ from types_boto3_secretsmanager.type_defs import SecretListEntryTypeDef
9
+
10
+ from clearskies_aws.secrets import secrets
11
+
12
+
13
+ class SecretsManager(secrets.Secrets):
14
+ secrets_manager: SecretsManagerClient
15
+
16
+ def __init__(self):
17
+ super().__init__()
18
+ self.secrets_manager = self.boto3.client("secretsmanager", region_name=self.environment.get("AWS_REGION"))
19
+
20
+ def create(self, secret_id: str, value: Any, kms_key_id: str | None = None) -> bool:
21
+ calling_parameters = {
22
+ "SecretId": secret_id,
23
+ "SecretString": value,
24
+ "KmsKeyId": kms_key_id,
25
+ }
26
+ calling_parameters = {key: value for (key, value) in calling_parameters.items() if value}
27
+ result = self.secrets_manager.create_secret(**calling_parameters)
28
+ return bool(result.get("ARN"))
29
+
30
+ def get( # type: ignore[override]
31
+ self,
32
+ secret_id: str,
33
+ version_id: str | None = None,
34
+ version_stage: str | None = None,
35
+ silent_if_not_found: bool = False,
36
+ ) -> str | bytes | None:
37
+ calling_parameters = {"SecretId": secret_id}
38
+
39
+ # Only add optional parameters if they are not None
40
+ if version_id:
41
+ calling_parameters["VersionId"] = version_id
42
+ if version_stage:
43
+ calling_parameters["VersionStage"] = version_stage
44
+
45
+ try:
46
+ result = self.secrets_manager.get_secret_value(**calling_parameters)
47
+ except ClientError as e:
48
+ error = e.response.get("Error", {})
49
+ if error.get("Code") == "ResourceNotFoundException":
50
+ if silent_if_not_found:
51
+ return None
52
+ raise NotFound(
53
+ f"Could not find secret '{secret_id}' with version '{version_id}' and stage '{version_stage}'"
54
+ )
55
+ raise e
56
+ if result.get("SecretString"):
57
+ return result.get("SecretString")
58
+ return result.get("SecretBinary")
59
+
60
+ def list_secrets(self, path: str) -> list[SecretListEntryTypeDef]: # type: ignore[override]
61
+ results = self.secrets_manager.list_secrets(
62
+ Filters=[
63
+ {
64
+ "Key": "name",
65
+ "Values": [path],
66
+ },
67
+ ],
68
+ )
69
+ return results["SecretList"]
70
+
71
+ def update(self, secret_id: str, value: str, kms_key_id: str | None = None) -> bool: # type: ignore[override]
72
+ calling_parameters = {
73
+ "SecretId": secret_id,
74
+ "SecretString": value,
75
+ }
76
+ if kms_key_id:
77
+ # If no KMS key is provided, we should not include it in the parameters
78
+ calling_parameters["KmsKeyId"] = kms_key_id
79
+
80
+ result = self.secrets_manager.update_secret(**calling_parameters)
81
+ return bool(result.get("ARN"))
82
+
83
+ def upsert(self, secret_id: str, value: str, kms_key_id: str | None = None) -> bool: # type: ignore[override]
84
+ calling_parameters = {
85
+ "SecretId": secret_id,
86
+ "SecretString": value,
87
+ }
88
+ if kms_key_id:
89
+ # If no KMS key is provided, we should not include it in the parameters
90
+ calling_parameters["KmsKeyId"] = kms_key_id
91
+
92
+ result = self.secrets_manager.put_secret_value(**calling_parameters)
93
+ return bool(result.get("ARN"))
94
+
95
+ def list_sub_folders(self, path: str, value: str) -> list[str]: # type: ignore[override]
96
+ raise NotImplementedError("Secrets Manager doesn't support list_sub_folders.")
@@ -1,4 +0,0 @@
1
- clear_skies_aws-2.0.1.dist-info/METADATA,sha256=RuKqjsvTbdZc56XVM2UD6IZnSySbVkWA5SaFkxpbps4,8972
2
- clear_skies_aws-2.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
3
- clear_skies_aws-2.0.1.dist-info/licenses/LICENSE,sha256=MkEX8JF8kZxdyBpTTcB0YTd-xZpWnHvbRlw-pQh8u58,1069
4
- clear_skies_aws-2.0.1.dist-info/RECORD,,