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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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"]