clear-skies 2.0.12__py3-none-any.whl → 2.0.14__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.

Potentially problematic release.


This version of clear-skies might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clear-skies
3
- Version: 2.0.12
3
+ Version: 2.0.14
4
4
  Summary: A framework for building backends in the cloud
5
5
  Project-URL: Documentation, https://clearskies.io/
6
6
  Project-URL: Repository, https://github.com/clearskies-py/clearskies
@@ -112,7 +112,7 @@ clearskies/configs/authentication.py,sha256=9XZClfYcECi5dzt1BvDof34tW6IotN252bcx
112
112
  clearskies/configs/authorization.py,sha256=Pa6wCyMitpKv2Nde69tMB5sJsxeCI2nVSdTuwF6R9pQ,828
113
113
  clearskies/configs/boolean.py,sha256=Yj6fEKo85wQkalg7oHimn1ZU3lJGxCmKeFV1mvdXqIc,624
114
114
  clearskies/configs/boolean_or_callable.py,sha256=vVyB1ES8IW_nX5ZzwumQy2EM7LctQ9M_8IB5BOnmNEU,745
115
- clearskies/configs/callable_config.py,sha256=XgGkMfbZrqcUaU9RWe6l1DVN_TRTW0GN0mHaaCHfQ3g,686
115
+ clearskies/configs/callable_config.py,sha256=nEZyHVUkrIysB21iiHJLwznbfU5bZGKCGioZ-5BOLfU,708
116
116
  clearskies/configs/columns.py,sha256=WhdpPUiwVqwBO2XCDDiMym2nWr_1-L1JoICyob4T6Wo,1258
117
117
  clearskies/configs/conditions.py,sha256=a09bVac4-10ZtKPQGSu_e9VnHnwITwy_7_T4NekXpgs,1008
118
118
  clearskies/configs/config.py,sha256=2qsF3ZBc8tsTXDgNLL7vT01kjWanbJH3oXO9R2S6GnU,909
@@ -158,15 +158,16 @@ clearskies/configs/writeable_model_columns.py,sha256=vQFh5w6ToC8SDi_GaEvy33csp2C
158
158
  clearskies/contexts/__init__.py,sha256=f7XVUq2UKlDH6fjmcUWk6lbe9p_OaGpZ5ZjM6CuwTGQ,247
159
159
  clearskies/contexts/cli.py,sha256=cuGWoyRhHlO_Ba6Dozg3sGob1VZoI4TBFOLu-2Udabk,2838
160
160
  clearskies/contexts/context.py,sha256=rT-d0v5wiVqRNz_bsitTBXYEy0555-H2xr9TpETf-og,3676
161
- clearskies/contexts/wsgi.py,sha256=7Vhh-gX2RPZUXad1hq9IkYx1JASyfV5xoPlwMOwjHso,3188
162
- clearskies/contexts/wsgi_ref.py,sha256=Z4oBIYeSsLp93dR1eBsZTaevzVYB0QrR-ugp1CQVltU,2822
161
+ clearskies/contexts/wsgi.py,sha256=Wrf0B9WxoMPe71jn2fATsSYyS_Ego3VKFGjuI6KliWs,3165
162
+ clearskies/contexts/wsgi_ref.py,sha256=q78gyeQS3KJ80NsozJkKb0ag00_6uwPK1yj5wxUM3JA,2818
163
163
  clearskies/di/__init__.py,sha256=Ab8GNv9ZksnCABq8n2gCcyLEAXD-5-kX4O8PweTJIFs,474
164
164
  clearskies/di/additional_config.py,sha256=65INxw8aqTZQsyaKPj-aQmd6FBe4_4DwibXGgWYBy14,5139
165
165
  clearskies/di/additional_config_auto_import.py,sha256=XYw0Kcnp6hp-ee-c0YjiATwJvRb2E82xk9PuoX9dGRY,758
166
- clearskies/di/di.py,sha256=mW7Tk6-9pI5khq9h_A2kXJ3XDytwC6JdLX3TcLxU2q0,46348
166
+ clearskies/di/di.py,sha256=_H3K5BK_kkY-8PP7GA5w2i0BmG4EX4nwQLot_TCjc7w,46416
167
167
  clearskies/di/injectable.py,sha256=TTgqhx494470I61-88BUQmHmevfat-wXVseKl8pQOEk,852
168
168
  clearskies/di/injectable_properties.py,sha256=yJP0J7l7tjG2soyXtrfDgktE7M8tQHaP-55Cmtq0b7M,6466
169
169
  clearskies/di/inject/__init__.py,sha256=plEkWId-VyhvqX5KM2HhdCqi7_ZJzPmFz69cPAo812Y,643
170
+ clearskies/di/inject/akeyless_sdk.py,sha256=eCV5KkALAAwNnHVMdAQ0hlYGA2U_sJfHn3vBxkngZh8,417
170
171
  clearskies/di/inject/by_class.py,sha256=1wn08Ahne1nJ-ddRRE4i70U2XwCvU_khG2U0iEGoaVE,832
171
172
  clearskies/di/inject/by_name.py,sha256=Vgt_4NdptIVNrpVawGz-ZZfqp1MQLnvlqAZmjsX0WqI,675
172
173
  clearskies/di/inject/di.py,sha256=wuJU7u3AKPYoymQVcIR8m7t4pVIEtMvsN1RzujMqGfk,311
@@ -211,7 +212,7 @@ clearskies/input_outputs/headers.py,sha256=AnyqI64kploPX7qiBfQCD9w8b2FYWVIuwaXVa
211
212
  clearskies/input_outputs/input_output.py,sha256=tJQVN3U3MX_jpwsXJ-g-K1cdqQwyuSarTjo3JOp7zQQ,5154
212
213
  clearskies/input_outputs/programmatic.py,sha256=OCRq0M42cKZKgk4YAfJyTWo3T4jNRmnGmVr7zCTovpg,1658
213
214
  clearskies/input_outputs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
214
- clearskies/input_outputs/wsgi.py,sha256=-utkoQnA4SCAJ-9Ydr-zTG8W5Hbsm4n91-i6Yu6GM7U,2749
215
+ clearskies/input_outputs/wsgi.py,sha256=wcqZUu8zuKj9V1ur2oNlYqgcI_sC5LJEieZxvDVMMFU,2783
215
216
  clearskies/input_outputs/exceptions/__init__.py,sha256=3KiM3KaMYEKoToqCCQ4_no2n0W5ROqeBC0sI2Ix4P6w,82
216
217
  clearskies/input_outputs/exceptions/cli_input_error.py,sha256=kOFU8aLTLmeTL_AKDshxMu8_ufildg6p8ndhE1xHfb0,41
217
218
  clearskies/input_outputs/exceptions/cli_not_found.py,sha256=JBBuZA9ZwdkPhd3a0qaGgEPQrxh1fehy4R3ZaV2gWXU,39
@@ -221,13 +222,14 @@ clearskies/query/join.py,sha256=4lrDUQzck7klKY_VYkc4SVK95SVwyy3SVTvasnsAEyc,4713
221
222
  clearskies/query/query.py,sha256=0XR3fNhOpDNJY0US2oseAS3p3Y0jxxVs86P6vWEvUcA,6063
222
223
  clearskies/query/sort.py,sha256=c-EtIkjg3kLjwSTdXD7sfyx-mNUhAepUV-2izprh3iY,754
223
224
  clearskies/secrets/__init__.py,sha256=G-A8YhCMlS_OdboSeKzCZp6iwfqwU4BPEnB5HvD88wY,142
224
- clearskies/secrets/akeyless.py,sha256=4SwnVNzMAijZtzR0Q25dizEw-q7nbS4G5s0CoGyc-G0,7219
225
- clearskies/secrets/secrets.py,sha256=9sYrI0PmxXAzyDCfylOmb8svXqPc9IefaWKaBPHrjxE,1815
225
+ clearskies/secrets/akeyless.py,sha256=TuKJbi0LQYMsEAxMFFeNYopuM9otcX9ROToXkhVJGSk,20375
226
+ clearskies/secrets/secrets.py,sha256=z9ouvwTwdyyOFmaCCWMRR6T9capRWFHswz563OA-JzE,1566
226
227
  clearskies/secrets/additional_configs/__init__.py,sha256=cFCrbtKF5nuR061S2y1iKZp349x-y8Srdwe3VZbfSFU,1119
227
228
  clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py,sha256=CnIiXLVQdUnUey3dbCTXuNNP7Mmw1gjjNjZiBtfgGto,2757
228
229
  clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py,sha256=N8ruxrTNhvYlp3cYXq6V78KPPr4n40LM7QoXHvD8IZg,6235
229
- clearskies/secrets/exceptions/__init__.py,sha256=j-SLHD-DL0CT4cZXibD9kXHk63JEl_UKX6xL_nq1EfE,32
230
- clearskies/secrets/exceptions/not_found.py,sha256=_lZwovDrd18dUHDop5pF4mhexBPNr126xF2gOLA2-EA,36
230
+ clearskies/secrets/exceptions/__init__.py,sha256=HtcwEuKzu3tRLq3FeXUN0UhaEGqukL5vntuBbX47DzM,209
231
+ clearskies/secrets/exceptions/not_found_error.py,sha256=9NT1g4Q94PRteYl9coiLVbqbHTlkrd-C-KQKG5clz9Q,41
232
+ clearskies/secrets/exceptions/permissions_error.py,sha256=6oxKMDLZLmoZZFB9PMZxWQgsaQXoX2V6qWn-LAckHt0,44
231
233
  clearskies/security_headers/__init__.py,sha256=JUpc4Y8dNBinDQAkP7OOABR7N787-lRR23boSWmY6Us,285
232
234
  clearskies/security_headers/cache_control.py,sha256=gD1070vxwDJXQi8R3qIIR6hsOpVaaEV3-I52Up1DfM4,2143
233
235
  clearskies/security_headers/cors.py,sha256=RqqakKH13JXCp0ybkf7YFGEKDR5Xg7a1lfk47AAI7CU,1778
@@ -251,7 +253,7 @@ clearskies/validators/minimum_value.py,sha256=NDLcG6xCemlv3kfr-RiUaM3x2INS1GJGMB
251
253
  clearskies/validators/required.py,sha256=GWxyexwj-K6DunZWNEnZxW6tQGAFd4oOCvQrW1s1K9k,1308
252
254
  clearskies/validators/timedelta.py,sha256=DJ0pTm-SSUtjZ7phGoD6vjb086vXPzvLLijkU-jQlOs,1892
253
255
  clearskies/validators/unique.py,sha256=GFEQOMYRIO9pSGHHj6zf1GdnJ0UM7Dm4ZO4uGn19BZo,991
254
- clear_skies-2.0.12.dist-info/METADATA,sha256=Lq1xv-aCUbaeDxJUtsjY36pQDKoGhV7JMQ7i0nhjKsM,2114
255
- clear_skies-2.0.12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
256
- clear_skies-2.0.12.dist-info/licenses/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
257
- clear_skies-2.0.12.dist-info/RECORD,,
256
+ clear_skies-2.0.14.dist-info/METADATA,sha256=LEuwdzSsmZkbXo0VGjF8R9m7AIGOmbWW4CL3MCY_kPQ,2114
257
+ clear_skies-2.0.14.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
258
+ clear_skies-2.0.14.dist-info/licenses/LICENSE,sha256=3Ehd0g3YOpCj8sqj0Xjq5qbOtjjgk9qzhhD9YjRQgOA,1053
259
+ clear_skies-2.0.14.dist-info/RECORD,,
@@ -7,7 +7,7 @@ from clearskies.configs import config
7
7
 
8
8
  class Callable(config.Config):
9
9
  def __set__(self, instance, value: CallableType):
10
- if not callable(value):
10
+ if value is not None and not callable(value):
11
11
  error_prefix = self._error_prefix(instance)
12
12
  raise TypeError(
13
13
  f"{error_prefix} attempt to set a value of type '{value.__class__.__name__}' to a parameter that requries a Callable."
@@ -3,9 +3,7 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from clearskies.contexts.context import Context
6
-
7
- if TYPE_CHECKING:
8
- from clearskies.input_outputs import Wsgi as WsgiInputOutput
6
+ from clearskies.input_outputs import Wsgi as WsgiInputOutput
9
7
 
10
8
 
11
9
  class Wsgi(Context):
@@ -7,12 +7,12 @@ from wsgiref.simple_server import make_server
7
7
  from wsgiref.util import setup_testing_defaults
8
8
 
9
9
  from clearskies.contexts.context import Context
10
+ from clearskies.input_outputs import Wsgi as WsgiInputOutput
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from clearskies.di import AdditionalConfig
13
14
  from clearskies.endpoint import Endpoint
14
15
  from clearskies.endpoint_group import EndpointGroup
15
- from clearskies.input_outputs import Wsgi as WsgiInputOutput
16
16
 
17
17
 
18
18
  class WsgiRef(Context):
clearskies/di/di.py CHANGED
@@ -591,6 +591,7 @@ class Di:
591
591
  4. The Di class itself if it has a matching `provide_[name]` function (aka the builtins)
592
592
  """
593
593
  if name in self._prepared and cache:
594
+ self.inject_properties(self._prepared[name].__class__)
594
595
  return self._prepared[name]
595
596
 
596
597
  if name in self._bindings:
@@ -985,7 +986,7 @@ class Di:
985
986
  def provide_global_table_prefix(self):
986
987
  return ""
987
988
 
988
- def provide_akeyles_sdk(self):
989
+ def provide_akeyless_sdk(self):
989
990
  import akeyless # type: ignore[import-untyped]
990
991
 
991
992
  return akeyless
@@ -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 AkeylessSDK(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("akeyless_sdk", cache=self.cache)
@@ -1,4 +1,5 @@
1
1
  from __future__ import annotations
2
+ from urllib.parse import parse_qs
2
3
 
3
4
  import json
4
5
  from typing import Callable
@@ -1,111 +1,372 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import datetime
4
- from typing import Any
4
+ import json
5
+ import logging
6
+ from types import ModuleType
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from typing_extensions import override
5
10
 
6
11
  from clearskies import configs, secrets
12
+ from clearskies.decorators import parameters_to_properties
7
13
  from clearskies.di import inject
14
+ from clearskies.secrets.exceptions import PermissionsError
15
+
16
+ if TYPE_CHECKING:
17
+ from akeyless import ListItemsOutput, V2Api
8
18
 
9
19
 
10
20
  class Akeyless(secrets.Secrets):
21
+ """
22
+ Backend for managing secrets using the Akeyless Vault.
23
+
24
+ This class provides integration with Akeyless vault services, allowing you to store, retrieve,
25
+ and manage secrets. It supports different types of secrets (static, dynamic, rotated) and
26
+ includes authentication mechanisms for AWS IAM, SAML, and JWT.
27
+ """
28
+
29
+ """
30
+ HTTP client for making API requests
31
+ """
11
32
  requests = inject.Requests()
33
+
34
+ """
35
+ Environment configuration for retrieving environment variables
36
+ """
12
37
  environment = inject.Environment()
13
- akeyless = inject.ByName("akeyless_sdk")
14
38
 
39
+ """
40
+ The Akeyless SDK module injected by the dependency injection system
41
+ """
42
+ akeyless: ModuleType = inject.ByName("akeyless_sdk") # type: ignore
43
+
44
+ """
45
+ The access ID for the Akeyless service
46
+
47
+ This must match the pattern p-[0-9a-zA-Z]+ (e.g., "p-abc123")
48
+ """
15
49
  access_id = configs.String(required=True, regexp=r"^p-[\d\w]+$")
50
+
51
+ """
52
+ The authentication method to use
53
+
54
+ Must be one of "aws_iam", "saml", or "jwt"
55
+ """
16
56
  access_type = configs.Select(["aws_iam", "saml", "jwt"], required=True)
57
+
58
+ """
59
+ The Akeyless API host to connect to
60
+
61
+ Defaults to "https://api.akeyless.io"
62
+ """
17
63
  api_host = configs.String(default="https://api.akeyless.io")
18
- profile = configs.String(regexp=r"^[\d\w\-]+$")
19
-
20
- _token_refresh: datetime.datetime = None # type: ignore
21
- _token: str = ""
22
- _api: Any = None
23
-
24
- def __init__(self, access_id: str, access_type: str, jwt_env_key: str = "", api_host: str = "", profile: str = ""):
25
- self.access_id = access_id
26
- self.access_type = access_type
27
- self.jwt_env_key = jwt_env_key
28
- self.api_host = api_host
29
- self.profile = profile
30
- if self.access_type == "jwt" and not self.jwt_env_key:
31
- raise ValueError("When using the JWT access type for Akeyless you must provide jwt_env_key")
32
64
 
65
+ """
66
+ The environment variable key that contains the JWT when using JWT authentication
67
+
68
+ This is required when access_type is "jwt"
69
+ """
70
+ jwt_env_key = configs.String(required=False)
71
+
72
+ """
73
+ The SAML profile name when using SAML authentication
74
+
75
+ Must match the pattern [0-9a-zA-Z-]+ if provided
76
+ """
77
+ profile = configs.String(regexp=r"^[\d\w-]+$")
78
+
79
+ """
80
+ Whether to automatically guess the secret type
81
+
82
+ When enabled, the system will check the secret type (static, dynamic, rotated)
83
+ and call the appropriate method to retrieve it.
84
+ """
85
+ auto_guess_type = configs.Boolean(default=False)
86
+
87
+ """
88
+ When the current token expires
89
+ """
90
+ _token_refresh: datetime.datetime # type: ignore
91
+
92
+ """
93
+ The current authentication token
94
+ """
95
+ _token: str
96
+
97
+ """
98
+ The configured V2Api client
99
+ """
100
+ _api: V2Api
101
+
102
+ @parameters_to_properties
103
+ def __init__(
104
+ self,
105
+ access_id: str,
106
+ access_type: str,
107
+ jwt_env_key: str | None = None,
108
+ api_host: str | None = None,
109
+ profile: str | None = None,
110
+ auto_guess_type: bool = False,
111
+ ):
112
+ """
113
+ Initialize the Akeyless backend with the specified configuration.
114
+
115
+ The access_id must be provided and follow the format p-[0-9a-zA-Z]+. The access_type must be
116
+ one of "aws_iam", "saml", or "jwt". If using JWT authentication, jwt_env_key must be provided.
117
+ """
33
118
  self.finalize_and_validate_configuration()
119
+ self.logger = logging.getLogger(self.__class__.__name__)
120
+
121
+ def configure(self) -> None:
122
+ """
123
+ Perform additional configuration validation.
124
+
125
+ Ensures that when using JWT authentication, the jwt_env_key is provided. Raises ValueError
126
+ if access_type is "jwt" and jwt_env_key is not provided.
127
+ """
128
+ if self.access_type == "jwt" and not self.jwt_env_key:
129
+ raise ValueError("When using the JWT access type for Akeyless you must provide jwt_env_key")
34
130
 
35
131
  @property
36
- def api(self) -> Any:
37
- if self._api is None:
132
+ def api(self) -> V2Api:
133
+ """
134
+ Get the configured V2Api client.
135
+
136
+ Creates a new API client if one doesn't exist yet, using the configured api_host.
137
+ """
138
+ if not hasattr(self, "_api"):
38
139
  configuration = self.akeyless.Configuration(host=self.api_host)
39
140
  self._api = self.akeyless.V2Api(self.akeyless.ApiClient(configuration))
40
141
  return self._api
41
142
 
42
143
  def create(self, path: str, value: Any) -> bool:
144
+ """
145
+ Create a new secret at the given path.
146
+
147
+ Checks permissions before creating the secret and raises PermissionsError if the user doesn't
148
+ have write permission for the path. The value is converted to a string before storage.
149
+ """
150
+ if not "write" in self.describe_permissions(path):
151
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
152
+
43
153
  res = self.api.create_secret(self.akeyless.CreateSecret(name=path, value=str(value), token=self._get_token()))
44
154
  return True
45
155
 
46
- def get(self, path: str, silent_if_not_found: bool = False) -> str:
156
+ def get(
157
+ self,
158
+ path: str,
159
+ silent_if_not_found: bool = False,
160
+ json_attribute: str | None = None,
161
+ args: dict[str, Any] | None = None,
162
+ ) -> str:
163
+ """
164
+ Get the secret at the given path.
165
+
166
+ When auto_guess_type is enabled, this method automatically determines if the secret is static,
167
+ dynamic, or rotated and calls the appropriate method to retrieve it. If silent_if_not_found is
168
+ True, returns an empty string when the secret is not found. If json_attribute is provided,
169
+ treats the secret as JSON and returns the specified attribute.
170
+ """
171
+ if not self.auto_guess_type:
172
+ return self.get_static_secret(path, silent_if_not_found=silent_if_not_found, json_attribute=json_attribute)
173
+
174
+ try:
175
+ secret = self.describe_secret(path)
176
+ except Exception as e:
177
+ if e.status == 404: # type: ignore
178
+ if silent_if_not_found:
179
+ return ""
180
+ raise e
181
+ else:
182
+ raise ValueError(
183
+ f"describe-secret call failed for path {path}: perhaps a permissions issue? Akeless says {e}"
184
+ )
185
+
186
+ self.logger.debug(f"Auto-detected secret type '{secret.item_type}' for secret '{path}'")
187
+ match secret.item_type.lower():
188
+ case "dynamic_secret":
189
+ return str(
190
+ self.get_dynamic_secret(
191
+ path,
192
+ json_attribute=json_attribute,
193
+ args=args,
194
+ )
195
+ )
196
+ case "rotated_secret":
197
+ return str(self.get_rotated_secret(path, json_attribute=json_attribute, args=args))
198
+ case "static_secret":
199
+ return self.get_static_secret(
200
+ path, json_attribute=json_attribute, silent_if_not_found=silent_if_not_found
201
+ )
202
+ case _:
203
+ raise ValueError(f"Unsupported secret type for auto-detection: '{secret.item_type}'")
204
+
205
+ def get_static_secret(self, path: str, silent_if_not_found: bool = False, json_attribute: str | None = None) -> str:
206
+ """
207
+ Get a static secret from the given path.
208
+
209
+ Checks permissions before retrieving the secret and raises PermissionsError if the user doesn't
210
+ have read permission. If silent_if_not_found is True, returns an empty string when the secret
211
+ is not found. If json_attribute is provided, treats the secret as JSON and returns the specified attribute.
212
+ """
213
+ if not "read" in self.describe_permissions(path):
214
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
215
+
47
216
  try:
48
- res = self._api.get_secret_value(self.akeyless.GetSecretValue(names=[path], token=self._get_token()))
217
+ res: dict[str, object] = self.api.get_secret_value( # type: ignore
218
+ self.akeyless.GetSecretValue(
219
+ names=[path], token=self._get_token(), json=True if json_attribute else False
220
+ )
221
+ )
49
222
  except Exception as e:
50
223
  if e.status == 404: # type: ignore
51
224
  if silent_if_not_found:
52
225
  return ""
53
226
  raise KeyError(f"Secret '{path}' not found")
54
227
  raise e
55
- return res[path]
228
+ if json_attribute:
229
+ return self._get_nested_attribute(res[path], json_attribute) # type: ignore
230
+ return str(res[path])
231
+
232
+ def get_dynamic_secret(
233
+ self, path: str, json_attribute: str | None = None, args: dict[str, Any] | None = None
234
+ ) -> Any:
235
+ """
236
+ Get a dynamic secret from the given path.
237
+
238
+ Dynamic secrets are generated on-demand, such as database credentials. Checks permissions
239
+ before retrieving the secret and raises PermissionsError if the user doesn't have read
240
+ permission. If json_attribute is provided, treats the result as JSON and returns the
241
+ specified attribute.
242
+ """
243
+ if not "read" in self.describe_permissions(path):
244
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
56
245
 
57
- def get_dynamic_secret(self, path: str, args: dict[str, Any] | None = None) -> Any:
58
246
  kwargs = {
59
247
  "name": path,
60
248
  "token": self._get_token(),
61
249
  }
62
250
  if args:
63
251
  kwargs["args"] = args # type: ignore
252
+ res: dict[str, Any] = self.api.get_dynamic_secret_value(self.akeyless.GetDynamicSecretValue(**kwargs)) # type: ignore
253
+ if json_attribute:
254
+ return self._get_nested_attribute(res, json_attribute)
255
+ return res
256
+
257
+ def get_rotated_secret(
258
+ self, path: str, json_attribute: str | None = None, args: dict[str, Any] | None = None
259
+ ) -> Any:
260
+ """
261
+ Get a rotated secret from the given path.
64
262
 
65
- return self._api.get_dynamic_secret_value(self.akeyless.GetDynamicSecretValue(**kwargs))
263
+ Rotated secrets are automatically replaced on a schedule. Checks permissions before
264
+ retrieving the secret and raises PermissionsError if the user doesn't have read
265
+ permission. If json_attribute is provided, treats the result as JSON and returns the
266
+ specified attribute.
267
+ """
268
+ if not "read" in self.describe_permissions(path):
269
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
66
270
 
67
- def get_rotated_secret(self, path: str, args: dict[str, Any] | None = None) -> Any:
68
271
  kwargs = {
69
272
  "names": path,
70
273
  "token": self._get_token(),
274
+ "json": True if json_attribute else False,
71
275
  }
72
276
  if args:
73
277
  kwargs["args"] = args # type: ignore
74
278
 
75
- res = self._api.get_rotated_secret_value(self.akeyless.GetRotatedSecretValue(**kwargs))
279
+ res: dict[str, str] = self._api.get_rotated_secret_value(self.akeyless.GetRotatedSecretValue(**kwargs))["value"] # type: ignore
280
+ if json_attribute:
281
+ return self._get_nested_attribute(res, json_attribute)
76
282
  return res
77
283
 
284
+ def describe_secret(self, path: str) -> Any:
285
+ """
286
+ Get metadata about a secret.
287
+
288
+ Checks permissions before retrieving metadata and raises PermissionsError if the user
289
+ doesn't have read permission for the path.
290
+ """
291
+ if not "read" in self.describe_permissions(path):
292
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
293
+
294
+ return self.api.describe_item(self.akeyless.DescribeItem(name=path, token=self._get_token()))
295
+
78
296
  def list_secrets(self, path: str) -> list[Any]:
79
- res = self._api.list_items(self.akeyless.ListItems(path=path, token=self._get_token()))
297
+ """
298
+ List all secrets at the given path.
299
+
300
+ Checks permissions before listing secrets and raises PermissionsError if the user doesn't
301
+ have list permission for the path. Returns an empty list if no secrets are found.
302
+ """
303
+ if not "list" in self.describe_permissions(path):
304
+ raise PermissionsError(f"You do not have permission the secrets in '{path}'")
305
+
306
+ res: ListItemsOutput = self.api.list_items( # type: ignore
307
+ self.akeyless.ListItems(
308
+ path=path,
309
+ token=self._get_token(),
310
+ )
311
+ )
80
312
  if not res.items:
81
313
  return []
82
314
 
83
315
  return [item.item_name for item in res.items]
84
316
 
85
317
  def update(self, path: str, value: Any) -> None:
86
- res = self._api.update_secret_val(
318
+ """
319
+ Update an existing secret.
320
+
321
+ Checks permissions before updating the secret and raises PermissionsError if the user
322
+ doesn't have write permission for the path. The value is converted to a string before storage.
323
+ """
324
+ if not "write" in self.describe_permissions(path):
325
+ raise PermissionsError(f"You do not have permission the secret '{path}'")
326
+
327
+ res = self.api.update_secret_val(
87
328
  self.akeyless.UpdateSecretVal(name=path, value=str(value), token=self._get_token())
88
329
  )
89
330
 
90
331
  def upsert(self, path: str, value: Any) -> None:
332
+ """
333
+ Create or update a secret.
334
+
335
+ This method attempts to update an existing secret, and if that fails, it tries to create
336
+ a new one. The value is converted to a string before storage.
337
+ """
91
338
  try:
92
339
  self.update(path, value)
93
340
  except Exception as e:
94
341
  self.create(path, value)
95
342
 
96
343
  def list_sub_folders(self, main_folder: str) -> list[str]:
97
- """Return the list of secrets/sub folders in the given folder."""
98
- items = self._api.list_items(self.akeyless.ListItems(path=main_folder, token=self._get_token()))
344
+ """
345
+ Return the list of secrets/sub folders in the given folder.
346
+
347
+ Checks permissions before listing subfolders and raises PermissionsError if the user doesn't
348
+ have list permission for the path. Returns the relative subfolder names without the parent path.
349
+ """
350
+ if not "list" in self.describe_permissions(main_folder):
351
+ raise PermissionsError(f"You do not have permission to list sub folders in '{main_folder}'")
352
+
353
+ items = self.api.list_items(self.akeyless.ListItems(path=main_folder, token=self._get_token()))
99
354
 
100
355
  # akeyless will return the absolute path and end in a slash but we only want the folder name
101
356
  main_folder_string_len = len(main_folder)
102
- return [sub_folder[main_folder_string_len:-1] for sub_folder in items.folders]
357
+ return [sub_folder[main_folder_string_len:-1] for sub_folder in items.folders] # type: ignore
103
358
 
104
359
  def get_ssh_certificate(self, cert_issuer: str, cert_username: str, path_to_public_file: str) -> Any:
360
+ """
361
+ Get an SSH certificate from Akeyless.
362
+
363
+ Reads the public key from the specified file path and requests a certificate for the given
364
+ username and issuer from Akeyless.
365
+ """
105
366
  with open(path_to_public_file, "r") as fp:
106
367
  public_key = fp.read()
107
368
 
108
- res = self._api.get_ssh_certificate(
369
+ res = self.api.get_ssh_certificate(
109
370
  self.akeyless.GetSSHCertificate(
110
371
  cert_username=cert_username,
111
372
  cert_issuer_name=cert_issuer,
@@ -114,11 +375,22 @@ class Akeyless(secrets.Secrets):
114
375
  )
115
376
  )
116
377
 
117
- return res.data
378
+ return res.data # type: ignore
118
379
 
119
380
  def _get_token(self) -> str:
381
+ """
382
+ Get an authentication token for Akeyless API calls.
383
+
384
+ Returns a cached token if available and not expired (within 10 seconds), otherwise obtains
385
+ a new one using the configured authentication method. Tokens are valid for about an hour,
386
+ but we set the refresh time to 30 minutes to be safe.
387
+ """
120
388
  # AKeyless tokens live for an hour
121
- if self._token is not None and (self._token_refresh - datetime.datetime.now()).total_seconds() > 10:
389
+ if (
390
+ hasattr(self, "_token_refresh")
391
+ and hasattr(self, "_token")
392
+ and (self._token_refresh - datetime.datetime.now()).total_seconds() > 10
393
+ ):
122
394
  return self._token
123
395
 
124
396
  auth_method_name = f"auth_{self.access_type}"
@@ -130,14 +402,26 @@ class Akeyless(secrets.Secrets):
130
402
  return self._token
131
403
 
132
404
  def auth_aws_iam(self):
405
+ """
406
+ Authenticate using AWS IAM.
407
+
408
+ Uses the akeyless_cloud_id package to generate a cloud ID and authenticates with Akeyless
409
+ using the configured access_id.
410
+ """
133
411
  from akeyless_cloud_id import CloudId # type: ignore
134
412
 
135
- res = self._api.auth(
413
+ res = self.api.auth(
136
414
  self.akeyless.Auth(access_id=self.access_id, access_type="aws_iam", cloud_id=CloudId().generate())
137
415
  )
138
- return res.token
416
+ return res.token # type: ignore
139
417
 
140
418
  def auth_saml(self):
419
+ """
420
+ Authenticate using SAML.
421
+
422
+ Uses the akeyless CLI to generate credentials and then retrieves a token either directly
423
+ from the credentials file or by making an API call to convert the credentials to a token.
424
+ """
141
425
  import json
142
426
  import os
143
427
  from pathlib import Path
@@ -161,27 +445,85 @@ class Akeyless(secrets.Secrets):
161
445
  return response.json()["token"]
162
446
 
163
447
  def auth_jwt(self):
448
+ """
449
+ Authenticate using JWT.
450
+
451
+ Retrieves the JWT from the environment variable specified by jwt_env_key and authenticates
452
+ with Akeyless. Raises ValueError if jwt_env_key is not specified.
453
+ """
164
454
  if not self.jwt_env_key:
165
455
  raise ValueError(
166
456
  "To use AKeyless JWT Auth, "
167
457
  "you must specify the name of the ENV key to load the JWT from when configuring AKeyless"
168
458
  )
169
- res = self._api.auth(
459
+ res = self.api.auth(
170
460
  self.akeyless.Auth(access_id=self.access_id, access_type="jwt", jwt=self.environment.get(self.jwt_env_key))
171
461
  )
172
- return res.token
462
+ return res.token # type: ignore
463
+
464
+ def describe_permissions(self, path: str, type: str = "item") -> list[str]:
465
+ """
466
+ List permissions for a path.
467
+
468
+ Returns a list of permission strings (e.g., "read", "write", "list") that the current
469
+ authentication token has for the specified path.
470
+ """
471
+ return self.api.describe_permissions(
472
+ self.akeyless.DescribePermissions(token=self._get_token(), path=path, type=type)
473
+ ).client_permissions # type: ignore
474
+
475
+ def _get_nested_attribute(self, data: dict[str, Any] | str, attr_path: str) -> Any:
476
+ """
477
+ Extract a nested attribute from JSON data.
478
+
479
+ Parses the provided data as JSON if it's a string. Traverses the nested structure using
480
+ the dot-separated path (e.g., "database.username"). Raises ValueError if the data cannot
481
+ be parsed as JSON, or KeyError if the attribute path doesn't exist in the data.
482
+ """
483
+ keys = attr_path.split(".", 1)
484
+ if not isinstance(data, dict):
485
+ try:
486
+ data = json.loads(data)
487
+ except Exception:
488
+ raise ValueError(f"Could not parse secret as JSON to get attribute '{attr_path}'")
489
+ if len(keys) == 1:
490
+ if not isinstance(data, dict) or keys[0] not in data:
491
+ raise KeyError(f"Secret does not contain attribute '{attr_path}'")
492
+ return data[keys[0]] # type: ignore
493
+ return self._get_nested_attribute(data[keys[0]], keys[1]) # type: ignore
173
494
 
174
495
 
175
496
  class AkeylessSaml(Akeyless):
497
+ """Convenience class for SAML authentication with Akeyless."""
498
+
176
499
  def __init__(self, access_id: str, api_host: str = "", profile: str = ""):
500
+ """
501
+ Initialize with SAML authentication.
502
+
503
+ Sets access_type to "saml" and passes the remaining parameters to the parent class.
504
+ """
177
505
  return super().__init__(access_id, "saml", api_host=api_host, profile=profile)
178
506
 
179
507
 
180
508
  class AkeylessJwt(Akeyless):
509
+ """Convenience class for JWT authentication with Akeyless."""
510
+
181
511
  def __init__(self, access_id: str, jwt_env_key: str = "", api_host: str = "", profile: str = ""):
512
+ """
513
+ Initialize with JWT authentication.
514
+
515
+ Sets access_type to "jwt" and passes the remaining parameters to the parent class.
516
+ """
182
517
  return super().__init__(access_id, "jwt", jwt_env_key=jwt_env_key, api_host=api_host, profile=profile)
183
518
 
184
519
 
185
520
  class AkeylessAwsIam(Akeyless):
521
+ """Convenience class for AWS IAM authentication with Akeyless."""
522
+
186
523
  def __init__(self, access_id: str, api_host: str = ""):
524
+ """
525
+ Initialize with AWS IAM authentication.
526
+
527
+ Sets access_type to "aws_iam" and passes the remaining parameters to the parent class.
528
+ """
187
529
  return super().__init__(access_id, "aws_iam", api_host=api_host)
@@ -1 +1,7 @@
1
- from .not_found import NotFound
1
+ from clearskies.secrets.exceptions.not_found_error import NotFoundError
2
+ from clearskies.secrets.exceptions.permissions_error import PermissionsError
3
+
4
+ __all__ = [
5
+ "NotFoundError",
6
+ "PermissionsError",
7
+ ]
@@ -0,0 +1,2 @@
1
+ class NotFoundError(Exception):
2
+ pass
@@ -0,0 +1,2 @@
1
+ class PermissionsError(Exception):
2
+ pass
@@ -18,11 +18,6 @@ class Secrets(ABC, clearskies.configurable.Configurable, InjectableProperties):
18
18
  "It looks like you tried to use the secret system in clearskies, but didn't specify a secret manager."
19
19
  )
20
20
 
21
- def get_dynamic_secret(self, path: str, args: dict[str, Any] | None = None) -> Any:
22
- raise NotImplementedError(
23
- "It looks like you tried to use the secret system in clearskies, but didn't specify a secret manager."
24
- )
25
-
26
21
  def list_secrets(self, path: str) -> list[Any]:
27
22
  raise NotImplementedError(
28
23
  "It looks like you tried to use the secret system in clearskies, but didn't specify a secret manager."
@@ -1,2 +0,0 @@
1
- class NotFound(Exception):
2
- pass