akeyless-agentcore-runtime 0.2.0__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.
@@ -0,0 +1,17 @@
1
+ """Fetch Akeyless secrets at runtime on AWS Bedrock AgentCore."""
2
+
3
+ from akeyless_agentcore.client import (
4
+ AkeylessRuntimeClient,
5
+ get_default_client,
6
+ get_secret,
7
+ get_secret_sync,
8
+ )
9
+
10
+ __all__ = [
11
+ "AkeylessRuntimeClient",
12
+ "get_default_client",
13
+ "get_secret",
14
+ "get_secret_sync",
15
+ ]
16
+
17
+ __version__ = "0.2.0"
@@ -0,0 +1,134 @@
1
+ """Authenticate to Akeyless using cloud identity or other configured methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from datetime import datetime, timezone
8
+
9
+ import akeyless
10
+ from akeyless_cloud_id import CloudId
11
+
12
+ from akeyless_agentcore.config import AkeylessRuntimeConfig
13
+
14
+ ACCESS_KEY_DEFAULT_TTL_SECONDS = 14 * 60
15
+ ENV_TOKEN_TTL_SECONDS = 50 * 60
16
+
17
+
18
+ @dataclass
19
+ class AuthSession:
20
+ token: str
21
+ expires_at: float
22
+
23
+
24
+ def _expiry_from_auth_output(auth_out: akeyless.AuthOutput, margin_seconds: float) -> float:
25
+ now = time.monotonic()
26
+ expiration = getattr(auth_out, "expiration", None)
27
+ if not expiration:
28
+ return now + ACCESS_KEY_DEFAULT_TTL_SECONDS - margin_seconds
29
+
30
+ raw = str(expiration).strip()
31
+ if not raw:
32
+ return now + ACCESS_KEY_DEFAULT_TTL_SECONDS - margin_seconds
33
+
34
+ try:
35
+ as_num = float(raw)
36
+ if as_num > 1e12:
37
+ # Epoch milliseconds
38
+ return (as_num / 1000.0) - time.time() + now - margin_seconds
39
+ except ValueError:
40
+ pass
41
+
42
+ try:
43
+ as_date = datetime.fromisoformat(raw.replace("Z", "+00:00"))
44
+ if as_date.tzinfo is None:
45
+ as_date = as_date.replace(tzinfo=timezone.utc)
46
+ return as_date.timestamp() - time.time() + now - margin_seconds
47
+ except ValueError:
48
+ pass
49
+
50
+ return now + ACCESS_KEY_DEFAULT_TTL_SECONDS - margin_seconds
51
+
52
+
53
+ def _get_cloud_id(config: AkeylessRuntimeConfig) -> str:
54
+ if config.cloud_id and config.cloud_id.strip():
55
+ return config.cloud_id.strip()
56
+
57
+ provider = config.cloud_provider or config.access_type
58
+ if provider not in ("aws_iam", "azure_ad", "gcp"):
59
+ raise ValueError(f"Cannot generate cloud ID for access type {provider}")
60
+
61
+ cloud_id = CloudId().generate()
62
+ if not cloud_id or not str(cloud_id).strip():
63
+ raise ValueError(
64
+ f"akeyless-cloud-id returned an empty cloud ID for provider {provider!r}. "
65
+ "On AgentCore Runtime, ensure the execution role has ambient AWS credentials "
66
+ "or pass cloud_id explicitly."
67
+ )
68
+ return str(cloud_id).strip()
69
+
70
+
71
+ def _sdk_host(gateway_url: str) -> str:
72
+ host = gateway_url.rstrip("/")
73
+ if host.endswith("/api/v2"):
74
+ if "api.akeyless.io" in host:
75
+ return host[: -len("/api/v2")]
76
+ return host
77
+ if host.endswith("/v2"):
78
+ return host
79
+ if "api.akeyless.io" in host:
80
+ return host
81
+ return f"{host}/api/v2"
82
+
83
+
84
+ def create_v2_api(gateway_url: str) -> akeyless.V2Api:
85
+ configuration = akeyless.Configuration(host=_sdk_host(gateway_url))
86
+ return akeyless.V2Api(akeyless.ApiClient(configuration))
87
+
88
+
89
+ def authenticate(api: akeyless.V2Api, config: AkeylessRuntimeConfig) -> AuthSession:
90
+ now = time.monotonic()
91
+
92
+ if config.token and config.token.strip():
93
+ return AuthSession(
94
+ token=config.token.strip(),
95
+ expires_at=now + ENV_TOKEN_TTL_SECONDS,
96
+ )
97
+
98
+ access_type = config.access_type
99
+
100
+ if access_type in ("access_key", "api_key"):
101
+ body = akeyless.Auth(
102
+ access_id=config.access_id,
103
+ access_type=access_type,
104
+ access_key=config.access_key,
105
+ )
106
+ elif access_type == "universal_identity":
107
+ body = akeyless.Auth(
108
+ access_type="universal_identity",
109
+ uid_token=config.uid_token,
110
+ )
111
+ elif access_type == "jwt":
112
+ body = akeyless.Auth(
113
+ access_id=config.access_id,
114
+ access_type="jwt",
115
+ jwt=config.jwt,
116
+ )
117
+ elif access_type in ("aws_iam", "azure_ad", "gcp"):
118
+ body = akeyless.Auth(
119
+ access_id=config.access_id,
120
+ access_type=access_type,
121
+ cloud_id=_get_cloud_id(config),
122
+ )
123
+ else:
124
+ raise ValueError(f"Unsupported access type: {access_type}")
125
+
126
+ auth_out = api.auth(body)
127
+ token = getattr(auth_out, "token", None)
128
+ if not token or not str(token).strip():
129
+ raise ValueError("Akeyless authentication did not return a token")
130
+
131
+ return AuthSession(
132
+ token=str(token).strip(),
133
+ expires_at=_expiry_from_auth_output(auth_out, config.token_expiry_margin_seconds),
134
+ )
@@ -0,0 +1,40 @@
1
+ """In-memory TTL cache for secret values and auth tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass
13
+ class _CacheEntry(Generic[T]):
14
+ value: T
15
+ expires_at: float
16
+
17
+
18
+ class TtlCache(Generic[T]):
19
+ def __init__(self) -> None:
20
+ self._entries: dict[str, _CacheEntry[T]] = {}
21
+
22
+ def get(self, key: str, now: float | None = None) -> T | None:
23
+ now = time.monotonic() if now is None else now
24
+ entry = self._entries.get(key)
25
+ if entry is None:
26
+ return None
27
+ if now >= entry.expires_at:
28
+ del self._entries[key]
29
+ return None
30
+ return entry.value
31
+
32
+ def set(self, key: str, value: T, ttl_seconds: float, now: float | None = None) -> None:
33
+ now = time.monotonic() if now is None else now
34
+ self._entries[key] = _CacheEntry(value=value, expires_at=now + ttl_seconds)
35
+
36
+ def delete(self, key: str) -> None:
37
+ self._entries.pop(key, None)
38
+
39
+ def clear(self) -> None:
40
+ self._entries.clear()
@@ -0,0 +1,363 @@
1
+ """Akeyless runtime client for Bedrock AgentCore agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import akeyless
11
+
12
+ from akeyless_agentcore.auth import AuthSession, authenticate, create_v2_api
13
+ from akeyless_agentcore.cache import TtlCache
14
+ from akeyless_agentcore.config import AkeylessRuntimeConfig, config_from_env, validate_config
15
+ from akeyless_agentcore.paths import (
16
+ format_structured_response,
17
+ join_secret_path,
18
+ normalize_path,
19
+ pick_secret_from_response,
20
+ )
21
+
22
+
23
+ def _is_not_found_error(error: Exception) -> bool:
24
+ status = getattr(error, "status", None)
25
+ if status == 404:
26
+ return True
27
+ message = str(error).lower()
28
+ return "not found" in message or "404" in message
29
+
30
+
31
+ @dataclass
32
+ class GetSecretOptions:
33
+ path: str | None = None
34
+ version: int | None = None
35
+ json: bool = False
36
+ ignore_cache: bool = False
37
+ allow_dynamic_fallback: bool = False
38
+
39
+
40
+ @dataclass
41
+ class DynamicSecretOptions:
42
+ path: str | None = None
43
+ timeout: int | None = None
44
+ args: list[str] | None = None
45
+ host: str | None = None
46
+ dbname: str | None = None
47
+ target: str | None = None
48
+ json: bool = False
49
+ ignore_cache: bool = False
50
+
51
+
52
+ @dataclass
53
+ class RotatedSecretOptions:
54
+ path: str | None = None
55
+ host: str | None = None
56
+ version: int | None = None
57
+ json: bool = False
58
+ ignore_cache: bool = False
59
+
60
+
61
+ class AkeylessRuntimeClient:
62
+ def __init__(self, **config_overrides: Any) -> None:
63
+ self._config = config_from_env(**config_overrides)
64
+ validate_config(self._config)
65
+ self._api = create_v2_api(self._config.gateway_url)
66
+ self._session: AuthSession | None = None
67
+ self._secret_cache: TtlCache[str] = TtlCache()
68
+
69
+ @classmethod
70
+ def from_env(cls, **overrides: Any) -> AkeylessRuntimeClient:
71
+ return cls(**overrides)
72
+
73
+ @property
74
+ def config(self) -> AkeylessRuntimeConfig:
75
+ return self._config
76
+
77
+ def resolve_path(self, name_or_path: str) -> str:
78
+ trimmed = name_or_path.strip()
79
+ if trimmed.startswith("/"):
80
+ return normalize_path(trimmed)
81
+ return join_secret_path(self._config.secret_prefix, trimmed)
82
+
83
+ def clear_cache(self) -> None:
84
+ self._secret_cache.clear()
85
+ self._session = None
86
+
87
+ def _cache_key(self, path: str, kind: str, extra: str = "") -> str:
88
+ return f"{kind}:{path}:{extra}"
89
+
90
+ def _read_cached(self, path: str, kind: str, ignore_cache: bool) -> str | None:
91
+ if ignore_cache:
92
+ return None
93
+ return self._secret_cache.get(self._cache_key(path, kind))
94
+
95
+ def _write_cached(self, path: str, kind: str, value: str) -> None:
96
+ self._secret_cache.set(
97
+ self._cache_key(path, kind),
98
+ value,
99
+ self._config.secret_cache_ttl_seconds,
100
+ )
101
+
102
+ def _get_token(self) -> str:
103
+ now = time.monotonic()
104
+ if self._session and now < self._session.expires_at:
105
+ return self._session.token
106
+
107
+ self._session = authenticate(self._api, self._config)
108
+ return self._session.token
109
+
110
+ async def get_secret_at_path(
111
+ self,
112
+ path: str,
113
+ *,
114
+ version: int | None = None,
115
+ json: bool = False,
116
+ ignore_cache: bool = False,
117
+ allow_dynamic_fallback: bool = False,
118
+ ) -> str:
119
+ return self.get_secret_at_path_sync(
120
+ path,
121
+ version=version,
122
+ json=json,
123
+ ignore_cache=ignore_cache,
124
+ allow_dynamic_fallback=allow_dynamic_fallback,
125
+ )
126
+
127
+ def get_secret_at_path_sync(
128
+ self,
129
+ path: str,
130
+ *,
131
+ version: int | None = None,
132
+ json: bool = False,
133
+ ignore_cache: bool = False,
134
+ allow_dynamic_fallback: bool = False,
135
+ ) -> str:
136
+ resolved = normalize_path(path)
137
+ cached = self._read_cached(resolved, "static", ignore_cache)
138
+ if cached is not None:
139
+ return cached
140
+
141
+ token = self._get_token()
142
+ body_kwargs: dict[str, Any] = {"names": [resolved], "token": token}
143
+ if version is not None:
144
+ body_kwargs["version"] = version
145
+ if json:
146
+ body_kwargs["json"] = True
147
+ if ignore_cache:
148
+ body_kwargs["ignore_cache"] = "true"
149
+
150
+ try:
151
+ raw = self._api.get_secret_value(akeyless.GetSecretValue(**body_kwargs))
152
+ if isinstance(raw, dict):
153
+ value = pick_secret_from_response(resolved, raw)
154
+ else:
155
+ value = format_structured_response(raw)
156
+ self._write_cached(resolved, "static", value)
157
+ return value
158
+ except Exception as error:
159
+ if allow_dynamic_fallback and _is_not_found_error(error):
160
+ return self.get_dynamic_secret_at_path_sync(
161
+ resolved,
162
+ ignore_cache=ignore_cache,
163
+ )
164
+ raise
165
+
166
+ async def get_secret(
167
+ self,
168
+ name: str,
169
+ options: GetSecretOptions | None = None,
170
+ ) -> str:
171
+ return self.get_secret_sync(name, options)
172
+
173
+ def get_secret_sync(
174
+ self,
175
+ name: str,
176
+ options: GetSecretOptions | None = None,
177
+ ) -> str:
178
+ opts = options or GetSecretOptions()
179
+ path = opts.path or self.resolve_path(name)
180
+ return self.get_secret_at_path_sync(
181
+ path,
182
+ version=opts.version,
183
+ json=opts.json,
184
+ ignore_cache=opts.ignore_cache,
185
+ allow_dynamic_fallback=opts.allow_dynamic_fallback,
186
+ )
187
+
188
+ def get_secret_json_sync(
189
+ self,
190
+ name: str,
191
+ options: GetSecretOptions | None = None,
192
+ ) -> dict[str, Any]:
193
+ raw = self.get_secret_sync(name, options)
194
+ data = json.loads(raw)
195
+ if not isinstance(data, dict):
196
+ raise ValueError(f"Secret {name!r} is not a JSON object")
197
+ return data
198
+
199
+ async def get_dynamic_secret_at_path(
200
+ self,
201
+ path: str,
202
+ options: DynamicSecretOptions | None = None,
203
+ ) -> str:
204
+ return self.get_dynamic_secret_at_path_sync(path, options)
205
+
206
+ def get_dynamic_secret_at_path_sync(
207
+ self,
208
+ path: str,
209
+ options: DynamicSecretOptions | None = None,
210
+ ) -> str:
211
+ opts = options or DynamicSecretOptions()
212
+ resolved = normalize_path(path)
213
+ should_cache = not any(
214
+ [
215
+ opts.args,
216
+ opts.timeout is not None,
217
+ opts.host,
218
+ opts.dbname,
219
+ opts.target,
220
+ ]
221
+ )
222
+
223
+ if should_cache:
224
+ cached = self._read_cached(resolved, "dynamic", opts.ignore_cache)
225
+ if cached is not None:
226
+ return cached
227
+
228
+ token = self._get_token()
229
+ body_kwargs: dict[str, Any] = {"name": resolved, "token": token}
230
+ if opts.timeout is not None:
231
+ body_kwargs["timeout"] = opts.timeout
232
+ if opts.args:
233
+ body_kwargs["args"] = opts.args
234
+ if opts.host:
235
+ body_kwargs["host"] = opts.host
236
+ if opts.dbname:
237
+ body_kwargs["dbname"] = opts.dbname
238
+ if opts.target:
239
+ body_kwargs["target"] = opts.target
240
+ if opts.json:
241
+ body_kwargs["json"] = True
242
+ if opts.ignore_cache:
243
+ body_kwargs["ignore_cache"] = "true"
244
+
245
+ raw = self._api.get_dynamic_secret_value(akeyless.GetDynamicSecretValue(**body_kwargs))
246
+ value = format_structured_response(raw)
247
+
248
+ if should_cache:
249
+ self._write_cached(resolved, "dynamic", value)
250
+ return value
251
+
252
+ async def get_dynamic_secret(
253
+ self,
254
+ name: str,
255
+ options: DynamicSecretOptions | None = None,
256
+ ) -> str:
257
+ return self.get_dynamic_secret_sync(name, options)
258
+
259
+ def get_dynamic_secret_sync(
260
+ self,
261
+ name: str,
262
+ options: DynamicSecretOptions | None = None,
263
+ ) -> str:
264
+ opts = options or DynamicSecretOptions()
265
+ path = opts.path or self.resolve_path(name)
266
+ return self.get_dynamic_secret_at_path_sync(path, opts)
267
+
268
+ async def get_rotated_secret_at_path(
269
+ self,
270
+ path: str,
271
+ options: RotatedSecretOptions | None = None,
272
+ ) -> str:
273
+ return self.get_rotated_secret_at_path_sync(path, options)
274
+
275
+ def get_rotated_secret_at_path_sync(
276
+ self,
277
+ path: str,
278
+ options: RotatedSecretOptions | None = None,
279
+ ) -> str:
280
+ opts = options or RotatedSecretOptions()
281
+ resolved = normalize_path(path)
282
+ cached = self._read_cached(resolved, "rotated", opts.ignore_cache)
283
+ if cached is not None:
284
+ return cached
285
+
286
+ token = self._get_token()
287
+ body_kwargs: dict[str, Any] = {"names": resolved, "token": token}
288
+ if opts.host:
289
+ body_kwargs["host"] = opts.host
290
+ if opts.version is not None:
291
+ body_kwargs["version"] = opts.version
292
+ if opts.json:
293
+ body_kwargs["json"] = True
294
+ if opts.ignore_cache:
295
+ body_kwargs["ignore_cache"] = "true"
296
+
297
+ raw = self._api.get_rotated_secret_value(akeyless.GetRotatedSecretValue(**body_kwargs))
298
+ value = format_structured_response(raw)
299
+ self._write_cached(resolved, "rotated", value)
300
+ return value
301
+
302
+ async def get_rotated_secret(
303
+ self,
304
+ name: str,
305
+ options: RotatedSecretOptions | None = None,
306
+ ) -> str:
307
+ return self.get_rotated_secret_sync(name, options)
308
+
309
+ def get_rotated_secret_sync(
310
+ self,
311
+ name: str,
312
+ options: RotatedSecretOptions | None = None,
313
+ ) -> str:
314
+ opts = options or RotatedSecretOptions()
315
+ path = opts.path or self.resolve_path(name)
316
+ return self.get_rotated_secret_at_path_sync(path, opts)
317
+
318
+ def list_secrets_sync(self, prefix: str | None = None) -> list[str]:
319
+ """List secret names under a prefix. Returns paths only, never values."""
320
+ resolved = normalize_path(prefix or self._config.secret_prefix)
321
+ token = self._get_token()
322
+ result = self._api.list_items(
323
+ akeyless.ListItems(
324
+ path=resolved,
325
+ token=token,
326
+ minimal_view=True,
327
+ auto_pagination="enabled",
328
+ )
329
+ )
330
+
331
+ items = getattr(result, "items", None) or []
332
+ names: list[str] = []
333
+ for item in items:
334
+ if isinstance(item, dict):
335
+ name = item.get("item_name")
336
+ else:
337
+ name = getattr(item, "item_name", None)
338
+ if name:
339
+ names.append(str(name))
340
+ return sorted(names)
341
+
342
+
343
+ _default_client: AkeylessRuntimeClient | None = None
344
+
345
+
346
+ def get_default_client(**overrides: Any) -> AkeylessRuntimeClient:
347
+ global _default_client
348
+ if _default_client is None:
349
+ _default_client = AkeylessRuntimeClient(**overrides)
350
+ return _default_client
351
+
352
+
353
+ def reset_default_client() -> None:
354
+ global _default_client
355
+ _default_client = None
356
+
357
+
358
+ async def get_secret(name: str, options: GetSecretOptions | None = None) -> str:
359
+ return await get_default_client().get_secret(name, options)
360
+
361
+
362
+ def get_secret_sync(name: str, options: GetSecretOptions | None = None) -> str:
363
+ return get_default_client().get_secret_sync(name, options)