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/secrets/models.py
ADDED
|
@@ -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"]
|