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,101 @@
1
+ """Kwargs-based provider for direct secret injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
9
+ from kstlib.secrets.providers.base import SecretProvider
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping
13
+ else: # pragma: no cover - runtime alias for typing constructs
14
+ Mapping = typing.Mapping
15
+
16
+
17
+ class KwargsProvider(SecretProvider):
18
+ """Resolve secrets from explicitly provided keyword arguments.
19
+
20
+ This provider is useful for testing and temporary overrides. Secrets
21
+ passed via kwargs take precedence over all other providers.
22
+
23
+ Example:
24
+ >>> from kstlib.secrets.providers.kwargs import KwargsProvider
25
+ >>> provider = KwargsProvider({"api.key": "test-key"})
26
+ >>> from kstlib.secrets.models import SecretRequest
27
+ >>> record = provider.resolve(SecretRequest(name="api.key"))
28
+ >>> record.value
29
+ 'test-key'
30
+ """
31
+
32
+ name = "kwargs"
33
+
34
+ def __init__(self, secrets: Mapping[str, Any] | None = None) -> None:
35
+ """Initialize with an optional mapping of secret names to values.
36
+
37
+ Args:
38
+ secrets: Mapping of dotted secret names to their values.
39
+ """
40
+ self._secrets: dict[str, Any] = dict(secrets) if secrets else {}
41
+
42
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
43
+ """Apply settings overrides (merges additional secrets).
44
+
45
+ Args:
46
+ settings: Optional mapping that may provide additional secrets
47
+ under the ``secrets`` key.
48
+ """
49
+ if not settings:
50
+ return
51
+ additional = settings.get("secrets")
52
+ if isinstance(additional, Mapping):
53
+ self._secrets.update(additional)
54
+
55
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
56
+ """Resolve a secret from the provided kwargs.
57
+
58
+ Args:
59
+ request: Secret descriptor with the name to look up.
60
+
61
+ Returns:
62
+ A ``SecretRecord`` if the secret is found, otherwise ``None``.
63
+ """
64
+ value = self._secrets.get(request.name)
65
+ if value is None:
66
+ return None
67
+ return SecretRecord(
68
+ value=value,
69
+ source=SecretSource.KWARGS,
70
+ metadata={"provider": "kwargs"},
71
+ )
72
+
73
+ def set(self, name: str, value: Any) -> None:
74
+ """Add or update a secret at runtime.
75
+
76
+ Args:
77
+ name: The dotted secret name (e.g., "api.key").
78
+ value: The secret value.
79
+ """
80
+ self._secrets[name] = value
81
+
82
+ def remove(self, name: str) -> bool:
83
+ """Remove a secret by name.
84
+
85
+ Args:
86
+ name: The dotted secret name to remove.
87
+
88
+ Returns:
89
+ True if the secret was removed, False if it didn't exist.
90
+ """
91
+ if name in self._secrets:
92
+ del self._secrets[name]
93
+ return True
94
+ return False
95
+
96
+ def clear(self) -> None:
97
+ """Remove all secrets from this provider."""
98
+ self._secrets.clear()
99
+
100
+
101
+ __all__ = ["KwargsProvider"]
@@ -0,0 +1,209 @@
1
+ """SOPS-backed provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # pylint: disable=duplicate-code
6
+ import json
7
+ import logging
8
+ import re
9
+ import shutil
10
+ from collections import OrderedDict
11
+ from collections.abc import Mapping, Sequence
12
+ from pathlib import Path
13
+ from subprocess import run as subprocess_run
14
+ from typing import Any
15
+
16
+ import yaml
17
+
18
+ from kstlib.limits import HARD_MAX_SOPS_CACHE_ENTRIES, SopsLimits, get_sops_limits
19
+ from kstlib.secrets.exceptions import SecretDecryptionError
20
+ from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
21
+ from kstlib.secrets.providers.base import SecretProvider
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SOPSProvider(SecretProvider):
27
+ """Load secrets from SOPS encrypted documents."""
28
+
29
+ name = "sops"
30
+
31
+ def __init__(
32
+ self,
33
+ *,
34
+ path: str | Path | None = None,
35
+ binary: str = "sops",
36
+ document_format: str = "auto",
37
+ limits: SopsLimits | None = None,
38
+ ) -> None:
39
+ """Configure the provider with optional defaults."""
40
+ self._path = Path(path) if path else None
41
+ self._binary = binary
42
+ self._document_format = document_format
43
+ self._limits = limits or get_sops_limits()
44
+ self._max_cache_entries = self._limits.max_cache_entries
45
+ self._cache: OrderedDict[Path, tuple[float, Any]] = OrderedDict()
46
+
47
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
48
+ """Apply overrides supplied by configuration files."""
49
+ if not settings:
50
+ return
51
+ target = settings.get("path")
52
+ if target:
53
+ self._path = Path(target)
54
+ binary = settings.get("binary")
55
+ if binary:
56
+ self._binary = binary
57
+ fmt = settings.get("format") or settings.get("document_format")
58
+ if fmt:
59
+ self._document_format = fmt
60
+ max_entries = settings.get("max_cache_entries")
61
+ if max_entries is not None:
62
+ # Enforce hard limit for deep defense
63
+ self._max_cache_entries = min(int(max_entries), HARD_MAX_SOPS_CACHE_ENTRIES)
64
+
65
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
66
+ """Resolve the requested secret from the encrypted document."""
67
+ path = self._resolve_path(request)
68
+ if path is None:
69
+ return None
70
+
71
+ document = self._load_document(path)
72
+ value = self._extract_value(document, request)
73
+ if value is None:
74
+ return None
75
+
76
+ metadata = {
77
+ "path": str(path),
78
+ "format": self._document_format,
79
+ "binary": self._binary,
80
+ }
81
+ return SecretRecord(value=value, source=SecretSource.SOPS, metadata=metadata)
82
+
83
+ def _resolve_path(self, request: SecretRequest) -> Path | None:
84
+ """Determine the effective SOPS path for a secret request."""
85
+ candidate = request.metadata.get("path") if request.metadata else None
86
+ if candidate:
87
+ return Path(candidate)
88
+ return self._path
89
+
90
+ def _load_document(self, path: Path) -> Any:
91
+ """Decrypt and parse the underlying SOPS document."""
92
+ try:
93
+ mtime = path.stat().st_mtime
94
+ except FileNotFoundError as exc: # pragma: no cover - defensive branch
95
+ raise SecretDecryptionError(f"SOPS file not found: {path}") from exc
96
+
97
+ cached = self._cache.get(path)
98
+ if cached and cached[0] == mtime:
99
+ # Move to end for LRU tracking
100
+ self._cache.move_to_end(path)
101
+ return cached[1]
102
+
103
+ binary_path = shutil.which(self._binary)
104
+ if binary_path is None:
105
+ raise SecretDecryptionError(
106
+ "SOPS binary not found. Install from https://github.com/getsops/sops or set 'binary' option.",
107
+ )
108
+
109
+ command = [binary_path, "--decrypt", str(path)]
110
+ process = subprocess_run(
111
+ command,
112
+ capture_output=True,
113
+ text=True,
114
+ check=False,
115
+ shell=False,
116
+ )
117
+ if process.returncode != 0:
118
+ diagnostic = process.stderr.strip() or process.stdout.strip()
119
+ if diagnostic:
120
+ logger.debug(
121
+ "SOPS decryption failed for %s: %s",
122
+ path,
123
+ self._redact_sensitive_output(diagnostic),
124
+ )
125
+ raise SecretDecryptionError(
126
+ f"Failed to decrypt secrets file '{path.name}'. Check SOPS configuration and file permissions."
127
+ )
128
+
129
+ document = self._parse_document(process.stdout)
130
+ self._cache[path] = (mtime, document)
131
+ self._cache.move_to_end(path)
132
+ # Evict oldest entries (LRU) if cache exceeds limit
133
+ while len(self._cache) > self._max_cache_entries:
134
+ self._cache.popitem(last=False)
135
+ return document
136
+
137
+ def _parse_document(self, payload: str) -> Any:
138
+ """Deserialize the decrypted payload according to the configured format."""
139
+ fmt = self._document_format.lower()
140
+ try:
141
+ if fmt == "json":
142
+ return json.loads(payload)
143
+ if fmt == "yaml":
144
+ return yaml.safe_load(payload)
145
+ if fmt == "text":
146
+ return payload
147
+ # auto-detect: try json then yaml, fallback to text
148
+ return json.loads(payload)
149
+ except json.JSONDecodeError:
150
+ try:
151
+ return yaml.safe_load(payload)
152
+ except yaml.YAMLError as exc:
153
+ raise SecretDecryptionError("Unable to parse decrypted document as JSON or YAML") from exc
154
+ except yaml.YAMLError as exc:
155
+ raise SecretDecryptionError("Unable to parse decrypted document as YAML") from exc
156
+
157
+ def _extract_value(self, document: Any, request: SecretRequest) -> Any | None:
158
+ """Extract the value identified by the request metadata or name."""
159
+ key_path = request.metadata.get("key_path") if request.metadata else None
160
+ if key_path is None:
161
+ key_path = request.name
162
+
163
+ if isinstance(key_path, str):
164
+ parts = [part for part in key_path.split(".") if part]
165
+ elif isinstance(key_path, Sequence):
166
+ parts = [str(part) for part in key_path]
167
+ else: # pragma: no cover - defensive guard
168
+ raise SecretDecryptionError("Invalid 'key_path' metadata; expected string or sequence of strings.")
169
+
170
+ current = document
171
+ for part in parts:
172
+ if isinstance(current, Mapping) and part in current:
173
+ current = current[part]
174
+ else:
175
+ return None
176
+ return current
177
+
178
+ @staticmethod
179
+ def _redact_sensitive_output(message: str) -> str:
180
+ """Redact known sensitive substrings from diagnostic output."""
181
+ patterns = [
182
+ re.compile(r"arn:aws:[^\s]+", re.IGNORECASE),
183
+ re.compile(r"AKIA[0-9A-Z]{16}"),
184
+ re.compile(r"(?:/home/|/Users/)[^\s]+"),
185
+ ]
186
+ redacted = message
187
+ for pattern in patterns:
188
+ redacted = pattern.sub("[REDACTED]", redacted)
189
+ return redacted
190
+
191
+ def purge_cache(self, *, path: str | Path | None = None) -> None:
192
+ """Clear decrypted document cache entries."""
193
+ if path is None:
194
+ self._cache.clear()
195
+ return
196
+
197
+ target = Path(path)
198
+ if target in self._cache:
199
+ self._cache.pop(target, None)
200
+ return
201
+
202
+ try:
203
+ resolved = target.resolve()
204
+ except OSError: # pragma: no cover - inaccessible paths
205
+ return
206
+ self._cache.pop(resolved, None)
207
+
208
+
209
+ __all__ = ["SOPSProvider"]
@@ -0,0 +1,221 @@
1
+ """SecretResolver orchestrates provider lookups."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from kstlib.config.loader import get_config
9
+ from kstlib.secrets.exceptions import SecretNotFoundError
10
+ from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
11
+ from kstlib.secrets.providers import configure_provider, get_provider
12
+
13
+ if TYPE_CHECKING:
14
+ from kstlib.secrets.providers.base import SecretProvider
15
+
16
+
17
+ class SecretResolver:
18
+ """Resolve secrets by delegating to a sequence of providers.
19
+
20
+ The resolver iterates through providers in order until one returns a value.
21
+ If no provider can resolve the secret and no default is given, a
22
+ ``SecretNotFoundError`` is raised (when ``required=True``).
23
+
24
+ Example:
25
+ >>> from kstlib.secrets.resolver import SecretResolver
26
+ >>> from kstlib.secrets.providers import get_provider
27
+ >>> from kstlib.secrets.models import SecretRequest
28
+ >>> resolver = SecretResolver([get_provider("environment")], name="app")
29
+ >>> # resolver.resolve(SecretRequest(name="API_KEY"))
30
+ """
31
+
32
+ def __init__(self, providers: Sequence[SecretProvider], *, name: str | None = None) -> None:
33
+ """Initialise the resolver with a provider cascade.
34
+
35
+ Args:
36
+ providers: Ordered sequence of secret providers to query.
37
+ name: Human-readable name for this resolver (used in error messages).
38
+ """
39
+ self._providers = list(providers)
40
+ self._name = name or "default"
41
+
42
+ @property
43
+ def name(self) -> str:
44
+ """Return the resolver name."""
45
+ return self._name
46
+
47
+ def resolve(self, request: SecretRequest) -> SecretRecord:
48
+ """Resolve the secret using the configured provider cascade.
49
+
50
+ Args:
51
+ request: The secret request to resolve.
52
+
53
+ Returns:
54
+ A SecretRecord with the resolved value and metadata.
55
+
56
+ Raises:
57
+ SecretNotFoundError: If the secret is not found and required=True.
58
+ """
59
+ for provider in self._providers:
60
+ record = provider.resolve(request)
61
+ if record is not None:
62
+ return record
63
+ if request.default is not None:
64
+ return self._default_record(request.default, is_async=False)
65
+ if request.required:
66
+ raise SecretNotFoundError(f"Secret '{request.name}' not found in resolver '{self._name}'")
67
+ return self._default_record(None, is_async=False)
68
+
69
+ async def resolve_async(self, request: SecretRequest) -> SecretRecord:
70
+ """Async counterpart for ``resolve``.
71
+
72
+ Args:
73
+ request: The secret request to resolve.
74
+
75
+ Returns:
76
+ A SecretRecord with the resolved value and metadata.
77
+
78
+ Raises:
79
+ SecretNotFoundError: If the secret is not found and required=True.
80
+ """
81
+ for provider in self._providers:
82
+ record = await provider.resolve_async(request)
83
+ if record is not None:
84
+ return record
85
+ if request.default is not None:
86
+ return self._default_record(request.default, is_async=True)
87
+ if request.required:
88
+ raise SecretNotFoundError(f"Secret '{request.name}' not found in resolver '{self._name}'")
89
+ return self._default_record(None, is_async=True)
90
+
91
+ def _default_record(self, value: Any, *, is_async: bool) -> SecretRecord:
92
+ metadata: dict[str, Any] = {"resolver": self._name}
93
+ if is_async:
94
+ metadata["async"] = True
95
+ return SecretRecord(value=value, source=SecretSource.DEFAULT, metadata=metadata)
96
+
97
+
98
+ def get_secret_resolver(
99
+ config: Mapping[str, Any] | None = None,
100
+ *,
101
+ secrets: Mapping[str, Any] | None = None,
102
+ ) -> SecretResolver:
103
+ """Build a resolver from configuration mapping.
104
+
105
+ When no explicit provider list is given, the default cascade is:
106
+ ``(KwargsProvider if secrets) -> EnvironmentProvider -> KeyringProvider -> (SOPSProvider if configured)``.
107
+
108
+ Args:
109
+ config: Optional mapping with ``providers`` list and/or ``sops`` settings.
110
+ When ``None``, uses the default provider chain.
111
+ secrets: Optional mapping of secret overrides to inject via KwargsProvider.
112
+
113
+ Returns:
114
+ Configured ``SecretResolver`` instance.
115
+
116
+ Example:
117
+ >>> from kstlib.secrets.resolver import get_secret_resolver
118
+ >>> resolver = get_secret_resolver() # uses defaults
119
+ >>> resolver.name
120
+ 'default'
121
+ """
122
+ config = config or {}
123
+ providers: list[SecretProvider] = []
124
+
125
+ # KwargsProvider always comes first (highest priority)
126
+ if secrets:
127
+ providers.append(get_provider("kwargs", secrets=secrets))
128
+
129
+ provider_configs = config.get("providers", [])
130
+ if not provider_configs:
131
+ providers.extend([get_provider("environment"), get_provider("keyring")])
132
+ sops_config = config.get("sops")
133
+ if isinstance(sops_config, Mapping):
134
+ providers.append(_build_sops_provider(sops_config))
135
+ else:
136
+ for provider_cfg in provider_configs:
137
+ name = provider_cfg.get("name")
138
+ if not name:
139
+ raise ValueError("Provider configuration requires a 'name' field")
140
+ settings = provider_cfg.get("settings")
141
+ provider = get_provider(name, **(provider_cfg.get("options") or {}))
142
+ providers.append(configure_provider(provider, settings))
143
+ return SecretResolver(providers, name=config.get("name"))
144
+
145
+
146
+ def resolve_secret(
147
+ name: str,
148
+ *,
149
+ config: Mapping[str, Any] | None = None,
150
+ secrets: Mapping[str, Any] | None = None,
151
+ **request_kwargs: Any,
152
+ ) -> SecretRecord:
153
+ """Resolve a secret by name using the global resolver cascade.
154
+
155
+ Args:
156
+ name: Identifier of the secret (``"smtp.password"`` for example).
157
+ config: Optional configuration mapping describing providers. When not
158
+ provided the function attempts to reuse the globally loaded config.
159
+ secrets: Optional mapping of secret overrides. These take precedence
160
+ over all other providers (useful for testing).
161
+ request_kwargs: Additional keyword arguments forwarded to
162
+ :class:`SecretRequest`. Supported keys are ``scope``, ``required``,
163
+ ``default`` and ``metadata``.
164
+
165
+ Returns:
166
+ A ``SecretRecord`` describing the resolved secret and its provenance.
167
+
168
+ Raises:
169
+ SecretNotFoundError: If the secret is not found and required=True.
170
+ TypeError: If unsupported keyword arguments are provided.
171
+
172
+ Example:
173
+ >>> from kstlib.secrets.resolver import resolve_secret
174
+ >>> # Override for testing
175
+ >>> record = resolve_secret("api.key", secrets={"api.key": "test-value"})
176
+ >>> record.source
177
+ <SecretSource.KWARGS: 'kwargs'>
178
+ """
179
+ if config is None:
180
+ global_config = get_config()
181
+ secrets_config = getattr(global_config, "secrets", None)
182
+ config = secrets_config.to_dict() if secrets_config is not None else None
183
+
184
+ allowed_keys = {"scope", "required", "default", "metadata"}
185
+ unexpected = set(request_kwargs) - allowed_keys
186
+ if unexpected:
187
+ unexpected_list = ", ".join(sorted(unexpected))
188
+ raise TypeError(f"Unsupported keyword arguments: {unexpected_list}")
189
+
190
+ resolver = get_secret_resolver(config, secrets=secrets)
191
+ scope = request_kwargs.get("scope")
192
+ required = request_kwargs.get("required", True)
193
+ default = request_kwargs.get("default")
194
+ metadata = request_kwargs.get("metadata")
195
+ request = SecretRequest(
196
+ name=name,
197
+ scope=scope,
198
+ required=required,
199
+ default=default,
200
+ metadata=dict(metadata) if metadata else {},
201
+ )
202
+ return resolver.resolve(request)
203
+
204
+
205
+ def _build_sops_provider(config: Mapping[str, Any]) -> SecretProvider:
206
+ """Instantiate a SOPS provider from a simple mapping."""
207
+ option_keys = {"path", "binary", "document_format", "format"}
208
+ raw_options = config.get("options")
209
+ options = (
210
+ {key: value for key, value in config.items() if key in option_keys}
211
+ if raw_options is None
212
+ else dict(raw_options)
213
+ )
214
+ if "format" in options and "document_format" not in options:
215
+ options["document_format"] = options.pop("format")
216
+ settings = config.get("settings")
217
+ provider = get_provider("sops", **options)
218
+ return configure_provider(provider, settings)
219
+
220
+
221
+ __all__ = ["SecretResolver", "get_secret_resolver"]
@@ -0,0 +1,130 @@
1
+ """Helpers that minimise the footprint of decrypted secrets.
2
+
3
+ The :func:`sensitive` context manager temporarily exposes a secret value and
4
+ then attempts to scrub it from memory, clear provider caches, and drop any
5
+ remaining references.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from collections.abc import Callable, Iterator, Mapping, Sequence
12
+ from contextlib import contextmanager, suppress
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any, Protocol, cast
15
+
16
+ if TYPE_CHECKING:
17
+ from kstlib.secrets.models import SecretRecord
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ LegacyPurge = Callable[[], None]
22
+
23
+
24
+ class CachePurgeProtocol(Protocol): # pylint: disable=too-few-public-methods
25
+ """Protocol implemented by providers exposing a cache purge hook."""
26
+
27
+ def purge_cache(self, *, path: str | Path | None = None) -> None: # pragma: no cover - protocol stub
28
+ """Clear cached decrypted material."""
29
+
30
+
31
+ @contextmanager
32
+ def sensitive(
33
+ record: SecretRecord,
34
+ *,
35
+ providers: Sequence[CachePurgeProtocol] | None = None,
36
+ ) -> Iterator[Any]:
37
+ """Temporarily expose a secret and scrub it afterwards.
38
+
39
+ The context manager yields the secret value so it can be used within the
40
+ protected block. On exit it attempts to overwrite mutable buffers in place,
41
+ clears provider caches when available, and replaces the value stored in the
42
+ :class:`SecretRecord` with ``None`` to break lingering references.
43
+
44
+ Example:
45
+ >>> from kstlib.secrets.models import SecretRecord, SecretSource
46
+ >>> from kstlib.secrets.sensitive import sensitive
47
+ >>> record = SecretRecord(value=bytearray(b"api-token"), source=SecretSource.SOPS)
48
+ >>> with sensitive(record) as secret:
49
+ ... secret[:3] = b"***" # handle the secret
50
+ >>> record.value is None
51
+ True
52
+
53
+ Args:
54
+ record: Secret wrapped in a :class:`SecretRecord`.
55
+ providers: Optional providers whose caches should be purged after use.
56
+
57
+ Yields:
58
+ The decrypted secret value.
59
+ """
60
+ try:
61
+ yield record.value
62
+ finally:
63
+ _scrub_value(record.value)
64
+ record.value = None
65
+ metadata = record.metadata if isinstance(record.metadata, Mapping) else {}
66
+ _purge_providers(providers, metadata=metadata)
67
+
68
+
69
+ def _scrub_value(value: Any) -> None:
70
+ """Best-effort scrubbing for mutable buffers."""
71
+ if value is None:
72
+ return
73
+ if isinstance(value, bytearray):
74
+ value[:] = b"\x00" * len(value)
75
+ return
76
+ if isinstance(value, memoryview):
77
+ if not value.readonly:
78
+ value[:] = b"\x00" * len(value)
79
+ value.release()
80
+ return
81
+ if hasattr(value, "clear") and hasattr(value, "__setitem__"):
82
+ try:
83
+ length = len(value)
84
+ except TypeError: # pragma: no cover - objects without __len__
85
+ length = 0
86
+ for index in range(length):
87
+ if _try_assign(value, index, 0):
88
+ continue
89
+ _try_assign(value, index, None)
90
+ with suppress(AttributeError, TypeError, ValueError):
91
+ value.clear()
92
+
93
+
94
+ def _purge_providers(
95
+ providers: Sequence[CachePurgeProtocol] | None,
96
+ *,
97
+ metadata: Mapping[str, Any] | None,
98
+ ) -> None:
99
+ """Invoke cache purge hooks for the supplied providers."""
100
+ if not providers:
101
+ return
102
+ path_hint: str | Path | None = None
103
+ if metadata:
104
+ candidate = metadata.get("path")
105
+ if isinstance(candidate, str | Path):
106
+ path_hint = candidate
107
+
108
+ for provider in providers:
109
+ purge = getattr(provider, "purge_cache", None)
110
+ if not callable(purge):
111
+ continue
112
+ try:
113
+ purge(path=path_hint)
114
+ except TypeError:
115
+ legacy_purge = cast("LegacyPurge", purge)
116
+ legacy_purge()
117
+ except (AttributeError, OSError, RuntimeError, ValueError) as error: # pragma: no cover - defensive logging
118
+ logger.debug("Provider cache purge failed for %s", provider, exc_info=error)
119
+
120
+
121
+ def _try_assign(target: Any, index: int, replacement: Any) -> bool:
122
+ """Attempt to assign ``replacement`` at ``index`` and report success."""
123
+ try:
124
+ target[index] = replacement
125
+ except (AttributeError, IndexError, KeyError, TypeError, ValueError):
126
+ return False
127
+ return True
128
+
129
+
130
+ __all__ = ["CachePurgeProtocol", "sensitive"]
@@ -0,0 +1,23 @@
1
+ """Security helpers (filesystem guardrails, policies, and errors)."""
2
+
3
+ from kstlib.secure import fs as _fs
4
+ from kstlib.secure import permissions as _perms
5
+
6
+ RELAXED_POLICY = _fs.RELAXED_POLICY
7
+ STRICT_POLICY = _fs.STRICT_POLICY
8
+ GuardPolicy = _fs.GuardPolicy
9
+ PathGuardrails = _fs.PathGuardrails
10
+ PathSecurityError = _fs.PathSecurityError
11
+
12
+ DirectoryPermissions = _perms.DirectoryPermissions
13
+ FilePermissions = _perms.FilePermissions
14
+
15
+ __all__ = [
16
+ "RELAXED_POLICY",
17
+ "STRICT_POLICY",
18
+ "DirectoryPermissions",
19
+ "FilePermissions",
20
+ "GuardPolicy",
21
+ "PathGuardrails",
22
+ "PathSecurityError",
23
+ ]