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
kstlib/auth/token.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Token storage backends for the authentication module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from kstlib.auth.errors import TokenStorageError
|
|
12
|
+
from kstlib.auth.models import Token
|
|
13
|
+
from kstlib.logging import TRACE_LEVEL, get_logger
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
# Deep defense: provider name limits
|
|
21
|
+
_MAX_PROVIDER_NAME_LENGTH = 128
|
|
22
|
+
_MIN_PROVIDER_NAME_LENGTH = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _validate_provider_name(provider_name: str) -> None:
|
|
26
|
+
"""Validate provider name for security.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
provider_name: Provider identifier to validate.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
TokenStorageError: If provider name is invalid.
|
|
33
|
+
"""
|
|
34
|
+
if not provider_name or len(provider_name) < _MIN_PROVIDER_NAME_LENGTH:
|
|
35
|
+
raise TokenStorageError("Provider name cannot be empty")
|
|
36
|
+
if len(provider_name) > _MAX_PROVIDER_NAME_LENGTH:
|
|
37
|
+
raise TokenStorageError(f"Provider name exceeds maximum length ({_MAX_PROVIDER_NAME_LENGTH})")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AbstractTokenStorage(ABC):
|
|
41
|
+
"""Abstract base class for token storage backends.
|
|
42
|
+
|
|
43
|
+
Implementations handle persisting and retrieving tokens, with optional
|
|
44
|
+
encryption (e.g., SOPS) for secure storage.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def save(self, provider_name: str, token: Token) -> None:
|
|
49
|
+
"""Persist a token for a provider.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
provider_name: Provider identifier.
|
|
53
|
+
token: Token to save.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
TokenStorageError: If save fails.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
@abstractmethod
|
|
60
|
+
def load(self, provider_name: str) -> Token | None:
|
|
61
|
+
"""Load a token for a provider.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
provider_name: Provider identifier.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Token if found, None otherwise.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
TokenStorageError: If load fails (not for missing tokens).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def delete(self, provider_name: str) -> bool:
|
|
75
|
+
"""Delete a token for a provider.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
provider_name: Provider identifier.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if token existed and was deleted.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def exists(self, provider_name: str) -> bool:
|
|
86
|
+
"""Check if a token exists for a provider.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
provider_name: Provider identifier.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if token exists.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
@contextmanager
|
|
96
|
+
def sensitive_token(self, provider_name: str) -> Iterator[Token | None]:
|
|
97
|
+
"""Context manager for secure token access.
|
|
98
|
+
|
|
99
|
+
Loads the token and yields it. On exit, clears the reference.
|
|
100
|
+
Subclasses may implement additional cleanup (e.g., memory scrubbing).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
provider_name: Provider identifier.
|
|
104
|
+
|
|
105
|
+
Yields:
|
|
106
|
+
Token if available, None otherwise.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> with storage.sensitive_token("corporate") as token: # doctest: +SKIP
|
|
110
|
+
... if token:
|
|
111
|
+
... print(token.access_token)
|
|
112
|
+
... # token reference cleared here
|
|
113
|
+
"""
|
|
114
|
+
token = self.load(provider_name)
|
|
115
|
+
try:
|
|
116
|
+
yield token
|
|
117
|
+
finally:
|
|
118
|
+
del token
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class MemoryTokenStorage(AbstractTokenStorage):
|
|
122
|
+
"""In-memory token storage (for development/testing).
|
|
123
|
+
|
|
124
|
+
Tokens are stored in a dictionary and lost when the process exits.
|
|
125
|
+
No encryption or persistence.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self) -> None:
|
|
129
|
+
"""Initialize empty storage."""
|
|
130
|
+
self._tokens: dict[str, Token] = {}
|
|
131
|
+
|
|
132
|
+
def save(self, provider_name: str, token: Token) -> None:
|
|
133
|
+
"""Store token in memory."""
|
|
134
|
+
_validate_provider_name(provider_name)
|
|
135
|
+
self._tokens[provider_name] = token
|
|
136
|
+
logger.debug("Token saved in memory for provider '%s'", provider_name)
|
|
137
|
+
|
|
138
|
+
def load(self, provider_name: str) -> Token | None:
|
|
139
|
+
"""Retrieve token from memory."""
|
|
140
|
+
return self._tokens.get(provider_name)
|
|
141
|
+
|
|
142
|
+
def delete(self, provider_name: str) -> bool:
|
|
143
|
+
"""Remove token from memory."""
|
|
144
|
+
if provider_name in self._tokens:
|
|
145
|
+
del self._tokens[provider_name]
|
|
146
|
+
logger.debug("Token deleted from memory for provider '%s'", provider_name)
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
def exists(self, provider_name: str) -> bool:
|
|
151
|
+
"""Check if token exists in memory."""
|
|
152
|
+
return provider_name in self._tokens
|
|
153
|
+
|
|
154
|
+
def clear_all(self) -> None:
|
|
155
|
+
"""Clear all tokens from memory."""
|
|
156
|
+
self._tokens.clear()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FileTokenStorage(AbstractTokenStorage):
|
|
160
|
+
"""Plain JSON file token storage.
|
|
161
|
+
|
|
162
|
+
Tokens are stored as unencrypted JSON files with restrictive permissions (600).
|
|
163
|
+
Suitable for development, testing, or environments where SOPS is unavailable.
|
|
164
|
+
|
|
165
|
+
Warning:
|
|
166
|
+
Tokens are stored in plaintext. Use SOPS storage for production environments
|
|
167
|
+
where token confidentiality is critical.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
_warned: bool = False # Class-level flag for one-time warning
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
directory: Path | str | None = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""Initialize file storage.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
directory: Directory to store token files.
|
|
180
|
+
Default: ~/.config/kstlib/auth/tokens
|
|
181
|
+
"""
|
|
182
|
+
if directory is None:
|
|
183
|
+
directory = Path.home() / ".config" / "kstlib" / "auth" / "tokens"
|
|
184
|
+
self.directory = Path(directory)
|
|
185
|
+
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
186
|
+
|
|
187
|
+
def _token_path(self, provider_name: str) -> Path:
|
|
188
|
+
"""Get the file path for a provider's token."""
|
|
189
|
+
_validate_provider_name(provider_name)
|
|
190
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in provider_name)
|
|
191
|
+
return self.directory / f"{safe_name}.token.json"
|
|
192
|
+
|
|
193
|
+
def save(self, provider_name: str, token: Token) -> None:
|
|
194
|
+
"""Save token to JSON file with restrictive permissions."""
|
|
195
|
+
import stat
|
|
196
|
+
|
|
197
|
+
# One-time warning about unencrypted storage (only on save, not on read/delete)
|
|
198
|
+
if not FileTokenStorage._warned:
|
|
199
|
+
logger.warning(
|
|
200
|
+
"FileTokenStorage: Tokens will be stored UNENCRYPTED at %s. "
|
|
201
|
+
"Consider using 'sops' storage for sensitive environments.",
|
|
202
|
+
self.directory,
|
|
203
|
+
)
|
|
204
|
+
FileTokenStorage._warned = True
|
|
205
|
+
|
|
206
|
+
path = self._token_path(provider_name)
|
|
207
|
+
|
|
208
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
209
|
+
logger.log(TRACE_LEVEL, "[TOKEN] Saving to file: %s", path)
|
|
210
|
+
data = token.to_dict()
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# Write to file
|
|
214
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
215
|
+
|
|
216
|
+
# Set restrictive permissions (owner read/write only: 600)
|
|
217
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
218
|
+
|
|
219
|
+
logger.debug("Token saved (plaintext) for provider '%s': %s", provider_name, path)
|
|
220
|
+
except OSError as e:
|
|
221
|
+
msg = f"Failed to save token for '{provider_name}': {e}"
|
|
222
|
+
raise TokenStorageError(msg) from e
|
|
223
|
+
|
|
224
|
+
def load(self, provider_name: str) -> Token | None:
|
|
225
|
+
"""Load token from JSON file."""
|
|
226
|
+
path = self._token_path(provider_name)
|
|
227
|
+
if not path.exists():
|
|
228
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
229
|
+
logger.log(TRACE_LEVEL, "[TOKEN] File not found: %s", path)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
233
|
+
logger.log(TRACE_LEVEL, "[TOKEN] Loading from file: %s", path)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
237
|
+
return Token.from_dict(data)
|
|
238
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
239
|
+
logger.warning("Failed to parse token file for '%s': %s", provider_name, e)
|
|
240
|
+
return None
|
|
241
|
+
except OSError as e:
|
|
242
|
+
logger.warning("Failed to read token file for '%s': %s", provider_name, e)
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def delete(self, provider_name: str) -> bool:
|
|
246
|
+
"""Delete token file."""
|
|
247
|
+
path = self._token_path(provider_name)
|
|
248
|
+
if path.exists():
|
|
249
|
+
path.unlink()
|
|
250
|
+
logger.debug("Token file deleted for provider '%s'", provider_name)
|
|
251
|
+
return True
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
def exists(self, provider_name: str) -> bool:
|
|
255
|
+
"""Check if token file exists."""
|
|
256
|
+
return self._token_path(provider_name).exists()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class SOPSTokenStorage(AbstractTokenStorage):
|
|
260
|
+
"""SOPS-encrypted token storage.
|
|
261
|
+
|
|
262
|
+
Tokens are encrypted using SOPS before being written to disk.
|
|
263
|
+
Uses the SOPS CLI directly for encryption/decryption operations.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(
|
|
267
|
+
self,
|
|
268
|
+
directory: Path | str,
|
|
269
|
+
*,
|
|
270
|
+
sops_binary: str = "sops",
|
|
271
|
+
age_recipients: list[str] | None = None,
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Initialize SOPS storage.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
directory: Directory to store encrypted token files.
|
|
277
|
+
sops_binary: Path to sops binary (default: "sops").
|
|
278
|
+
age_recipients: Age public keys for encryption.
|
|
279
|
+
If not provided, relies on .sops.yaml or environment.
|
|
280
|
+
"""
|
|
281
|
+
import shutil
|
|
282
|
+
|
|
283
|
+
self.directory = Path(directory)
|
|
284
|
+
self.directory.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
285
|
+
self.sops_binary = shutil.which(sops_binary) or sops_binary
|
|
286
|
+
self.age_recipients = age_recipients
|
|
287
|
+
|
|
288
|
+
def _token_path(self, provider_name: str) -> Path:
|
|
289
|
+
"""Get the file path for a provider's encrypted token."""
|
|
290
|
+
_validate_provider_name(provider_name)
|
|
291
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in provider_name)
|
|
292
|
+
return self.directory / f"{safe_name}.token.sops.json"
|
|
293
|
+
|
|
294
|
+
def _run_sops(
|
|
295
|
+
self,
|
|
296
|
+
args: list[str],
|
|
297
|
+
*,
|
|
298
|
+
input_data: str | None = None,
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Run SOPS command and return output."""
|
|
301
|
+
import subprocess
|
|
302
|
+
|
|
303
|
+
cmd = [self.sops_binary, *args]
|
|
304
|
+
|
|
305
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
306
|
+
# Log command without sensitive data
|
|
307
|
+
safe_args = [a for a in args if not a.startswith("/")] # Redact paths
|
|
308
|
+
logger.log(TRACE_LEVEL, "[SOPS] Running: sops %s", " ".join(safe_args[:3]))
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
cmd,
|
|
313
|
+
input=input_data,
|
|
314
|
+
capture_output=True,
|
|
315
|
+
text=True,
|
|
316
|
+
check=True,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
320
|
+
logger.log(TRACE_LEVEL, "[SOPS] Command succeeded")
|
|
321
|
+
|
|
322
|
+
return result.stdout
|
|
323
|
+
except subprocess.CalledProcessError as e:
|
|
324
|
+
# Redact potentially sensitive output
|
|
325
|
+
stderr = e.stderr or ""
|
|
326
|
+
if "could not decrypt" in stderr.lower():
|
|
327
|
+
stderr = "Decryption failed (credentials/keys may be missing)"
|
|
328
|
+
msg = f"SOPS command failed: {stderr}"
|
|
329
|
+
raise TokenStorageError(msg) from e
|
|
330
|
+
except FileNotFoundError as e:
|
|
331
|
+
msg = f"SOPS binary not found at '{self.sops_binary}'"
|
|
332
|
+
raise TokenStorageError(msg) from e
|
|
333
|
+
|
|
334
|
+
def save(self, provider_name: str, token: Token) -> None:
|
|
335
|
+
"""Save token encrypted with SOPS."""
|
|
336
|
+
import tempfile
|
|
337
|
+
|
|
338
|
+
path = self._token_path(provider_name)
|
|
339
|
+
|
|
340
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
341
|
+
logger.log(TRACE_LEVEL, "[TOKEN] Encrypting and saving to: %s", path)
|
|
342
|
+
|
|
343
|
+
data = token.to_dict()
|
|
344
|
+
json_data = json.dumps(data, indent=2)
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
# Write plaintext to temp file, then encrypt to target
|
|
348
|
+
with tempfile.NamedTemporaryFile(
|
|
349
|
+
mode="w",
|
|
350
|
+
suffix=".json",
|
|
351
|
+
delete=False,
|
|
352
|
+
encoding="utf-8",
|
|
353
|
+
) as tmp:
|
|
354
|
+
tmp.write(json_data)
|
|
355
|
+
tmp_path = Path(tmp.name)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
from kstlib.secure.permissions import FilePermissions
|
|
359
|
+
|
|
360
|
+
# Remove existing file (READONLY can't be overwritten)
|
|
361
|
+
if path.exists():
|
|
362
|
+
path.chmod(FilePermissions.OWNER_RW) # Unlock for deletion
|
|
363
|
+
path.unlink()
|
|
364
|
+
|
|
365
|
+
# Build SOPS encrypt command
|
|
366
|
+
args = ["--encrypt", "--output", str(path)]
|
|
367
|
+
|
|
368
|
+
# Add age recipients if specified
|
|
369
|
+
if self.age_recipients:
|
|
370
|
+
args.extend(["--age", ",".join(self.age_recipients)])
|
|
371
|
+
|
|
372
|
+
args.append(str(tmp_path))
|
|
373
|
+
self._run_sops(args)
|
|
374
|
+
|
|
375
|
+
# Read-only: token files are immutable once written
|
|
376
|
+
path.chmod(FilePermissions.READONLY)
|
|
377
|
+
logger.debug("Token saved (SOPS encrypted) for provider '%s': %s", provider_name, path)
|
|
378
|
+
finally:
|
|
379
|
+
# Clean up temp file
|
|
380
|
+
tmp_path.unlink(missing_ok=True)
|
|
381
|
+
|
|
382
|
+
except Exception as e:
|
|
383
|
+
if isinstance(e, TokenStorageError):
|
|
384
|
+
raise
|
|
385
|
+
msg = f"Failed to save encrypted token for '{provider_name}': {e}"
|
|
386
|
+
raise TokenStorageError(msg) from e
|
|
387
|
+
|
|
388
|
+
def load(self, provider_name: str) -> Token | None:
|
|
389
|
+
"""Load and decrypt token from SOPS file."""
|
|
390
|
+
path = self._token_path(provider_name)
|
|
391
|
+
if not path.exists():
|
|
392
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
393
|
+
logger.log(TRACE_LEVEL, "[TOKEN] Encrypted file not found: %s", path)
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
if logger.isEnabledFor(TRACE_LEVEL):
|
|
397
|
+
logger.log(TRACE_LEVEL, "[TOKEN] Decrypting from: %s", path)
|
|
398
|
+
|
|
399
|
+
try:
|
|
400
|
+
decrypted = self._run_sops(["--decrypt", str(path)])
|
|
401
|
+
data = json.loads(decrypted)
|
|
402
|
+
return Token.from_dict(data)
|
|
403
|
+
except TokenStorageError:
|
|
404
|
+
logger.warning("Failed to decrypt token for '%s'", provider_name)
|
|
405
|
+
return None
|
|
406
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
407
|
+
logger.warning("Failed to parse decrypted token for '%s': %s", provider_name, e)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
def delete(self, provider_name: str) -> bool:
|
|
411
|
+
"""Delete encrypted token file."""
|
|
412
|
+
from kstlib.secure.permissions import FilePermissions
|
|
413
|
+
|
|
414
|
+
path = self._token_path(provider_name)
|
|
415
|
+
if path.exists():
|
|
416
|
+
path.chmod(FilePermissions.OWNER_RW) # Unlock READONLY file
|
|
417
|
+
path.unlink()
|
|
418
|
+
logger.debug("Encrypted token file deleted for provider '%s'", provider_name)
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
def exists(self, provider_name: str) -> bool:
|
|
423
|
+
"""Check if encrypted token file exists."""
|
|
424
|
+
return self._token_path(provider_name).exists()
|
|
425
|
+
|
|
426
|
+
@contextmanager
|
|
427
|
+
def sensitive_token(self, provider_name: str) -> Iterator[Token | None]:
|
|
428
|
+
"""Context manager for secure token access with cleanup."""
|
|
429
|
+
token = self.load(provider_name)
|
|
430
|
+
try:
|
|
431
|
+
yield token
|
|
432
|
+
finally:
|
|
433
|
+
# Clear reference (Python GC will handle the rest)
|
|
434
|
+
del token
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def get_token_storage(
|
|
438
|
+
storage_type: str = "memory",
|
|
439
|
+
*,
|
|
440
|
+
directory: Path | str | None = None,
|
|
441
|
+
**kwargs: Any,
|
|
442
|
+
) -> AbstractTokenStorage:
|
|
443
|
+
"""Factory function to create a token storage backend.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
storage_type: Type of storage ("memory", "file", or "sops").
|
|
447
|
+
directory: Directory for file/SOPS storage (default: ~/.config/kstlib/auth/tokens).
|
|
448
|
+
**kwargs: Additional arguments for SOPS storage (e.g., age_recipients).
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Token storage instance.
|
|
452
|
+
|
|
453
|
+
Raises:
|
|
454
|
+
ValueError: If storage_type is unknown.
|
|
455
|
+
|
|
456
|
+
Example:
|
|
457
|
+
>>> storage = get_token_storage("memory")
|
|
458
|
+
>>> storage = get_token_storage("file", directory="/tmp/tokens") # doctest: +SKIP
|
|
459
|
+
>>> storage = get_token_storage("sops", directory="/tmp/tokens") # doctest: +SKIP
|
|
460
|
+
"""
|
|
461
|
+
if storage_type == "memory":
|
|
462
|
+
return MemoryTokenStorage()
|
|
463
|
+
|
|
464
|
+
if storage_type == "file":
|
|
465
|
+
return FileTokenStorage(directory)
|
|
466
|
+
|
|
467
|
+
if storage_type == "sops":
|
|
468
|
+
if directory is None:
|
|
469
|
+
directory = Path.home() / ".config" / "kstlib" / "auth" / "tokens"
|
|
470
|
+
return SOPSTokenStorage(directory, **kwargs)
|
|
471
|
+
|
|
472
|
+
msg = f"Unknown storage type: {storage_type}. Use 'memory', 'file', or 'sops'."
|
|
473
|
+
raise ValueError(msg)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
__all__ = [
|
|
477
|
+
"AbstractTokenStorage",
|
|
478
|
+
"FileTokenStorage",
|
|
479
|
+
"MemoryTokenStorage",
|
|
480
|
+
"SOPSTokenStorage",
|
|
481
|
+
"get_token_storage",
|
|
482
|
+
]
|
kstlib/cache/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Cache module for kstlib.
|
|
2
|
+
|
|
3
|
+
Provides flexible caching decorators with multiple strategies:
|
|
4
|
+
- TTL (Time-To-Live) based caching
|
|
5
|
+
- LRU (Least Recently Used) caching
|
|
6
|
+
- File-based caching with mtime invalidation
|
|
7
|
+
- Full async/await support
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
Basic usage with default TTL strategy::
|
|
11
|
+
|
|
12
|
+
from kstlib.cache import cache
|
|
13
|
+
|
|
14
|
+
@cache
|
|
15
|
+
def expensive_function(x: int) -> int:
|
|
16
|
+
return x * 2
|
|
17
|
+
|
|
18
|
+
Async function caching::
|
|
19
|
+
|
|
20
|
+
@cache(ttl=60)
|
|
21
|
+
async def fetch_data(url: str) -> dict:
|
|
22
|
+
# Automatically detects async and handles appropriately
|
|
23
|
+
return await http_get(url)
|
|
24
|
+
|
|
25
|
+
LRU strategy::
|
|
26
|
+
|
|
27
|
+
@cache(strategy="lru", maxsize=256)
|
|
28
|
+
def compute_fibonacci(n: int) -> int:
|
|
29
|
+
if n < 2:
|
|
30
|
+
return n
|
|
31
|
+
return compute_fibonacci(n-1) + compute_fibonacci(n-2)
|
|
32
|
+
|
|
33
|
+
File-based caching with mtime checking::
|
|
34
|
+
|
|
35
|
+
@cache(strategy="file", check_mtime=True)
|
|
36
|
+
def load_config(path: str) -> dict:
|
|
37
|
+
# Cache invalidates automatically if file modified
|
|
38
|
+
return parse_yaml(path)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from kstlib.cache.decorator import cache
|
|
42
|
+
from kstlib.cache.strategies import CacheStrategy, FileCacheStrategy, LRUCacheStrategy, TTLCacheStrategy
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"CacheStrategy",
|
|
46
|
+
"FileCacheStrategy",
|
|
47
|
+
"LRUCacheStrategy",
|
|
48
|
+
"TTLCacheStrategy",
|
|
49
|
+
"cache",
|
|
50
|
+
]
|