kstlib 0.0.1a0__py3-none-any.whl → 1.0.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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"""Credential resolver for RAPI module.
|
|
2
|
+
|
|
3
|
+
This module provides multi-source credential resolution for REST API calls.
|
|
4
|
+
Supports environment variables, files (JSON/YAML), SOPS-encrypted files,
|
|
5
|
+
and kstlib.auth providers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from kstlib.rapi.exceptions import CredentialError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Mapping
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Pattern for jq-like path extraction: .foo.bar[0].baz or .foo["key-with-dash"]
|
|
26
|
+
# Supports: .key, [0], ["quoted-key"], ['quoted-key']
|
|
27
|
+
_JQ_PATH_PATTERN = re.compile(r'\.?([a-zA-Z_][a-zA-Z0-9_]*|\[\d+\]|\["[^"]+"\]|\[\'[^\']+\'\])')
|
|
28
|
+
|
|
29
|
+
# Deep defense limits for fields mapping
|
|
30
|
+
_MAX_FIELDS = 20 # Max number of fields in a mapping
|
|
31
|
+
_MAX_FIELD_NAME_LENGTH = 64 # Max characters for field name
|
|
32
|
+
_MAX_FIELD_VALUE_SIZE = 10 * 1024 # 10KB max per field value
|
|
33
|
+
_FIELD_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class CredentialRecord:
|
|
38
|
+
"""Resolved credential with metadata.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
value: Primary credential value (token, API key).
|
|
42
|
+
secret: Secondary credential value (API secret for signing).
|
|
43
|
+
source: Source type that provided this credential.
|
|
44
|
+
expires_at: Expiration timestamp (if known).
|
|
45
|
+
extras: Additional credential fields (passphrase, etc.).
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
>>> record = CredentialRecord(value="token123", source="env")
|
|
49
|
+
>>> record.value
|
|
50
|
+
'token123'
|
|
51
|
+
>>> record = CredentialRecord(
|
|
52
|
+
... value="key", secret="secret", source="sops",
|
|
53
|
+
... extras={"passphrase": "pass123"}
|
|
54
|
+
... )
|
|
55
|
+
>>> record.extras.get("passphrase")
|
|
56
|
+
'pass123'
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
value: str
|
|
60
|
+
secret: str | None = None
|
|
61
|
+
source: str = "unknown"
|
|
62
|
+
expires_at: float | None = None
|
|
63
|
+
extras: dict[str, str] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _validate_field_name(name: str, credential_name: str) -> None:
|
|
67
|
+
"""Validate a field name for security.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: Field name to validate.
|
|
71
|
+
credential_name: Credential name for error messages.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
CredentialError: If field name is invalid.
|
|
75
|
+
"""
|
|
76
|
+
if not name:
|
|
77
|
+
raise CredentialError(credential_name, "Empty field name in fields mapping")
|
|
78
|
+
|
|
79
|
+
if len(name) > _MAX_FIELD_NAME_LENGTH:
|
|
80
|
+
raise CredentialError(
|
|
81
|
+
credential_name,
|
|
82
|
+
f"Field name '{name[:20]}...' exceeds max length ({_MAX_FIELD_NAME_LENGTH})",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not _FIELD_NAME_PATTERN.match(name):
|
|
86
|
+
raise CredentialError(
|
|
87
|
+
credential_name,
|
|
88
|
+
f"Invalid field name '{name}': must be alphanumeric with underscores",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _validate_field_value(value: str, field_name: str, credential_name: str) -> None:
|
|
93
|
+
"""Validate a field value for security.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
value: Field value to validate.
|
|
97
|
+
field_name: Field name for error messages.
|
|
98
|
+
credential_name: Credential name for error messages.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
CredentialError: If field value is invalid.
|
|
102
|
+
"""
|
|
103
|
+
if len(value) > _MAX_FIELD_VALUE_SIZE:
|
|
104
|
+
raise CredentialError(
|
|
105
|
+
credential_name,
|
|
106
|
+
f"Field '{field_name}' value exceeds max size ({_MAX_FIELD_VALUE_SIZE} bytes)",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_fields_mapping(
|
|
111
|
+
fields: Mapping[str, str],
|
|
112
|
+
credential_name: str,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Validate entire fields mapping for security.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
fields: Fields mapping to validate.
|
|
118
|
+
credential_name: Credential name for error messages.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
CredentialError: If mapping is invalid.
|
|
122
|
+
"""
|
|
123
|
+
if len(fields) > _MAX_FIELDS:
|
|
124
|
+
raise CredentialError(
|
|
125
|
+
credential_name,
|
|
126
|
+
f"Too many fields in mapping ({len(fields)} > {_MAX_FIELDS})",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if "key" not in fields:
|
|
130
|
+
raise CredentialError(
|
|
131
|
+
credential_name,
|
|
132
|
+
"Missing required 'key' in fields mapping",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for name, source_field in fields.items():
|
|
136
|
+
_validate_field_name(name, credential_name)
|
|
137
|
+
_validate_field_name(source_field, credential_name)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CredentialResolver:
|
|
141
|
+
"""Resolve credentials from multiple sources.
|
|
142
|
+
|
|
143
|
+
Supported credential types:
|
|
144
|
+
- env: Environment variable
|
|
145
|
+
- file: JSON/YAML file with jq-like path extraction
|
|
146
|
+
- sops: SOPS-encrypted file
|
|
147
|
+
- provider: kstlib.auth provider (OAuth2/OIDC)
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
credentials_config: Credentials section from config.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
>>> resolver = CredentialResolver({"github": {"type": "env", "var": "GITHUB_TOKEN"}})
|
|
154
|
+
>>> record = resolver.resolve("github") # doctest: +SKIP
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, credentials_config: Mapping[str, Any] | None = None) -> None:
|
|
158
|
+
"""Initialize CredentialResolver.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
credentials_config: Credentials section from config.
|
|
162
|
+
"""
|
|
163
|
+
self._config = credentials_config or {}
|
|
164
|
+
self._cache: dict[str, CredentialRecord] = {}
|
|
165
|
+
|
|
166
|
+
def resolve(self, credential_name: str) -> CredentialRecord:
|
|
167
|
+
"""Resolve a credential by name.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
credential_name: Name of the credential in config.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
CredentialRecord with resolved value(s).
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
CredentialError: If credential cannot be resolved.
|
|
177
|
+
"""
|
|
178
|
+
log.debug("Resolving credential: %s", credential_name)
|
|
179
|
+
|
|
180
|
+
if credential_name in self._cache:
|
|
181
|
+
log.debug("Credential '%s' found in cache", credential_name)
|
|
182
|
+
return self._cache[credential_name]
|
|
183
|
+
|
|
184
|
+
if credential_name not in self._config:
|
|
185
|
+
raise CredentialError(credential_name, "Not found in credentials config")
|
|
186
|
+
|
|
187
|
+
cred_config = self._config[credential_name]
|
|
188
|
+
cred_type = cred_config.get("type", "env")
|
|
189
|
+
|
|
190
|
+
log.debug("Credential '%s' type: %s", credential_name, cred_type)
|
|
191
|
+
|
|
192
|
+
if cred_type == "env":
|
|
193
|
+
record = self._resolve_env(credential_name, cred_config)
|
|
194
|
+
elif cred_type == "file":
|
|
195
|
+
record = self._resolve_file(credential_name, cred_config)
|
|
196
|
+
elif cred_type == "sops":
|
|
197
|
+
record = self._resolve_sops(credential_name, cred_config)
|
|
198
|
+
elif cred_type == "provider":
|
|
199
|
+
record = self._resolve_provider(credential_name, cred_config)
|
|
200
|
+
else:
|
|
201
|
+
raise CredentialError(credential_name, f"Unknown credential type: {cred_type}")
|
|
202
|
+
|
|
203
|
+
self._cache[credential_name] = record
|
|
204
|
+
log.debug("Credential '%s' resolved from %s", credential_name, record.source)
|
|
205
|
+
return record
|
|
206
|
+
|
|
207
|
+
def _resolve_env(
|
|
208
|
+
self,
|
|
209
|
+
credential_name: str,
|
|
210
|
+
cred_config: Mapping[str, Any],
|
|
211
|
+
) -> CredentialRecord:
|
|
212
|
+
"""Resolve credential from environment variable.
|
|
213
|
+
|
|
214
|
+
Config format (new - generic fields mapping):
|
|
215
|
+
type: env
|
|
216
|
+
fields:
|
|
217
|
+
key: "API_KEY" # Required, maps to value
|
|
218
|
+
secret: "API_SECRET" # Optional, maps to secret
|
|
219
|
+
passphrase: "API_PASS" # Optional, maps to extras
|
|
220
|
+
|
|
221
|
+
Config format (legacy - still supported):
|
|
222
|
+
type: env
|
|
223
|
+
var: "GITHUB_TOKEN"
|
|
224
|
+
# Or for key+secret pair:
|
|
225
|
+
var_key: "API_KEY"
|
|
226
|
+
var_secret: "API_SECRET"
|
|
227
|
+
"""
|
|
228
|
+
# New format: fields mapping
|
|
229
|
+
fields = cred_config.get("fields")
|
|
230
|
+
if fields:
|
|
231
|
+
return self._resolve_env_fields(credential_name, fields)
|
|
232
|
+
|
|
233
|
+
# Legacy format: var or var_key/var_secret
|
|
234
|
+
var_name = cred_config.get("var")
|
|
235
|
+
var_key = cred_config.get("var_key")
|
|
236
|
+
var_secret = cred_config.get("var_secret")
|
|
237
|
+
|
|
238
|
+
if var_name:
|
|
239
|
+
value = os.environ.get(var_name)
|
|
240
|
+
if not value:
|
|
241
|
+
raise CredentialError(
|
|
242
|
+
credential_name,
|
|
243
|
+
f"Environment variable '{var_name}' not set",
|
|
244
|
+
)
|
|
245
|
+
_validate_field_value(value, "var", credential_name)
|
|
246
|
+
return CredentialRecord(value=value, source="env")
|
|
247
|
+
|
|
248
|
+
if var_key:
|
|
249
|
+
key_value = os.environ.get(var_key)
|
|
250
|
+
if not key_value:
|
|
251
|
+
raise CredentialError(
|
|
252
|
+
credential_name,
|
|
253
|
+
f"Environment variable '{var_key}' not set",
|
|
254
|
+
)
|
|
255
|
+
_validate_field_value(key_value, "var_key", credential_name)
|
|
256
|
+
secret_value = None
|
|
257
|
+
if var_secret:
|
|
258
|
+
secret_value = os.environ.get(var_secret)
|
|
259
|
+
if not secret_value:
|
|
260
|
+
raise CredentialError(
|
|
261
|
+
credential_name,
|
|
262
|
+
f"Environment variable '{var_secret}' not set",
|
|
263
|
+
)
|
|
264
|
+
_validate_field_value(secret_value, "var_secret", credential_name)
|
|
265
|
+
return CredentialRecord(value=key_value, secret=secret_value, source="env")
|
|
266
|
+
|
|
267
|
+
raise CredentialError(credential_name, "Missing 'var', 'var_key', or 'fields' in env config")
|
|
268
|
+
|
|
269
|
+
def _resolve_env_fields(
|
|
270
|
+
self,
|
|
271
|
+
credential_name: str,
|
|
272
|
+
fields: Mapping[str, str],
|
|
273
|
+
) -> CredentialRecord:
|
|
274
|
+
"""Resolve credentials from environment using fields mapping.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
credential_name: Credential name for error messages.
|
|
278
|
+
fields: Mapping of logical names to env var names.
|
|
279
|
+
key -> value, secret -> secret, others -> extras
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
CredentialRecord with resolved values.
|
|
283
|
+
"""
|
|
284
|
+
_validate_fields_mapping(fields, credential_name)
|
|
285
|
+
|
|
286
|
+
# Resolve key (required)
|
|
287
|
+
key_env_var = fields["key"]
|
|
288
|
+
key_value = os.environ.get(key_env_var)
|
|
289
|
+
if not key_value:
|
|
290
|
+
raise CredentialError(
|
|
291
|
+
credential_name,
|
|
292
|
+
f"Environment variable '{key_env_var}' not set (fields.key)",
|
|
293
|
+
)
|
|
294
|
+
_validate_field_value(key_value, "key", credential_name)
|
|
295
|
+
|
|
296
|
+
# Resolve secret (optional)
|
|
297
|
+
secret_value: str | None = None
|
|
298
|
+
if "secret" in fields:
|
|
299
|
+
secret_env_var = fields["secret"]
|
|
300
|
+
secret_value = os.environ.get(secret_env_var)
|
|
301
|
+
if not secret_value:
|
|
302
|
+
raise CredentialError(
|
|
303
|
+
credential_name,
|
|
304
|
+
f"Environment variable '{secret_env_var}' not set (fields.secret)",
|
|
305
|
+
)
|
|
306
|
+
_validate_field_value(secret_value, "secret", credential_name)
|
|
307
|
+
|
|
308
|
+
# Resolve extras (all other fields)
|
|
309
|
+
extras: dict[str, str] = {}
|
|
310
|
+
for field_name, env_var in fields.items():
|
|
311
|
+
if field_name in ("key", "secret"):
|
|
312
|
+
continue
|
|
313
|
+
env_value = os.environ.get(env_var)
|
|
314
|
+
if not env_value:
|
|
315
|
+
raise CredentialError(
|
|
316
|
+
credential_name,
|
|
317
|
+
f"Environment variable '{env_var}' not set (fields.{field_name})",
|
|
318
|
+
)
|
|
319
|
+
_validate_field_value(env_value, field_name, credential_name)
|
|
320
|
+
extras[field_name] = env_value
|
|
321
|
+
|
|
322
|
+
return CredentialRecord(
|
|
323
|
+
value=key_value,
|
|
324
|
+
secret=secret_value,
|
|
325
|
+
source="env",
|
|
326
|
+
extras=extras,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def _load_file_data(self, credential_name: str, file_path: str) -> Any:
|
|
330
|
+
"""Load and parse JSON/YAML file data."""
|
|
331
|
+
path = Path(file_path).expanduser()
|
|
332
|
+
if not path.exists():
|
|
333
|
+
raise CredentialError(credential_name, f"File not found: {path}")
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
content = path.read_text(encoding="utf-8")
|
|
337
|
+
if path.suffix in (".yaml", ".yml"):
|
|
338
|
+
import yaml
|
|
339
|
+
|
|
340
|
+
return yaml.safe_load(content)
|
|
341
|
+
return json.loads(content)
|
|
342
|
+
except Exception as e:
|
|
343
|
+
raise CredentialError(credential_name, f"Failed to read file: {e}") from e
|
|
344
|
+
|
|
345
|
+
def _extract_key_secret(
|
|
346
|
+
self,
|
|
347
|
+
credential_name: str,
|
|
348
|
+
data: Any,
|
|
349
|
+
key_field: str,
|
|
350
|
+
secret_field: str | None,
|
|
351
|
+
) -> CredentialRecord:
|
|
352
|
+
"""Extract key and optional secret from data."""
|
|
353
|
+
key_value = self.extract_value(data, f".{key_field}")
|
|
354
|
+
if key_value is None:
|
|
355
|
+
raise CredentialError(credential_name, f"Field '{key_field}' not found in file")
|
|
356
|
+
|
|
357
|
+
secret_value = None
|
|
358
|
+
if secret_field:
|
|
359
|
+
secret_value = self.extract_value(data, f".{secret_field}")
|
|
360
|
+
if secret_value is None:
|
|
361
|
+
raise CredentialError(credential_name, f"Field '{secret_field}' not found in file")
|
|
362
|
+
secret_value = str(secret_value)
|
|
363
|
+
|
|
364
|
+
return CredentialRecord(value=str(key_value), secret=secret_value, source="file")
|
|
365
|
+
|
|
366
|
+
def _extract_expires_at(
|
|
367
|
+
self,
|
|
368
|
+
data: Any,
|
|
369
|
+
cred_config: Mapping[str, Any],
|
|
370
|
+
) -> float | None:
|
|
371
|
+
"""Extract expires_at timestamp from data if configured.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
data: Parsed file data.
|
|
375
|
+
cred_config: Credential configuration.
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Timestamp as float, or None if not configured/found.
|
|
379
|
+
"""
|
|
380
|
+
expires_at_path = cred_config.get("expires_at_path")
|
|
381
|
+
if not expires_at_path:
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
expires_at = self.extract_value(data, expires_at_path)
|
|
385
|
+
if expires_at is None:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
return self._parse_expires_at(expires_at)
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _parse_expires_at(expires_at: Any) -> float | None:
|
|
392
|
+
"""Parse expires_at value to timestamp.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
expires_at: Raw expires_at value (int, float, or ISO string).
|
|
396
|
+
|
|
397
|
+
Returns:
|
|
398
|
+
Timestamp as float, or None if unparseable.
|
|
399
|
+
"""
|
|
400
|
+
if isinstance(expires_at, int | float):
|
|
401
|
+
return float(expires_at)
|
|
402
|
+
|
|
403
|
+
if isinstance(expires_at, str):
|
|
404
|
+
# Try ISO format first
|
|
405
|
+
try:
|
|
406
|
+
from datetime import datetime
|
|
407
|
+
|
|
408
|
+
dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
409
|
+
return dt.timestamp()
|
|
410
|
+
except ValueError:
|
|
411
|
+
# Try as numeric string
|
|
412
|
+
try:
|
|
413
|
+
return float(expires_at)
|
|
414
|
+
except ValueError:
|
|
415
|
+
pass
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
def _resolve_fields_from_data(
|
|
419
|
+
self,
|
|
420
|
+
credential_name: str,
|
|
421
|
+
data: Any,
|
|
422
|
+
fields: Mapping[str, str],
|
|
423
|
+
source: str,
|
|
424
|
+
expires_at: float | None = None,
|
|
425
|
+
) -> CredentialRecord:
|
|
426
|
+
"""Resolve credentials from data using fields mapping.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
credential_name: Credential name for error messages.
|
|
430
|
+
data: Parsed data (dict from file/SOPS).
|
|
431
|
+
fields: Mapping of logical names to field names in data.
|
|
432
|
+
source: Source identifier (file, sops).
|
|
433
|
+
expires_at: Optional expiration timestamp.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
CredentialRecord with resolved values.
|
|
437
|
+
"""
|
|
438
|
+
_validate_fields_mapping(fields, credential_name)
|
|
439
|
+
|
|
440
|
+
# Resolve key (required)
|
|
441
|
+
key_field = fields["key"]
|
|
442
|
+
key_value = self.extract_value(data, f".{key_field}")
|
|
443
|
+
if key_value is None:
|
|
444
|
+
raise CredentialError(
|
|
445
|
+
credential_name,
|
|
446
|
+
f"Field '{key_field}' not found in {source} (fields.key)",
|
|
447
|
+
)
|
|
448
|
+
key_str = str(key_value)
|
|
449
|
+
_validate_field_value(key_str, "key", credential_name)
|
|
450
|
+
|
|
451
|
+
# Resolve secret (optional)
|
|
452
|
+
secret_value: str | None = None
|
|
453
|
+
if "secret" in fields:
|
|
454
|
+
secret_field = fields["secret"]
|
|
455
|
+
secret_raw = self.extract_value(data, f".{secret_field}")
|
|
456
|
+
if secret_raw is None:
|
|
457
|
+
raise CredentialError(
|
|
458
|
+
credential_name,
|
|
459
|
+
f"Field '{secret_field}' not found in {source} (fields.secret)",
|
|
460
|
+
)
|
|
461
|
+
secret_value = str(secret_raw)
|
|
462
|
+
_validate_field_value(secret_value, "secret", credential_name)
|
|
463
|
+
|
|
464
|
+
# Resolve extras (all other fields)
|
|
465
|
+
extras: dict[str, str] = {}
|
|
466
|
+
for field_name, data_field in fields.items():
|
|
467
|
+
if field_name in ("key", "secret"):
|
|
468
|
+
continue
|
|
469
|
+
field_value = self.extract_value(data, f".{data_field}")
|
|
470
|
+
if field_value is None:
|
|
471
|
+
raise CredentialError(
|
|
472
|
+
credential_name,
|
|
473
|
+
f"Field '{data_field}' not found in {source} (fields.{field_name})",
|
|
474
|
+
)
|
|
475
|
+
extra_str = str(field_value)
|
|
476
|
+
_validate_field_value(extra_str, field_name, credential_name)
|
|
477
|
+
extras[field_name] = extra_str
|
|
478
|
+
|
|
479
|
+
return CredentialRecord(
|
|
480
|
+
value=key_str,
|
|
481
|
+
secret=secret_value,
|
|
482
|
+
source=source,
|
|
483
|
+
expires_at=expires_at,
|
|
484
|
+
extras=extras,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _resolve_file(
|
|
488
|
+
self,
|
|
489
|
+
credential_name: str,
|
|
490
|
+
cred_config: Mapping[str, Any],
|
|
491
|
+
) -> CredentialRecord:
|
|
492
|
+
"""Resolve credential from JSON/YAML file with jq-like extraction.
|
|
493
|
+
|
|
494
|
+
Config format (new - generic fields mapping):
|
|
495
|
+
type: file
|
|
496
|
+
path: "~/.config/credentials.json"
|
|
497
|
+
fields:
|
|
498
|
+
key: "api_key" # Required, maps to value
|
|
499
|
+
secret: "api_secret" # Optional, maps to secret
|
|
500
|
+
passphrase: "pass" # Optional, maps to extras
|
|
501
|
+
|
|
502
|
+
Config format (legacy - still supported):
|
|
503
|
+
type: file
|
|
504
|
+
path: "~/.config/credentials.json"
|
|
505
|
+
token_path: ".access_token" # jq-like path
|
|
506
|
+
expires_at_path: ".expires_at" # Optional, for OAuth2 tokens
|
|
507
|
+
# Or for key+secret:
|
|
508
|
+
key_field: "api_key"
|
|
509
|
+
secret_field: "api_secret"
|
|
510
|
+
"""
|
|
511
|
+
file_path = cred_config.get("path")
|
|
512
|
+
if not file_path:
|
|
513
|
+
raise CredentialError(credential_name, "Missing 'path' in file config")
|
|
514
|
+
|
|
515
|
+
data = self._load_file_data(credential_name, file_path)
|
|
516
|
+
|
|
517
|
+
# Extract expiration if configured
|
|
518
|
+
expires_at = self._extract_expires_at(data, cred_config)
|
|
519
|
+
|
|
520
|
+
# New format: fields mapping
|
|
521
|
+
fields = cred_config.get("fields")
|
|
522
|
+
if fields:
|
|
523
|
+
return self._resolve_fields_from_data(credential_name, data, fields, "file", expires_at)
|
|
524
|
+
|
|
525
|
+
# Legacy format: token_path or key_field
|
|
526
|
+
token_path = cred_config.get("token_path")
|
|
527
|
+
if token_path:
|
|
528
|
+
value = self.extract_value(data, token_path)
|
|
529
|
+
if value is None:
|
|
530
|
+
raise CredentialError(credential_name, f"Path '{token_path}' not found in file")
|
|
531
|
+
value_str = str(value)
|
|
532
|
+
_validate_field_value(value_str, "token", credential_name)
|
|
533
|
+
return CredentialRecord(value=value_str, source="file", expires_at=expires_at)
|
|
534
|
+
|
|
535
|
+
key_field = cred_config.get("key_field")
|
|
536
|
+
if key_field:
|
|
537
|
+
record = self._extract_key_secret(
|
|
538
|
+
credential_name,
|
|
539
|
+
data,
|
|
540
|
+
key_field,
|
|
541
|
+
cred_config.get("secret_field"),
|
|
542
|
+
)
|
|
543
|
+
# Add expires_at if available
|
|
544
|
+
if expires_at:
|
|
545
|
+
return CredentialRecord(
|
|
546
|
+
value=record.value,
|
|
547
|
+
secret=record.secret,
|
|
548
|
+
source=record.source,
|
|
549
|
+
expires_at=expires_at,
|
|
550
|
+
)
|
|
551
|
+
return record
|
|
552
|
+
|
|
553
|
+
raise CredentialError(
|
|
554
|
+
credential_name,
|
|
555
|
+
"Missing 'token_path', 'key_field', or 'fields' in file config",
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
def _resolve_sops(
|
|
559
|
+
self,
|
|
560
|
+
credential_name: str,
|
|
561
|
+
cred_config: Mapping[str, Any],
|
|
562
|
+
) -> CredentialRecord:
|
|
563
|
+
"""Resolve credential from SOPS-encrypted file.
|
|
564
|
+
|
|
565
|
+
Config format (new - generic fields mapping):
|
|
566
|
+
type: sops
|
|
567
|
+
path: "secrets/api.sops.json"
|
|
568
|
+
fields:
|
|
569
|
+
key: "api_key" # Required, maps to value
|
|
570
|
+
secret: "api_secret" # Optional, maps to secret
|
|
571
|
+
passphrase: "pass" # Optional, maps to extras
|
|
572
|
+
|
|
573
|
+
Config format (legacy - still supported):
|
|
574
|
+
type: sops
|
|
575
|
+
path: "secrets/api.sops.json"
|
|
576
|
+
key_field: "api_key"
|
|
577
|
+
secret_field: "api_secret"
|
|
578
|
+
"""
|
|
579
|
+
file_path = cred_config.get("path")
|
|
580
|
+
if not file_path:
|
|
581
|
+
raise CredentialError(credential_name, "Missing 'path' in sops config")
|
|
582
|
+
|
|
583
|
+
# Build a secrets config with SOPS provider for the specific file
|
|
584
|
+
sops_config: dict[str, Any] = {
|
|
585
|
+
"providers": [{"name": "sops", "settings": {"path": file_path}}],
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
# New format: fields mapping
|
|
589
|
+
fields = cred_config.get("fields")
|
|
590
|
+
if fields:
|
|
591
|
+
return self._resolve_sops_fields(credential_name, fields, sops_config)
|
|
592
|
+
|
|
593
|
+
# Legacy format: token_path or key_field
|
|
594
|
+
return self._resolve_sops_legacy(credential_name, cred_config, sops_config)
|
|
595
|
+
|
|
596
|
+
def _resolve_sops_legacy(
|
|
597
|
+
self,
|
|
598
|
+
credential_name: str,
|
|
599
|
+
cred_config: Mapping[str, Any],
|
|
600
|
+
sops_config: dict[str, Any],
|
|
601
|
+
) -> CredentialRecord:
|
|
602
|
+
"""Resolve SOPS credentials using legacy format (key_field/secret_field)."""
|
|
603
|
+
key_field = cred_config.get("key_field")
|
|
604
|
+
secret_field = cred_config.get("secret_field")
|
|
605
|
+
token_path = cred_config.get("token_path")
|
|
606
|
+
|
|
607
|
+
if token_path:
|
|
608
|
+
return self._resolve_sops_token_path(credential_name, token_path, sops_config)
|
|
609
|
+
|
|
610
|
+
if key_field:
|
|
611
|
+
return self._resolve_sops_key_secret(credential_name, key_field, secret_field, sops_config)
|
|
612
|
+
|
|
613
|
+
raise CredentialError(
|
|
614
|
+
credential_name,
|
|
615
|
+
"Missing 'token_path', 'key_field', or 'fields' in sops config",
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def _resolve_sops_token_path(
|
|
619
|
+
self,
|
|
620
|
+
credential_name: str,
|
|
621
|
+
token_path: str,
|
|
622
|
+
sops_config: dict[str, Any],
|
|
623
|
+
) -> CredentialRecord:
|
|
624
|
+
"""Resolve single SOPS token using token_path."""
|
|
625
|
+
from kstlib.secrets import resolve_secret
|
|
626
|
+
|
|
627
|
+
key_path = token_path.lstrip(".")
|
|
628
|
+
try:
|
|
629
|
+
record = resolve_secret(key_path, config=sops_config)
|
|
630
|
+
_validate_field_value(record.value, "token", credential_name)
|
|
631
|
+
return CredentialRecord(value=record.value, source="sops")
|
|
632
|
+
except CredentialError:
|
|
633
|
+
raise
|
|
634
|
+
except Exception as e:
|
|
635
|
+
raise CredentialError(
|
|
636
|
+
credential_name,
|
|
637
|
+
f"Failed to resolve SOPS secret: {e}",
|
|
638
|
+
) from e
|
|
639
|
+
|
|
640
|
+
def _resolve_sops_key_secret(
|
|
641
|
+
self,
|
|
642
|
+
credential_name: str,
|
|
643
|
+
key_field: str,
|
|
644
|
+
secret_field: str | None,
|
|
645
|
+
sops_config: dict[str, Any],
|
|
646
|
+
) -> CredentialRecord:
|
|
647
|
+
"""Resolve SOPS key and optional secret using legacy format."""
|
|
648
|
+
from kstlib.secrets import resolve_secret
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
record_key = resolve_secret(key_field, config=sops_config)
|
|
652
|
+
key_value = record_key.value
|
|
653
|
+
_validate_field_value(key_value, "key", credential_name)
|
|
654
|
+
except CredentialError:
|
|
655
|
+
raise
|
|
656
|
+
except Exception as e:
|
|
657
|
+
raise CredentialError(
|
|
658
|
+
credential_name,
|
|
659
|
+
f"Failed to resolve SOPS key field: {e}",
|
|
660
|
+
) from e
|
|
661
|
+
|
|
662
|
+
secret_value = None
|
|
663
|
+
if secret_field:
|
|
664
|
+
try:
|
|
665
|
+
record_secret = resolve_secret(secret_field, config=sops_config)
|
|
666
|
+
secret_value = record_secret.value
|
|
667
|
+
_validate_field_value(secret_value, "secret", credential_name)
|
|
668
|
+
except CredentialError:
|
|
669
|
+
raise
|
|
670
|
+
except Exception as e:
|
|
671
|
+
raise CredentialError(
|
|
672
|
+
credential_name,
|
|
673
|
+
f"Failed to resolve SOPS secret field: {e}",
|
|
674
|
+
) from e
|
|
675
|
+
|
|
676
|
+
return CredentialRecord(value=key_value, secret=secret_value, source="sops")
|
|
677
|
+
|
|
678
|
+
def _resolve_sops_fields(
|
|
679
|
+
self,
|
|
680
|
+
credential_name: str,
|
|
681
|
+
fields: Mapping[str, str],
|
|
682
|
+
sops_config: dict[str, Any],
|
|
683
|
+
) -> CredentialRecord:
|
|
684
|
+
"""Resolve credentials from SOPS using fields mapping.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
credential_name: Credential name for error messages.
|
|
688
|
+
fields: Mapping of logical names to field names in SOPS file.
|
|
689
|
+
sops_config: SOPS provider configuration.
|
|
690
|
+
|
|
691
|
+
Returns:
|
|
692
|
+
CredentialRecord with resolved values.
|
|
693
|
+
"""
|
|
694
|
+
from kstlib.secrets import resolve_secret
|
|
695
|
+
|
|
696
|
+
_validate_fields_mapping(fields, credential_name)
|
|
697
|
+
|
|
698
|
+
# Resolve key (required)
|
|
699
|
+
key_field = fields["key"]
|
|
700
|
+
try:
|
|
701
|
+
record_key = resolve_secret(key_field, config=sops_config)
|
|
702
|
+
key_value = record_key.value
|
|
703
|
+
_validate_field_value(key_value, "key", credential_name)
|
|
704
|
+
except CredentialError:
|
|
705
|
+
raise
|
|
706
|
+
except Exception as e:
|
|
707
|
+
raise CredentialError(
|
|
708
|
+
credential_name,
|
|
709
|
+
f"Failed to resolve SOPS field '{key_field}' (fields.key): {e}",
|
|
710
|
+
) from e
|
|
711
|
+
|
|
712
|
+
# Resolve secret (optional)
|
|
713
|
+
secret_value: str | None = None
|
|
714
|
+
if "secret" in fields:
|
|
715
|
+
secret_field = fields["secret"]
|
|
716
|
+
try:
|
|
717
|
+
record_secret = resolve_secret(secret_field, config=sops_config)
|
|
718
|
+
secret_value = record_secret.value
|
|
719
|
+
_validate_field_value(secret_value, "secret", credential_name)
|
|
720
|
+
except CredentialError:
|
|
721
|
+
raise
|
|
722
|
+
except Exception as e:
|
|
723
|
+
raise CredentialError(
|
|
724
|
+
credential_name,
|
|
725
|
+
f"Failed to resolve SOPS field '{secret_field}' (fields.secret): {e}",
|
|
726
|
+
) from e
|
|
727
|
+
|
|
728
|
+
# Resolve extras (all other fields)
|
|
729
|
+
extras: dict[str, str] = {}
|
|
730
|
+
for field_name, sops_field in fields.items():
|
|
731
|
+
if field_name in ("key", "secret"):
|
|
732
|
+
continue
|
|
733
|
+
try:
|
|
734
|
+
record_extra = resolve_secret(sops_field, config=sops_config)
|
|
735
|
+
extra_value = record_extra.value
|
|
736
|
+
_validate_field_value(extra_value, field_name, credential_name)
|
|
737
|
+
extras[field_name] = extra_value
|
|
738
|
+
except CredentialError:
|
|
739
|
+
raise
|
|
740
|
+
except Exception as e:
|
|
741
|
+
raise CredentialError(
|
|
742
|
+
credential_name,
|
|
743
|
+
f"Failed to resolve SOPS field '{sops_field}' (fields.{field_name}): {e}",
|
|
744
|
+
) from e
|
|
745
|
+
|
|
746
|
+
return CredentialRecord(
|
|
747
|
+
value=key_value,
|
|
748
|
+
secret=secret_value,
|
|
749
|
+
source="sops",
|
|
750
|
+
extras=extras,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
def _resolve_provider(
|
|
754
|
+
self,
|
|
755
|
+
credential_name: str,
|
|
756
|
+
cred_config: Mapping[str, Any],
|
|
757
|
+
) -> CredentialRecord:
|
|
758
|
+
"""Resolve credential from kstlib.auth provider.
|
|
759
|
+
|
|
760
|
+
Config format:
|
|
761
|
+
type: provider
|
|
762
|
+
provider: "corporate" # kstlib.auth provider name
|
|
763
|
+
"""
|
|
764
|
+
provider_name = cred_config.get("provider")
|
|
765
|
+
if not provider_name:
|
|
766
|
+
raise CredentialError(credential_name, "Missing 'provider' in provider config")
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
from kstlib.auth import OIDCProvider, get_token_storage_from_config
|
|
770
|
+
|
|
771
|
+
storage = get_token_storage_from_config(provider_name=provider_name)
|
|
772
|
+
token = storage.load(provider_name)
|
|
773
|
+
|
|
774
|
+
if not token or not token.access_token:
|
|
775
|
+
raise CredentialError(
|
|
776
|
+
credential_name,
|
|
777
|
+
f"No valid token found for provider '{provider_name}'. Run 'kstlib auth login' first.",
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# Check if token is expired
|
|
781
|
+
if token.is_expired:
|
|
782
|
+
# Try to refresh using the provider
|
|
783
|
+
if token.is_refreshable:
|
|
784
|
+
log.debug("Access token expired, attempting refresh")
|
|
785
|
+
provider = OIDCProvider.from_config(provider_name)
|
|
786
|
+
token = provider.refresh(token)
|
|
787
|
+
storage.save(provider_name, token)
|
|
788
|
+
else:
|
|
789
|
+
raise CredentialError(
|
|
790
|
+
credential_name,
|
|
791
|
+
f"Token for provider '{provider_name}' is expired. Run 'kstlib auth login' to refresh.",
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
# Convert datetime to float timestamp for CredentialRecord
|
|
795
|
+
expires_at_ts: float | None = None
|
|
796
|
+
if token.expires_at is not None:
|
|
797
|
+
expires_at_ts = token.expires_at.timestamp()
|
|
798
|
+
|
|
799
|
+
return CredentialRecord(
|
|
800
|
+
value=token.access_token,
|
|
801
|
+
source="provider",
|
|
802
|
+
expires_at=expires_at_ts,
|
|
803
|
+
)
|
|
804
|
+
except ImportError as e:
|
|
805
|
+
raise CredentialError(
|
|
806
|
+
credential_name,
|
|
807
|
+
f"kstlib.auth module not available: {e}",
|
|
808
|
+
) from e
|
|
809
|
+
except Exception as e:
|
|
810
|
+
if isinstance(e, CredentialError):
|
|
811
|
+
raise
|
|
812
|
+
raise CredentialError(
|
|
813
|
+
credential_name,
|
|
814
|
+
f"Failed to get token from provider: {e}",
|
|
815
|
+
) from e
|
|
816
|
+
|
|
817
|
+
@staticmethod
|
|
818
|
+
def extract_value(data: Any, path: str) -> Any:
|
|
819
|
+
"""Extract value using jq-like path syntax.
|
|
820
|
+
|
|
821
|
+
Supports:
|
|
822
|
+
- .foo.bar - nested object access
|
|
823
|
+
- .foo[0] - array index access
|
|
824
|
+
- .foo["key-with-dash"] - bracket notation for special keys
|
|
825
|
+
- .foo['key-with-dash'] - single quotes also supported
|
|
826
|
+
- .foo.bar[0].baz - combined access
|
|
827
|
+
|
|
828
|
+
Args:
|
|
829
|
+
data: Data structure to extract from.
|
|
830
|
+
path: jq-like path (e.g., ".foo.bar[0]" or '.foo["access-token"]').
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
Extracted value or None if not found.
|
|
834
|
+
|
|
835
|
+
Examples:
|
|
836
|
+
>>> CredentialResolver.extract_value({"foo": {"bar": [1, 2, 3]}}, ".foo.bar[1]")
|
|
837
|
+
2
|
|
838
|
+
>>> CredentialResolver.extract_value({"a": "b"}, ".missing")
|
|
839
|
+
>>> CredentialResolver.extract_value([1, 2, 3], ".[0]")
|
|
840
|
+
1
|
|
841
|
+
>>> CredentialResolver.extract_value({"a-b": "value"}, '.["a-b"]')
|
|
842
|
+
'value'
|
|
843
|
+
"""
|
|
844
|
+
if not path or path == ".":
|
|
845
|
+
return data
|
|
846
|
+
|
|
847
|
+
current = data
|
|
848
|
+
matches = _JQ_PATH_PATTERN.findall(path)
|
|
849
|
+
|
|
850
|
+
for part in matches:
|
|
851
|
+
if current is None:
|
|
852
|
+
return None
|
|
853
|
+
|
|
854
|
+
if part.startswith("[") and part.endswith("]"):
|
|
855
|
+
inner = part[1:-1]
|
|
856
|
+
# Check for quoted string key: ["key"] or ['key']
|
|
857
|
+
if (inner.startswith('"') and inner.endswith('"')) or (inner.startswith("'") and inner.endswith("'")):
|
|
858
|
+
# Extract key from quotes
|
|
859
|
+
key = inner[1:-1]
|
|
860
|
+
if isinstance(current, dict):
|
|
861
|
+
current = current.get(key)
|
|
862
|
+
else:
|
|
863
|
+
return None
|
|
864
|
+
else:
|
|
865
|
+
# Array index
|
|
866
|
+
try:
|
|
867
|
+
index = int(inner)
|
|
868
|
+
if isinstance(current, list | tuple) and 0 <= index < len(current):
|
|
869
|
+
current = current[index]
|
|
870
|
+
else:
|
|
871
|
+
return None
|
|
872
|
+
except (ValueError, IndexError):
|
|
873
|
+
return None
|
|
874
|
+
elif isinstance(current, dict):
|
|
875
|
+
current = current.get(part)
|
|
876
|
+
else:
|
|
877
|
+
return None
|
|
878
|
+
|
|
879
|
+
return current
|
|
880
|
+
|
|
881
|
+
def clear_cache(self) -> None:
|
|
882
|
+
"""Clear the credential cache."""
|
|
883
|
+
self._cache.clear()
|
|
884
|
+
log.debug("Credential cache cleared")
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
__all__ = ["CredentialRecord", "CredentialResolver"]
|