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,62 @@
1
+ """Data models used by the secrets resolver."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping, MutableMapping
12
+ else: # pragma: no cover - runtime aliases for typing constructs
13
+ Mapping = typing.Mapping
14
+ MutableMapping = typing.MutableMapping
15
+
16
+
17
+ class SecretSource(str, Enum):
18
+ """Enumerates the possible origins for a resolved secret."""
19
+
20
+ KWARGS = "kwargs"
21
+ ENVIRONMENT = "environment"
22
+ KEYRING = "keyring"
23
+ SOPS = "sops"
24
+ KMS = "kms"
25
+ DEFAULT = "default"
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class SecretRequest:
30
+ """Describes a secret lookup request.
31
+
32
+ Attributes:
33
+ name: Identifier of the secret (e.g. "smtp.password").
34
+ scope: Optional scope that providers can exploit for namespacing.
35
+ required: Whether the resolver must raise if the secret is missing.
36
+ default: Optional fallback value when the secret is not required.
37
+ metadata: Arbitrary provider hints (e.g. keyring namespace).
38
+ """
39
+
40
+ name: str
41
+ scope: str | None = None
42
+ required: bool = True
43
+ default: Any | None = None
44
+ metadata: MutableMapping[str, Any] = field(default_factory=dict)
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class SecretRecord:
49
+ """Represents the value returned by the resolver.
50
+
51
+ Attributes:
52
+ value: The secret itself.
53
+ source: The origin of the secret.
54
+ metadata: Provider specific metadata (e.g. timestamp, path, ttl).
55
+ """
56
+
57
+ value: Any
58
+ source: SecretSource
59
+ metadata: Mapping[str, Any] = field(default_factory=dict)
60
+
61
+
62
+ __all__ = ["SecretRecord", "SecretRequest", "SecretSource"]
@@ -0,0 +1,79 @@
1
+ """Provider registry utilities for the secrets subsystem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from kstlib.secrets.providers.base import ProviderFactory, SecretProvider
8
+ from kstlib.utils.lazy import lazy_factory
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ _REGISTRY: dict[str, ProviderFactory] = {}
14
+
15
+
16
+ def register_provider(name: str, factory: ProviderFactory) -> None:
17
+ """Register a provider factory under the given name."""
18
+ _REGISTRY[name] = factory
19
+
20
+
21
+ def get_provider(name: str, **kwargs: Any) -> SecretProvider:
22
+ """Instantiate a provider by name."""
23
+ try:
24
+ factory = _REGISTRY[name]
25
+ except KeyError as exc: # pragma: no cover - defensive branch
26
+ raise ValueError(f"Unknown secret provider '{name}'") from exc
27
+ return factory(**kwargs)
28
+
29
+
30
+ def configure_provider(provider: SecretProvider, settings: Mapping[str, Any] | None) -> SecretProvider:
31
+ """Apply provider-specific configuration and return the provider."""
32
+ provider.configure(settings)
33
+ return provider
34
+
35
+
36
+ __all__ = [
37
+ "ProviderFactory",
38
+ "SecretProvider",
39
+ "configure_provider",
40
+ "get_provider",
41
+ "register_provider",
42
+ ]
43
+
44
+
45
+ # --- Lazy-loaded provider factories ---
46
+ # Providers are only imported when their factory is called, not at module load.
47
+ # Body is replaced by the decorator; type: ignore[empty-body] silences mypy.
48
+
49
+
50
+ @lazy_factory("kstlib.secrets.providers.kwargs", "KwargsProvider")
51
+ def _kwargs_factory(**_kwargs: Any) -> SecretProvider: # type: ignore[empty-body]
52
+ ... # pragma: no cover - body replaced by decorator
53
+
54
+
55
+ @lazy_factory("kstlib.secrets.providers.environment", "EnvironmentProvider")
56
+ def _environment_factory(**_kwargs: Any) -> SecretProvider: # type: ignore[empty-body]
57
+ ... # pragma: no cover - body replaced by decorator
58
+
59
+
60
+ @lazy_factory("kstlib.secrets.providers.keyring", "KeyringProvider")
61
+ def _keyring_factory(**_kwargs: Any) -> SecretProvider: # type: ignore[empty-body]
62
+ ... # pragma: no cover - body replaced by decorator
63
+
64
+
65
+ @lazy_factory("kstlib.secrets.providers.sops", "SOPSProvider")
66
+ def _sops_factory(**_kwargs: Any) -> SecretProvider: # type: ignore[empty-body]
67
+ ... # pragma: no cover - body replaced by decorator
68
+
69
+
70
+ @lazy_factory("kstlib.secrets.providers.kms", "KMSProvider")
71
+ def _kms_factory(**_kwargs: Any) -> SecretProvider: # type: ignore[empty-body]
72
+ ... # pragma: no cover - body replaced by decorator
73
+
74
+
75
+ register_provider("kwargs", _kwargs_factory)
76
+ register_provider("environment", _environment_factory)
77
+ register_provider("keyring", _keyring_factory)
78
+ register_provider("sops", _sops_factory)
79
+ register_provider("kms", _kms_factory)
@@ -0,0 +1,58 @@
1
+ """Provider base classes and registry helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # pylint: disable=unnecessary-ellipsis,too-few-public-methods
6
+ import asyncio
7
+ from abc import ABC, abstractmethod
8
+ from typing import TYPE_CHECKING, Any, Protocol
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Mapping
12
+
13
+ from kstlib.secrets.models import SecretRecord, SecretRequest
14
+
15
+
16
+ class SecretProvider(ABC):
17
+ """Abstract provider responsible for retrieving secrets from a backend."""
18
+
19
+ name: str = "provider"
20
+
21
+ @abstractmethod
22
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
23
+ """Retrieve the secret synchronously.
24
+
25
+ Args:
26
+ request: Details about the requested secret.
27
+
28
+ Returns:
29
+ A ``SecretRecord`` if the provider can handle the request, otherwise
30
+ ``None``.
31
+ """
32
+
33
+ async def resolve_async(self, request: SecretRequest) -> SecretRecord | None:
34
+ """Async-friendly hook.
35
+
36
+ Providers may override this method when native async support is
37
+ available. The default implementation delegates to ``resolve`` using
38
+ ``asyncio.to_thread`` to avoid blocking the event loop.
39
+ """
40
+ return await asyncio.to_thread(self.resolve, request)
41
+
42
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
43
+ """Apply provider specific configuration settings.
44
+
45
+ Subclasses can override to handle provider-level configuration. The
46
+ default implementation ignores the settings.
47
+ """
48
+ if not settings:
49
+ return
50
+ _ = settings
51
+
52
+
53
+ class ProviderFactory(Protocol):
54
+ """Protocol describing callables that build providers."""
55
+
56
+ def __call__(self, **kwargs: Any) -> SecretProvider: # pragma: no cover - protocol definition
57
+ """Return a configured provider instance."""
58
+ ...
@@ -0,0 +1,66 @@
1
+ """Environment variable provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import typing
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
10
+ from kstlib.secrets.providers.base import SecretProvider
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Mapping
14
+ else: # pragma: no cover - runtime alias for typing constructs
15
+ Mapping = typing.Mapping
16
+
17
+
18
+ class EnvironmentProvider(SecretProvider):
19
+ """Resolve secrets from process environment variables."""
20
+
21
+ name = "environment"
22
+
23
+ def __init__(self, *, prefix: str = "KSTLIB_", delimiter: str = "__") -> None:
24
+ """Configure the provider prefix and delimiter."""
25
+ self._prefix = prefix
26
+ self._delimiter = delimiter
27
+
28
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
29
+ """Apply settings overrides coming from configuration files.
30
+
31
+ Args:
32
+ settings: Optional mapping that may provide ``prefix`` or
33
+ ``delimiter`` keys overriding the defaults.
34
+ """
35
+ if not settings:
36
+ return
37
+ self._prefix = settings.get("prefix", self._prefix)
38
+ self._delimiter = settings.get("delimiter", self._delimiter)
39
+
40
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
41
+ """Resolve a secret using the environment cascade.
42
+
43
+ Args:
44
+ request: Secret descriptor describing scope and key.
45
+
46
+ Returns:
47
+ A ``SecretRecord`` populated from the environment when present,
48
+ otherwise ``None``.
49
+ """
50
+ env_key = self._build_env_key(request)
51
+ value = os.getenv(env_key)
52
+ if value is None:
53
+ return None
54
+ return SecretRecord(value=value, source=SecretSource.ENVIRONMENT, metadata={"env_key": env_key})
55
+
56
+ def _build_env_key(self, request: SecretRequest) -> str:
57
+ """Construct the canonical environment variable name for a request."""
58
+ parts: list[str] = [self._prefix.rstrip(self._delimiter)]
59
+ if request.scope:
60
+ parts.append(request.scope)
61
+ parts.append(request.name)
62
+ env_key = self._delimiter.join(part.upper().replace(".", self._delimiter) for part in parts if part)
63
+ return env_key
64
+
65
+
66
+ __all__ = ["EnvironmentProvider"]
@@ -0,0 +1,107 @@
1
+ """Keyring-backed provider."""
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
+ # pylint: disable=invalid-name
17
+ keyring_backend: Any | None
18
+ try: # pragma: no cover - optional dependency
19
+ import keyring as keyring_module # type: ignore[import-not-found]
20
+ except ModuleNotFoundError: # pragma: no cover - fallback when keyring absent
21
+ keyring_backend = None
22
+ else:
23
+ keyring_backend = keyring_module
24
+
25
+
26
+ class KeyringProvider(SecretProvider):
27
+ """Retrieve secrets from the system keyring."""
28
+
29
+ name = "keyring"
30
+
31
+ def __init__(self, *, service: str = "kstlib") -> None:
32
+ """Instantiate the provider with an optional service namespace."""
33
+ self._service = service
34
+
35
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
36
+ """Load configuration overrides into the provider.
37
+
38
+ Args:
39
+ settings: Optional mapping with a ``service`` key overriding the
40
+ default keyring service name.
41
+ """
42
+ if not settings:
43
+ return
44
+ self._service = settings.get("service", self._service)
45
+
46
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
47
+ """Retrieve a secret from the backing keyring.
48
+
49
+ Args:
50
+ request: Secret lookup description provided by the resolver.
51
+
52
+ Returns:
53
+ A populated ``SecretRecord`` when the secret exists, otherwise
54
+ ``None`` to signal a miss.
55
+ """
56
+ if keyring_backend is None:
57
+ return None
58
+ username = self._username_for(request)
59
+ value = keyring_backend.get_password(self._service, username)
60
+ if value is None:
61
+ return None
62
+ metadata = {"service": self._service, "username": username}
63
+ return SecretRecord(value=value, source=SecretSource.KEYRING, metadata=metadata)
64
+
65
+ def store(self, request: SecretRequest, value: str) -> None:
66
+ """Persist a secret to the keyring backend.
67
+
68
+ Args:
69
+ request: Secret descriptor used to derive a keyring username.
70
+ value: Plaintext secret that should be stored.
71
+
72
+ Raises:
73
+ RuntimeError: If the optional ``keyring`` dependency is missing.
74
+ """
75
+ if keyring_backend is None:
76
+ raise RuntimeError("keyring package is not available")
77
+ username = self._username_for(request)
78
+ keyring_backend.set_password(self._service, username, value)
79
+
80
+ def delete(self, request: SecretRequest) -> None:
81
+ """Remove a secret from the keyring backend.
82
+
83
+ Args:
84
+ request: Secret descriptor used to derive a keyring username.
85
+
86
+ Raises:
87
+ RuntimeError: If the optional ``keyring`` dependency is missing.
88
+ """
89
+ if keyring_backend is None:
90
+ raise RuntimeError("keyring package is not available")
91
+ username = self._username_for(request)
92
+ keyring_backend.delete_password(self._service, username)
93
+
94
+ def _username_for(self, request: SecretRequest) -> str:
95
+ """Compute a stable username for the keyring entry.
96
+
97
+ Args:
98
+ request: Secret descriptor providing scope and name details.
99
+
100
+ Returns:
101
+ The composed username used for keyring operations.
102
+ """
103
+ scope = request.scope or "default"
104
+ return f"{scope}:{request.name}"
105
+
106
+
107
+ __all__ = ["KeyringProvider"]
@@ -0,0 +1,223 @@
1
+ """AWS KMS-backed secret provider.
2
+
3
+ This provider uses AWS KMS to encrypt/decrypt secret values directly.
4
+ It supports both real AWS KMS and LocalStack for local development.
5
+
6
+ Note: For SOPS-managed files with KMS encryption, use SOPSProvider instead.
7
+ This provider is for direct KMS encrypt/decrypt operations.
8
+ """
9
+
10
+ # pylint: disable=too-many-arguments
11
+ # Justification: __init__ takes standard AWS config params (key_id, region,
12
+ # endpoint, access_key, secret_key) - this is the canonical AWS client pattern.
13
+ # Wrapping in a dataclass would add indirection without benefit.
14
+
15
+ # pylint: disable=broad-exception-caught
16
+ # Justification: False positive - ClientError is a fallback to Exception only when
17
+ # boto3 is not installed (line 34). At runtime with boto3, we catch the real
18
+ # botocore.exceptions.ClientError, not the generic Exception.
19
+
20
+ from __future__ import annotations
21
+
22
+ import base64
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from kstlib.logging import get_logger
26
+ from kstlib.secrets.exceptions import SecretDecryptionError
27
+ from kstlib.secrets.models import SecretRecord, SecretRequest, SecretSource
28
+ from kstlib.secrets.providers.base import SecretProvider
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Mapping
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ # boto3 is optional - only needed when KMS provider is used
36
+ try:
37
+ import boto3 # type: ignore[import-not-found]
38
+ from botocore.exceptions import ClientError # type: ignore[import-not-found]
39
+
40
+ _HAS_BOTO3 = True
41
+ except ImportError: # pragma: no cover
42
+ _HAS_BOTO3 = False
43
+ boto3 = None # type: ignore[assignment]
44
+ ClientError = Exception # type: ignore[misc,assignment]
45
+
46
+
47
+ class KMSProvider(SecretProvider):
48
+ """Load and decrypt secrets using AWS KMS.
49
+
50
+ This provider can:
51
+ - Decrypt base64-encoded ciphertext stored in environment variables or config
52
+ - Store encrypted values using KMS encrypt operation
53
+
54
+ Unlike SOPSProvider which decrypts entire files, KMSProvider works with
55
+ individual secret values that have been encrypted with KMS.
56
+
57
+ Example:
58
+ >>> from kstlib.secrets.providers.kms import KMSProvider
59
+ >>> provider = KMSProvider(
60
+ ... key_id="alias/my-key",
61
+ ... endpoint_url="http://localhost:4566", # LocalStack
62
+ ... )
63
+ >>> # provider.resolve(SecretRequest(name="db.password", metadata={"ciphertext": "..."}))
64
+ """
65
+
66
+ name = "kms"
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ key_id: str | None = None,
72
+ region_name: str = "us-east-1",
73
+ endpoint_url: str | None = None,
74
+ aws_access_key_id: str | None = None,
75
+ aws_secret_access_key: str | None = None,
76
+ ) -> None:
77
+ """Configure the KMS provider.
78
+
79
+ Args:
80
+ key_id: Default KMS key ID or alias (e.g., "alias/kstlib-test").
81
+ region_name: AWS region (default: us-east-1).
82
+ endpoint_url: Custom endpoint for LocalStack (e.g., "http://localhost:4566").
83
+ aws_access_key_id: AWS access key (optional, uses default credential chain).
84
+ aws_secret_access_key: AWS secret key (optional, uses default credential chain).
85
+ """
86
+ self._key_id = key_id
87
+ self._region_name = region_name
88
+ self._endpoint_url = endpoint_url
89
+ self._aws_access_key_id = aws_access_key_id
90
+ self._aws_secret_access_key = aws_secret_access_key
91
+ self._client: Any = None
92
+
93
+ def configure(self, settings: Mapping[str, Any] | None = None) -> None:
94
+ """Apply configuration from settings mapping."""
95
+ if not settings:
96
+ return
97
+ if "key_id" in settings:
98
+ self._key_id = settings["key_id"]
99
+ if "region_name" in settings:
100
+ self._region_name = settings["region_name"]
101
+ if "endpoint_url" in settings:
102
+ self._endpoint_url = settings["endpoint_url"]
103
+ if "aws_access_key_id" in settings:
104
+ self._aws_access_key_id = settings["aws_access_key_id"]
105
+ if "aws_secret_access_key" in settings:
106
+ self._aws_secret_access_key = settings["aws_secret_access_key"]
107
+ # Reset client to pick up new config
108
+ self._client = None
109
+
110
+ def _get_client(self) -> Any:
111
+ """Lazily initialize and return the KMS client."""
112
+ if self._client is not None:
113
+ return self._client
114
+
115
+ if not _HAS_BOTO3:
116
+ raise SecretDecryptionError("boto3 is required for KMS provider. Install with: pip install boto3")
117
+
118
+ client_kwargs: dict[str, Any] = {
119
+ "service_name": "kms",
120
+ "region_name": self._region_name,
121
+ }
122
+ if self._endpoint_url:
123
+ client_kwargs["endpoint_url"] = self._endpoint_url
124
+ if self._aws_access_key_id:
125
+ client_kwargs["aws_access_key_id"] = self._aws_access_key_id
126
+ if self._aws_secret_access_key:
127
+ client_kwargs["aws_secret_access_key"] = self._aws_secret_access_key
128
+
129
+ self._client = boto3.client(**client_kwargs) # type: ignore[union-attr]
130
+ return self._client
131
+
132
+ def resolve(self, request: SecretRequest) -> SecretRecord | None:
133
+ """Resolve a secret by decrypting KMS ciphertext.
134
+
135
+ The ciphertext can be provided in request metadata as:
136
+ - "ciphertext": base64-encoded encrypted data
137
+ - "ciphertext_blob": raw bytes (less common)
138
+
139
+ If no ciphertext is provided, returns None (allowing fallback to other providers).
140
+ """
141
+ metadata = request.metadata or {}
142
+ ciphertext_b64 = metadata.get("ciphertext")
143
+ ciphertext_blob = metadata.get("ciphertext_blob")
144
+
145
+ if ciphertext_b64 is None and ciphertext_blob is None:
146
+ return None
147
+
148
+ try:
149
+ if ciphertext_b64:
150
+ ciphertext_blob = base64.b64decode(ciphertext_b64)
151
+
152
+ client = self._get_client()
153
+ response = client.decrypt(CiphertextBlob=ciphertext_blob)
154
+ plaintext = response["Plaintext"]
155
+
156
+ # Decode if bytes
157
+ if isinstance(plaintext, bytes):
158
+ plaintext = plaintext.decode("utf-8")
159
+
160
+ return SecretRecord(
161
+ value=plaintext,
162
+ source=SecretSource.KMS,
163
+ metadata={
164
+ "key_id": response.get("KeyId", self._key_id),
165
+ "region": self._region_name,
166
+ "endpoint": self._endpoint_url,
167
+ },
168
+ )
169
+ except ClientError as exc:
170
+ error_code = exc.response.get("Error", {}).get("Code", "Unknown") # type: ignore[union-attr]
171
+ logger.debug("KMS decryption failed for %s: %s", request.name, error_code)
172
+ raise SecretDecryptionError(f"Failed to decrypt secret '{request.name}': {error_code}") from exc
173
+
174
+ def encrypt(self, plaintext: str | bytes, *, key_id: str | None = None) -> str:
175
+ """Encrypt a plaintext value and return base64-encoded ciphertext.
176
+
177
+ Args:
178
+ plaintext: The value to encrypt (string or bytes).
179
+ key_id: KMS key ID or alias. If not provided, uses the default key_id.
180
+
181
+ Returns:
182
+ Base64-encoded ciphertext that can be decrypted later.
183
+
184
+ Raises:
185
+ SecretDecryptionError: If encryption fails or no key_id is configured.
186
+ """
187
+ effective_key_id = key_id or self._key_id
188
+ if not effective_key_id:
189
+ raise SecretDecryptionError("No KMS key_id configured for encryption")
190
+
191
+ if isinstance(plaintext, str):
192
+ plaintext = plaintext.encode("utf-8")
193
+
194
+ try:
195
+ client = self._get_client()
196
+ response = client.encrypt(KeyId=effective_key_id, Plaintext=plaintext)
197
+ ciphertext_blob = response["CiphertextBlob"]
198
+ return base64.b64encode(ciphertext_blob).decode("ascii")
199
+ except ClientError as exc:
200
+ error_code = exc.response.get("Error", {}).get("Code", "Unknown") # type: ignore[union-attr]
201
+ raise SecretDecryptionError(f"Failed to encrypt: {error_code}") from exc
202
+
203
+ def is_available(self) -> bool:
204
+ """Check if KMS is reachable and the configured key exists.
205
+
206
+ Returns:
207
+ True if KMS is accessible and the key can be used.
208
+ """
209
+ if not _HAS_BOTO3:
210
+ return False
211
+
212
+ if not self._key_id:
213
+ return False
214
+
215
+ try:
216
+ client = self._get_client()
217
+ client.describe_key(KeyId=self._key_id)
218
+ except ClientError:
219
+ return False
220
+ return True
221
+
222
+
223
+ __all__ = ["KMSProvider"]