vaultriever 0.1.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.
- vaultriever/__init__.py +33 -0
- vaultriever/exceptions.py +27 -0
- vaultriever/logging.py +16 -0
- vaultriever/providers/__init__.py +22 -0
- vaultriever/providers/aws.py +79 -0
- vaultriever/providers/azure.py +19 -0
- vaultriever/providers/base.py +60 -0
- vaultriever/providers/databricks.py +95 -0
- vaultriever/providers/gcp.py +19 -0
- vaultriever/py.typed +0 -0
- vaultriever/resolver.py +30 -0
- vaultriever/settings_mixin.py +82 -0
- vaultriever/sri.py +92 -0
- vaultriever-0.1.0.dist-info/METADATA +154 -0
- vaultriever-0.1.0.dist-info/RECORD +17 -0
- vaultriever-0.1.0.dist-info/WHEEL +4 -0
- vaultriever-0.1.0.dist-info/licenses/LICENSE +21 -0
vaultriever/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""vaultriever: retrieve secrets in an ARN-like way from different vaults.
|
|
2
|
+
|
|
3
|
+
Secrets are addressed with a Secret Resource Identifier (SRI)::
|
|
4
|
+
|
|
5
|
+
provider:region:secret_name:secret_key
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from vaultriever.exceptions import (
|
|
9
|
+
DatabricksConfigurationError,
|
|
10
|
+
ProviderNotRegisteredError,
|
|
11
|
+
SecretRetrievalError,
|
|
12
|
+
SRIParseError,
|
|
13
|
+
VaultrieverError,
|
|
14
|
+
)
|
|
15
|
+
from vaultriever.providers import SecretProvider, SecretProviderRegistry
|
|
16
|
+
from vaultriever.resolver import resolve_secret
|
|
17
|
+
from vaultriever.settings_mixin import SecretSRIMixin
|
|
18
|
+
from vaultriever.sri import SecretProperties, is_sri, parse_sri
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
'DatabricksConfigurationError',
|
|
22
|
+
'ProviderNotRegisteredError',
|
|
23
|
+
'SecretProperties',
|
|
24
|
+
'SecretProvider',
|
|
25
|
+
'SecretProviderRegistry',
|
|
26
|
+
'SecretRetrievalError',
|
|
27
|
+
'SecretSRIMixin',
|
|
28
|
+
'SRIParseError',
|
|
29
|
+
'VaultrieverError',
|
|
30
|
+
'is_sri',
|
|
31
|
+
'parse_sri',
|
|
32
|
+
'resolve_secret',
|
|
33
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Package-specific exceptions.
|
|
2
|
+
|
|
3
|
+
Error messages must never contain secret values. Including the SRI's
|
|
4
|
+
secret_name/region is fine; the resolved value is not.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VaultrieverError(Exception):
|
|
11
|
+
"""Base class for all vaultriever errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SRIParseError(VaultrieverError, ValueError):
|
|
15
|
+
"""Raised when a string is not a valid Secret Resource Identifier."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProviderNotRegisteredError(VaultrieverError, LookupError):
|
|
19
|
+
"""Raised when an SRI references a provider that is not registered."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretRetrievalError(VaultrieverError):
|
|
23
|
+
"""Raised when a provider fails to retrieve or decode a secret."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatabricksConfigurationError(SecretRetrievalError):
|
|
27
|
+
"""Raised when the Databricks provider cannot build a usable context."""
|
vaultriever/logging.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Logger helper.
|
|
2
|
+
|
|
3
|
+
All modules obtain their logger through :func:`get_logger` so the package
|
|
4
|
+
shares a single ``vaultriever`` namespace and users can configure it once.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_logger(name: str) -> logging.Logger:
|
|
13
|
+
"""Return a logger under the ``vaultriever`` namespace."""
|
|
14
|
+
if name == 'vaultriever' or name.startswith('vaultriever.'):
|
|
15
|
+
return logging.getLogger(name)
|
|
16
|
+
return logging.getLogger(f'vaultriever.{name}')
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Secret providers and their registry.
|
|
2
|
+
|
|
3
|
+
Default providers are registered at import time. Provider SDK dependencies
|
|
4
|
+
(boto3, databricks-sdk) are imported lazily on first use, so registration is
|
|
5
|
+
safe even when the corresponding extra is not installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from vaultriever.providers.aws import AWSSecretProvider
|
|
11
|
+
from vaultriever.providers.base import SecretProvider, SecretProviderRegistry
|
|
12
|
+
from vaultriever.providers.databricks import DatabricksSecretProvider
|
|
13
|
+
|
|
14
|
+
SecretProviderRegistry.register(AWSSecretProvider())
|
|
15
|
+
SecretProviderRegistry.register(DatabricksSecretProvider())
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
'AWSSecretProvider',
|
|
19
|
+
'DatabricksSecretProvider',
|
|
20
|
+
'SecretProvider',
|
|
21
|
+
'SecretProviderRegistry',
|
|
22
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""AWS Secrets Manager provider.
|
|
2
|
+
|
|
3
|
+
SRI semantics: ``aws:<region>:<secret_name>:<json_key>``. The secret's
|
|
4
|
+
``SecretString`` must be a JSON object; ``json_key`` selects one of its keys.
|
|
5
|
+
Credentials are resolved by the AWS SDK default chain (env vars, profile,
|
|
6
|
+
IAM role).
|
|
7
|
+
|
|
8
|
+
Requires the ``aws`` extra: ``pip install vaultriever[aws]``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from vaultriever.exceptions import SecretRetrievalError
|
|
18
|
+
from vaultriever.logging import get_logger
|
|
19
|
+
from vaultriever.sri import SecretProperties
|
|
20
|
+
|
|
21
|
+
logger = get_logger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@lru_cache
|
|
25
|
+
def _get_aws_secret_json(secret_name: str, region_name: str) -> dict[str, Any]:
|
|
26
|
+
"""Fetch and decode a JSON secret, caching by (secret_name, region)."""
|
|
27
|
+
try:
|
|
28
|
+
import boto3
|
|
29
|
+
except ImportError as exc: # pragma: no cover - depends on install extras
|
|
30
|
+
raise SecretRetrievalError(
|
|
31
|
+
"boto3 is required for the 'aws' provider. Install with: pip install vaultriever[aws]"
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
logger.debug('Fetching AWS secret %r in region %r', secret_name, region_name)
|
|
35
|
+
client = boto3.Session().client('secretsmanager', region_name=region_name)
|
|
36
|
+
try:
|
|
37
|
+
response = client.get_secret_value(SecretId=secret_name)
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
raise SecretRetrievalError(
|
|
40
|
+
f'Failed to retrieve AWS secret {secret_name!r} in region {region_name!r}: '
|
|
41
|
+
f'{type(exc).__name__}'
|
|
42
|
+
) from exc
|
|
43
|
+
|
|
44
|
+
secret_string = response.get('SecretString')
|
|
45
|
+
if secret_string is None:
|
|
46
|
+
raise SecretRetrievalError(
|
|
47
|
+
f'AWS secret {secret_name!r} has no SecretString (binary secrets are not supported)'
|
|
48
|
+
)
|
|
49
|
+
try:
|
|
50
|
+
data = json.loads(secret_string)
|
|
51
|
+
except json.JSONDecodeError as exc:
|
|
52
|
+
raise SecretRetrievalError(f'AWS secret {secret_name!r} is not valid JSON') from exc
|
|
53
|
+
if not isinstance(data, dict):
|
|
54
|
+
raise SecretRetrievalError(f'AWS secret {secret_name!r} is not a JSON object')
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AWSSecretProvider:
|
|
59
|
+
"""Retrieve keys from JSON secrets stored in AWS Secrets Manager."""
|
|
60
|
+
|
|
61
|
+
name = 'aws'
|
|
62
|
+
|
|
63
|
+
def get_secret_value(self, props: SecretProperties) -> Any:
|
|
64
|
+
if not props.region:
|
|
65
|
+
raise SecretRetrievalError(
|
|
66
|
+
"AWS SRIs require a non-empty region, e.g. 'aws:us-east-1:my-secret:MY_KEY'"
|
|
67
|
+
)
|
|
68
|
+
data = _get_aws_secret_json(props.secret_name, props.region)
|
|
69
|
+
try:
|
|
70
|
+
return data[props.secret_key]
|
|
71
|
+
except KeyError:
|
|
72
|
+
raise SecretRetrievalError(
|
|
73
|
+
f'Key {props.secret_key!r} not found in AWS secret {props.secret_name!r}'
|
|
74
|
+
) from None
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def clear_cache() -> None:
|
|
78
|
+
"""Clear the cached secret payloads (e.g. after rotation)."""
|
|
79
|
+
_get_aws_secret_json.cache_clear()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Azure Key Vault provider (stub).
|
|
2
|
+
|
|
3
|
+
Intended SRI semantics: ``azure:<vault_or_region>:<secret_name>:<key_or_version>``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from vaultriever.sri import SecretProperties
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AzureSecretProvider:
|
|
14
|
+
"""Placeholder provider; not implemented yet."""
|
|
15
|
+
|
|
16
|
+
name = 'azure'
|
|
17
|
+
|
|
18
|
+
def get_secret_value(self, props: SecretProperties) -> Any:
|
|
19
|
+
raise NotImplementedError('Azure provider not implemented yet.')
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Provider interface and registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from vaultriever.exceptions import ProviderNotRegisteredError
|
|
8
|
+
from vaultriever.logging import get_logger
|
|
9
|
+
from vaultriever.sri import SecretProperties
|
|
10
|
+
|
|
11
|
+
logger = get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@runtime_checkable
|
|
15
|
+
class SecretProvider(Protocol):
|
|
16
|
+
"""Minimal interface every secret provider must implement."""
|
|
17
|
+
|
|
18
|
+
name: str # e.g. "aws", "databricks"
|
|
19
|
+
|
|
20
|
+
def get_secret_value(self, props: SecretProperties) -> Any: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SecretProviderRegistry:
|
|
24
|
+
"""Registry of available secret providers, keyed by provider name."""
|
|
25
|
+
|
|
26
|
+
_providers: dict[str, SecretProvider] = {}
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def register(cls, provider: SecretProvider) -> None:
|
|
30
|
+
"""Register a provider under its ``name``, replacing any existing one."""
|
|
31
|
+
logger.debug('Registering secret provider %r', provider.name)
|
|
32
|
+
cls._providers[provider.name] = provider
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def get(cls, name: str) -> SecretProvider:
|
|
36
|
+
"""Return the provider registered under ``name``.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ProviderNotRegisteredError: If no provider is registered.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
return cls._providers[name]
|
|
43
|
+
except KeyError:
|
|
44
|
+
raise ProviderNotRegisteredError(
|
|
45
|
+
f'No secret provider registered for {name!r}. '
|
|
46
|
+
f'Available providers: {cls.available_providers()}'
|
|
47
|
+
) from None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def unregister(cls, name: str) -> None:
|
|
51
|
+
"""Remove a provider from the registry; no-op if not registered."""
|
|
52
|
+
cls._providers.pop(name, None)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def is_registered(cls, name: str) -> bool:
|
|
56
|
+
return name in cls._providers
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def available_providers(cls) -> list[str]:
|
|
60
|
+
return sorted(cls._providers)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Databricks secrets provider.
|
|
2
|
+
|
|
3
|
+
SRI semantics: ``databricks:<profile>:<scope>:<key>``.
|
|
4
|
+
|
|
5
|
+
- Empty region (``databricks::my-scope:MY_KEY``) -> default workspace: the
|
|
6
|
+
runtime ``dbutils`` when running on Databricks, otherwise the SDK's default
|
|
7
|
+
authentication.
|
|
8
|
+
- Non-empty region -> used as the Databricks CLI profile name, e.g.
|
|
9
|
+
``databricks:staging:my-scope:MY_KEY`` reads via the ``staging`` profile.
|
|
10
|
+
|
|
11
|
+
Requires the ``databricks`` extra outside a Databricks runtime:
|
|
12
|
+
``pip install vaultriever[databricks]``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from vaultriever.exceptions import DatabricksConfigurationError, SecretRetrievalError
|
|
21
|
+
from vaultriever.logging import get_logger
|
|
22
|
+
from vaultriever.sri import SecretProperties
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DatabricksContext:
|
|
29
|
+
"""Where to read Databricks secrets from. ``profile=None`` means default."""
|
|
30
|
+
|
|
31
|
+
profile: str | None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_databricks_context(region: str | None) -> DatabricksContext:
|
|
35
|
+
"""Map the SRI region component to a Databricks context.
|
|
36
|
+
|
|
37
|
+
An empty/None region selects the default workspace; anything else is
|
|
38
|
+
interpreted as a Databricks CLI profile name.
|
|
39
|
+
"""
|
|
40
|
+
if not region:
|
|
41
|
+
return DatabricksContext(profile=None)
|
|
42
|
+
return DatabricksContext(profile=region)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_secret(context: DatabricksContext, scope: str, key: str) -> str:
|
|
46
|
+
"""Fetch a secret for the given context. Split out so tests can patch it."""
|
|
47
|
+
if context.profile is None:
|
|
48
|
+
# On a Databricks runtime this is the native dbutils; elsewhere the
|
|
49
|
+
# SDK falls back to default authentication.
|
|
50
|
+
try:
|
|
51
|
+
from databricks.sdk.runtime import dbutils
|
|
52
|
+
|
|
53
|
+
return str(dbutils.secrets.get(scope=scope, key=key))
|
|
54
|
+
except ImportError:
|
|
55
|
+
try:
|
|
56
|
+
from databricks.sdk import WorkspaceClient
|
|
57
|
+
except ImportError as import_exc:
|
|
58
|
+
raise DatabricksConfigurationError(
|
|
59
|
+
"databricks-sdk is required for the 'databricks' provider. "
|
|
60
|
+
'Install with: pip install vaultriever[databricks]'
|
|
61
|
+
) from import_exc
|
|
62
|
+
|
|
63
|
+
client = WorkspaceClient()
|
|
64
|
+
return str(client.dbutils.secrets.get(scope=scope, key=key))
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
from databricks.sdk import WorkspaceClient
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
raise DatabricksConfigurationError(
|
|
70
|
+
"databricks-sdk is required for the 'databricks' provider. "
|
|
71
|
+
'Install with: pip install vaultriever[databricks]'
|
|
72
|
+
) from exc
|
|
73
|
+
client = WorkspaceClient(profile=context.profile)
|
|
74
|
+
return str(client.dbutils.secrets.get(scope=scope, key=key))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DatabricksSecretProvider:
|
|
78
|
+
"""Retrieve secrets from Databricks secret scopes."""
|
|
79
|
+
|
|
80
|
+
name = 'databricks'
|
|
81
|
+
|
|
82
|
+
def get_secret_value(self, props: SecretProperties) -> Any:
|
|
83
|
+
context = resolve_databricks_context(props.region)
|
|
84
|
+
logger.debug(
|
|
85
|
+
'Fetching Databricks secret scope=%r profile=%r', props.secret_name, context.profile
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
return _get_secret(context, props.secret_name, props.secret_key)
|
|
89
|
+
except SecretRetrievalError:
|
|
90
|
+
raise
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise SecretRetrievalError(
|
|
93
|
+
f'Failed to retrieve Databricks secret scope={props.secret_name!r} '
|
|
94
|
+
f'key={props.secret_key!r}: {type(exc).__name__}'
|
|
95
|
+
) from exc
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""GCP Secret Manager provider (stub).
|
|
2
|
+
|
|
3
|
+
Intended SRI semantics: ``gcp:<project_or_region>:<secret_name>:<key_or_version>``.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from vaultriever.sri import SecretProperties
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GCPSecretProvider:
|
|
14
|
+
"""Placeholder provider; not implemented yet."""
|
|
15
|
+
|
|
16
|
+
name = 'gcp'
|
|
17
|
+
|
|
18
|
+
def get_secret_value(self, props: SecretProperties) -> Any:
|
|
19
|
+
raise NotImplementedError('GCP provider not implemented yet.')
|
vaultriever/py.typed
ADDED
|
File without changes
|
vaultriever/resolver.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Secret resolution: parse an SRI and dispatch to the right provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from vaultriever.logging import get_logger
|
|
6
|
+
from vaultriever.providers import SecretProviderRegistry
|
|
7
|
+
from vaultriever.sri import parse_sri
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_secret(secret_sri: str) -> str:
|
|
13
|
+
"""Resolve an SRI string to its secret value.
|
|
14
|
+
|
|
15
|
+
Raises:
|
|
16
|
+
SRIParseError: If the SRI is malformed.
|
|
17
|
+
ProviderNotRegisteredError: If the provider is unknown.
|
|
18
|
+
SecretRetrievalError: If the provider fails to retrieve the secret.
|
|
19
|
+
"""
|
|
20
|
+
props = parse_sri(secret_sri)
|
|
21
|
+
provider = SecretProviderRegistry.get(props.provider)
|
|
22
|
+
logger.debug(
|
|
23
|
+
'Resolving secret via provider=%r region=%r secret_name=%r',
|
|
24
|
+
props.provider,
|
|
25
|
+
props.region,
|
|
26
|
+
props.secret_name,
|
|
27
|
+
)
|
|
28
|
+
value = provider.get_secret_value(props)
|
|
29
|
+
# Always return a string so callers can wrap it in SecretStr.
|
|
30
|
+
return str(value)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Pydantic-settings integration.
|
|
2
|
+
|
|
3
|
+
:class:`SecretSRIMixin` adds a wildcard ``mode='before'`` validator that
|
|
4
|
+
detects SRI strings in ``str``/``SecretStr`` fields and replaces them with the
|
|
5
|
+
resolved secret wrapped in ``SecretStr``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any, ClassVar
|
|
13
|
+
|
|
14
|
+
from pydantic import SecretStr, ValidationInfo, field_validator
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
from vaultriever.logging import get_logger
|
|
18
|
+
from vaultriever.providers import SecretProviderRegistry
|
|
19
|
+
from vaultriever.resolver import resolve_secret
|
|
20
|
+
from vaultriever.sri import is_sri
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecretSRIMixin(BaseSettings):
|
|
26
|
+
"""Base/mixin for ``BaseSettings`` models that resolves SRI values.
|
|
27
|
+
|
|
28
|
+
It extends ``BaseSettings`` (which keeps type checkers happy about
|
|
29
|
+
``model_config``), so both forms work::
|
|
30
|
+
|
|
31
|
+
class MySettings(SecretSRIMixin):
|
|
32
|
+
api_key: str | SecretStr = 'aws:us-east-1:my-secret:API_KEY'
|
|
33
|
+
|
|
34
|
+
class MySettings(SecretSRIMixin, BaseSettings):
|
|
35
|
+
api_key: str | SecretStr = 'aws:us-east-1:my-secret:API_KEY'
|
|
36
|
+
|
|
37
|
+
Behavior:
|
|
38
|
+
- ``str``/``SecretStr`` values that look like an SRI *and* reference a
|
|
39
|
+
registered provider are resolved and returned as ``SecretStr``.
|
|
40
|
+
- All other values pass through unchanged.
|
|
41
|
+
- When ``enable_env_export`` is True (the default), validated values are
|
|
42
|
+
also written to ``os.environ`` under the field name. Note that this
|
|
43
|
+
writes resolved secrets in **plaintext** to the process environment;
|
|
44
|
+
set ``enable_env_export: ClassVar[bool] = False`` on your subclass to
|
|
45
|
+
opt out (the ``ClassVar`` annotation keeps pydantic from treating it as
|
|
46
|
+
a field).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Ensures SRI strings used as field defaults are also resolved.
|
|
50
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(validate_default=True)
|
|
51
|
+
|
|
52
|
+
enable_env_export: ClassVar[bool] = True
|
|
53
|
+
|
|
54
|
+
@field_validator('*', mode='before')
|
|
55
|
+
@classmethod
|
|
56
|
+
def resolve_sri(cls, value: Any, info: ValidationInfo) -> Any:
|
|
57
|
+
if isinstance(value, SecretStr):
|
|
58
|
+
raw = value.get_secret_value()
|
|
59
|
+
elif isinstance(value, str):
|
|
60
|
+
raw = value
|
|
61
|
+
else:
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
# Only treat the value as an SRI if its provider is registered, so
|
|
65
|
+
# colon-heavy strings such as connection URLs pass through untouched.
|
|
66
|
+
if is_sri(raw) and SecretProviderRegistry.is_registered(raw.split(':', 1)[0]):
|
|
67
|
+
logger.debug('Resolving SRI for field %r', info.field_name)
|
|
68
|
+
secret = SecretStr(resolve_secret(raw))
|
|
69
|
+
cls._export_to_env(info.field_name, secret.get_secret_value())
|
|
70
|
+
return secret
|
|
71
|
+
|
|
72
|
+
cls._export_to_env(info.field_name, raw)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def _export_to_env(cls, field_name: str | None, value: Any) -> None:
|
|
77
|
+
if not cls.enable_env_export or not field_name:
|
|
78
|
+
return
|
|
79
|
+
if isinstance(value, dict | list):
|
|
80
|
+
os.environ[field_name] = json.dumps(value)
|
|
81
|
+
else:
|
|
82
|
+
os.environ[field_name] = str(value)
|
vaultriever/sri.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Secret Resource Identifier (SRI) parsing and validation.
|
|
2
|
+
|
|
3
|
+
Canonical format::
|
|
4
|
+
|
|
5
|
+
provider:region:secret_name:secret_key
|
|
6
|
+
|
|
7
|
+
Examples:
|
|
8
|
+
- ``aws:us-east-1:my-secret:OPENAI_API_KEY``
|
|
9
|
+
- ``databricks::my-secret-scope:OPENAI_API_KEY`` (empty region -> default
|
|
10
|
+
profile/workspace)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from vaultriever.exceptions import SRIParseError
|
|
19
|
+
|
|
20
|
+
SRI_PARTS = 4
|
|
21
|
+
_PROVIDER_RE = re.compile(r'^[a-z][a-z0-9_-]*$')
|
|
22
|
+
# Regions/profiles are identifier-like. Keeping this strict prevents strings
|
|
23
|
+
# such as 'postgresql://user:pass@host:5432/db' from being detected as SRIs.
|
|
24
|
+
_REGION_RE = re.compile(r'^[A-Za-z0-9._-]*$')
|
|
25
|
+
|
|
26
|
+
_FORMAT_HINT = "Expected 'provider:region:secret_name:secret_key'"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SecretProperties:
|
|
31
|
+
"""Parsed components of an SRI string."""
|
|
32
|
+
|
|
33
|
+
provider: str
|
|
34
|
+
region: str | None
|
|
35
|
+
secret_name: str
|
|
36
|
+
secret_key: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_sri(value: str) -> bool:
|
|
40
|
+
"""Return True if ``value`` is structurally a valid SRI.
|
|
41
|
+
|
|
42
|
+
The check requires exactly 4 colon-separated parts, a lowercase provider
|
|
43
|
+
name, and non-empty secret_name/secret_key. Only the region may be empty.
|
|
44
|
+
This is a structural check only; it does not verify that the provider is
|
|
45
|
+
registered or that the secret exists.
|
|
46
|
+
"""
|
|
47
|
+
if not isinstance(value, str):
|
|
48
|
+
return False
|
|
49
|
+
parts = value.split(':')
|
|
50
|
+
if len(parts) != SRI_PARTS:
|
|
51
|
+
return False
|
|
52
|
+
provider, region, secret_name, secret_key = parts
|
|
53
|
+
return (
|
|
54
|
+
bool(_PROVIDER_RE.match(provider))
|
|
55
|
+
and bool(_REGION_RE.match(region))
|
|
56
|
+
and bool(secret_name)
|
|
57
|
+
and bool(secret_key)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_sri(value: str) -> SecretProperties:
|
|
62
|
+
"""Parse an SRI string into :class:`SecretProperties`.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
SRIParseError: If the string is malformed. The message never includes
|
|
66
|
+
the full input to avoid leaking values that only look like SRIs.
|
|
67
|
+
"""
|
|
68
|
+
parts = value.split(':')
|
|
69
|
+
if len(parts) != SRI_PARTS:
|
|
70
|
+
raise SRIParseError(f'Malformed SRI with {len(parts)} part(s). {_FORMAT_HINT}')
|
|
71
|
+
|
|
72
|
+
provider, region, secret_name, secret_key = parts
|
|
73
|
+
if not _PROVIDER_RE.match(provider):
|
|
74
|
+
raise SRIParseError(
|
|
75
|
+
f'Invalid SRI provider {provider!r}: must be a non-empty lowercase name. {_FORMAT_HINT}'
|
|
76
|
+
)
|
|
77
|
+
if not _REGION_RE.match(region):
|
|
78
|
+
raise SRIParseError(
|
|
79
|
+
f'Invalid SRI region {region!r}: only letters, digits, ".", "_" and "-" '
|
|
80
|
+
f'are allowed. {_FORMAT_HINT}'
|
|
81
|
+
)
|
|
82
|
+
if not secret_name:
|
|
83
|
+
raise SRIParseError(f'SRI secret_name must be non-empty. {_FORMAT_HINT}')
|
|
84
|
+
if not secret_key:
|
|
85
|
+
raise SRIParseError(f'SRI secret_key must be non-empty. {_FORMAT_HINT}')
|
|
86
|
+
|
|
87
|
+
return SecretProperties(
|
|
88
|
+
provider=provider,
|
|
89
|
+
region=region or None,
|
|
90
|
+
secret_name=secret_name,
|
|
91
|
+
secret_key=secret_key,
|
|
92
|
+
)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaultriever
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Retrieve secrets in an ARN-like way from different vaults seamlessly, compatible with pydantic-settings.
|
|
5
|
+
Project-URL: Homepage, https://github.com/DougTrajano/vaultriever
|
|
6
|
+
Project-URL: Repository, https://github.com/DougTrajano/vaultriever
|
|
7
|
+
Project-URL: Issues, https://github.com/DougTrajano/vaultriever/issues
|
|
8
|
+
Author: Douglas Trajano
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aws,databricks,pydantic,pydantic-settings,secrets,vault
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0
|
|
26
|
+
Provides-Extra: aws
|
|
27
|
+
Requires-Dist: boto3>=1.26.0; extra == 'aws'
|
|
28
|
+
Provides-Extra: databricks
|
|
29
|
+
Requires-Dist: databricks-sdk>=0.20.0; extra == 'databricks'
|
|
30
|
+
Provides-Extra: docs
|
|
31
|
+
Requires-Dist: mkdocs-git-revision-date-localized-plugin>=1.2.0; extra == 'docs'
|
|
32
|
+
Requires-Dist: mkdocs-material>=9.5.0; extra == 'docs'
|
|
33
|
+
Requires-Dist: mkdocs>=1.6.0; extra == 'docs'
|
|
34
|
+
Requires-Dist: mkdocstrings[python]>=0.25.0; extra == 'docs'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# vaultriever
|
|
38
|
+
|
|
39
|
+
Retrieve secrets in an ARN-like way from different vaults seamlessly, compatible with [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) — no custom field types, just `str` fields plus a reusable mixin.
|
|
40
|
+
|
|
41
|
+
## Secret Resource Identifier (SRI)
|
|
42
|
+
|
|
43
|
+
Secrets are addressed with a 4-part string:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
provider:region:secret_name:secret_key
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Provider | Example | Notes |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| AWS Secrets Manager | `aws:us-east-1:my-secret:OPENAI_API_KEY` | Secret must be a JSON object; `secret_key` selects a key. |
|
|
52
|
+
| Databricks | `databricks::my-secret-scope:OPENAI_API_KEY` | Empty region → default workspace/profile; non-empty region → CLI profile name. |
|
|
53
|
+
| Azure Key Vault | `azure:<vault_or_region>:<secret_name>:<key_or_version>` | Planned. |
|
|
54
|
+
| GCP Secret Manager | `gcp:<project_or_region>:<secret_name>:<key_or_version>` | Planned. |
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install vaultriever[aws] # AWS Secrets Manager
|
|
60
|
+
pip install vaultriever[databricks] # Databricks (not needed on a Databricks runtime)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### With pydantic-settings
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pydantic import Field, SecretStr
|
|
69
|
+
from pydantic_settings import BaseSettings
|
|
70
|
+
from vaultriever import SecretSRIMixin
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class OpenAISettings(SecretSRIMixin, BaseSettings):
|
|
74
|
+
openai_api_key: str | SecretStr = Field(
|
|
75
|
+
default='aws:us-east-1:my-secret:OPENAI_API_KEY',
|
|
76
|
+
description='OpenAI API key; SRI or literal.',
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
settings = OpenAISettings()
|
|
81
|
+
settings.openai_api_key # SecretStr('**********') — masked
|
|
82
|
+
settings.openai_api_key.get_secret_value() # the resolved secret
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Behavior:
|
|
86
|
+
|
|
87
|
+
- `str` / `SecretStr` values that look like an SRI (and reference a registered provider) are resolved and wrapped in `SecretStr`, so they stay masked in `repr()` and logs.
|
|
88
|
+
- Everything else passes through unchanged — literals, URLs, non-string values.
|
|
89
|
+
- Validated values are also exported to `os.environ` under the field name for downstream usage. **This writes resolved secrets in plaintext to the process environment**; opt out per model:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from typing import ClassVar
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class MySettings(SecretSRIMixin, BaseSettings):
|
|
96
|
+
enable_env_export: ClassVar[bool] = False
|
|
97
|
+
|
|
98
|
+
api_key: str | SecretStr
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Standalone
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
from vaultriever import is_sri, resolve_secret
|
|
105
|
+
|
|
106
|
+
is_sri('aws:us-east-1:my-secret:API_KEY') # True
|
|
107
|
+
resolve_secret('aws:us-east-1:my-secret:API_KEY') # 'sk-...'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Custom providers
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from vaultriever import SecretProviderRegistry
|
|
114
|
+
from vaultriever.sri import SecretProperties
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MyVaultProvider:
|
|
118
|
+
name = 'myvault'
|
|
119
|
+
|
|
120
|
+
def get_secret_value(self, props: SecretProperties) -> str:
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
SecretProviderRegistry.register(MyVaultProvider())
|
|
125
|
+
# Now 'myvault:region:name:key' SRIs resolve through it.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Providers
|
|
129
|
+
|
|
130
|
+
### AWS Secrets Manager
|
|
131
|
+
|
|
132
|
+
- Credentials come from the AWS SDK default chain (env vars, profile, IAM role).
|
|
133
|
+
- The region is required and taken from the SRI.
|
|
134
|
+
- Secret payloads are cached per `(secret_name, region)` for the process lifetime; call `AWSSecretProvider.clear_cache()` after a rotation.
|
|
135
|
+
|
|
136
|
+
### Databricks
|
|
137
|
+
|
|
138
|
+
- On a Databricks runtime, secrets are read via the native `dbutils`.
|
|
139
|
+
- Elsewhere, the [databricks-sdk](https://github.com/databricks/databricks-sdk-py) is used with default authentication, or with the CLI profile named by the SRI's region component (`databricks:staging:my-scope:MY_KEY`).
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
uv sync --all-extras --dev
|
|
145
|
+
uv run pytest
|
|
146
|
+
uv run ruff check .
|
|
147
|
+
uv run mypy
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Releases are published to PyPI by pushing a `vX.Y.Z` tag.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
vaultriever/__init__.py,sha256=qUG25TBeN9tcUJvEUMO25twEZ4qtkqpldzlErjFweGI,909
|
|
2
|
+
vaultriever/exceptions.py,sha256=bfbStUjIh4AsrfN_rlalr6KktUK81uuvVIq4ZuH0Y6U,808
|
|
3
|
+
vaultriever/logging.py,sha256=oEqR69yFqWKCShJ6vB8-7NioQGFn-QqrMHQpU5VDOBY,490
|
|
4
|
+
vaultriever/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
vaultriever/resolver.py,sha256=dhcJuN10Y67mqiP6_s2ZFcsRIE9U7v8rzqgbCsaCEoY,976
|
|
6
|
+
vaultriever/settings_mixin.py,sha256=c6amQmxC9uXHaEykn-wrNXz_xNrnXEG1-X0VIgxWyqY,3130
|
|
7
|
+
vaultriever/sri.py,sha256=yXnUpjiIqFkqORC3lcpduAK-XJBGFPw_2D8DDcFDS_I,2909
|
|
8
|
+
vaultriever/providers/__init__.py,sha256=kp5Gt35U39ILjT8H5hiL7KwCeNA9ixT8vvQBh-daH54,733
|
|
9
|
+
vaultriever/providers/aws.py,sha256=GrBF-qhXVmD3r0hTX5xkWh2N4f6Z4uNW9p1U4yh3JM8,2861
|
|
10
|
+
vaultriever/providers/azure.py,sha256=rMDhDuM4sO2LrSJe4lJ4y38QCZftr55-3SVnQz1QSAc,471
|
|
11
|
+
vaultriever/providers/base.py,sha256=dmG6dAaVEOtzjqZatVlMDBkebI1tLMofpoLhV55e-U4,1856
|
|
12
|
+
vaultriever/providers/databricks.py,sha256=0KHas-i-WhjPHEsZW7dPvzvLE9zMHxvu-WCaBwry9Fg,3481
|
|
13
|
+
vaultriever/providers/gcp.py,sha256=WuQynwRlgz94jKXNgb0do01JFiOi1zyV94IfJqVm_Yo,468
|
|
14
|
+
vaultriever-0.1.0.dist-info/METADATA,sha256=ZFx0f-qh2F5pXrBxH5EBPSpm3iypGpP1WZzg-_q0sSk,5201
|
|
15
|
+
vaultriever-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
vaultriever-0.1.0.dist-info/licenses/LICENSE,sha256=Qo4k0W8GvBayeeEC1Nytlxt4FnUjcfpuUEQfnGb2ZrE,1072
|
|
17
|
+
vaultriever-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Douglas Trajano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|