redenv 0.2.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.
- redenv/__init__.py +7 -0
- redenv/client.py +137 -0
- redenv/crypto.py +71 -0
- redenv/errors.py +6 -0
- redenv/py.typed +0 -0
- redenv/secrets.py +95 -0
- redenv/sync/__init__.py +3 -0
- redenv/sync/client.py +131 -0
- redenv/sync/utils.py +199 -0
- redenv/types.py +64 -0
- redenv/utils.py +251 -0
- redenv-0.2.0.dist-info/METADATA +203 -0
- redenv-0.2.0.dist-info/RECORD +15 -0
- redenv-0.2.0.dist-info/WHEEL +4 -0
- redenv-0.2.0.dist-info/licenses/LICENSE +21 -0
redenv/__init__.py
ADDED
redenv/client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, Any, Optional, Literal
|
|
4
|
+
from upstash_redis.asyncio import Redis
|
|
5
|
+
from cachetools import LRUCache
|
|
6
|
+
from .types import RedenvOptions, CacheEntry
|
|
7
|
+
from .secrets import Secrets
|
|
8
|
+
from .utils import fetch_and_decrypt, populate_env, log, error, set_secret, get_secret_version
|
|
9
|
+
from .errors import RedenvError
|
|
10
|
+
|
|
11
|
+
class Redenv:
|
|
12
|
+
def __init__(self, options: Dict[str, Any]):
|
|
13
|
+
self.options = RedenvOptions.from_dict(options)
|
|
14
|
+
self.validate_options()
|
|
15
|
+
|
|
16
|
+
self._cache = LRUCache(maxsize=1000)
|
|
17
|
+
|
|
18
|
+
self.redis = Redis(
|
|
19
|
+
url=self.options.upstash.url,
|
|
20
|
+
token=self.options.upstash.token
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def validate_options(self):
|
|
24
|
+
if not self.options.project:
|
|
25
|
+
raise RedenvError("Missing required configuration option: project", "MISSING_CONFIG")
|
|
26
|
+
if not self.options.token_id:
|
|
27
|
+
raise RedenvError("Missing required configuration option: token_id", "MISSING_CONFIG")
|
|
28
|
+
if not self.options.token:
|
|
29
|
+
raise RedenvError("Missing required configuration option: token", "MISSING_CONFIG")
|
|
30
|
+
if not self.options.upstash.url or not self.options.upstash.token:
|
|
31
|
+
raise RedenvError("Missing required configuration option: upstash", "MISSING_CONFIG")
|
|
32
|
+
|
|
33
|
+
def _get_cache_key(self) -> str:
|
|
34
|
+
return f"redenv:{self.options.project}:{self.options.environment}"
|
|
35
|
+
|
|
36
|
+
async def _get_secrets(self) -> Secrets:
|
|
37
|
+
key = self._get_cache_key()
|
|
38
|
+
entry = self._cache.get(key)
|
|
39
|
+
now = time.time()
|
|
40
|
+
|
|
41
|
+
ttl_seconds = self.options.cache.ttl
|
|
42
|
+
swr_seconds = self.options.cache.swr
|
|
43
|
+
|
|
44
|
+
# Function to fetch fresh value
|
|
45
|
+
async def fetch_fresh() -> Secrets:
|
|
46
|
+
try:
|
|
47
|
+
log("Fetching fresh secrets...", self.options.log)
|
|
48
|
+
secrets = await fetch_and_decrypt(self.redis, self.options)
|
|
49
|
+
|
|
50
|
+
# Update cache with new entry
|
|
51
|
+
self._cache[key] = CacheEntry(secrets, time.time())
|
|
52
|
+
|
|
53
|
+
# Side effect: populate environment
|
|
54
|
+
await populate_env(secrets, self.options)
|
|
55
|
+
|
|
56
|
+
return secrets
|
|
57
|
+
except Exception as e:
|
|
58
|
+
error(f"Failed to fetch secrets: {e}", self.options.log)
|
|
59
|
+
raise e
|
|
60
|
+
|
|
61
|
+
if entry:
|
|
62
|
+
age = now - entry.created_at
|
|
63
|
+
|
|
64
|
+
if age < ttl_seconds:
|
|
65
|
+
# Case 1: Fresh
|
|
66
|
+
log("Cache hit (Fresh).", self.options.log)
|
|
67
|
+
return entry.value
|
|
68
|
+
|
|
69
|
+
elif age < (ttl_seconds + swr_seconds):
|
|
70
|
+
# Case 2: Stale (SWR)
|
|
71
|
+
log("Cache hit (Stale). Revalidating in background...", self.options.log)
|
|
72
|
+
# Return stale value immediately
|
|
73
|
+
# Spawn background refresh
|
|
74
|
+
asyncio.create_task(fetch_fresh())
|
|
75
|
+
return entry.value
|
|
76
|
+
else:
|
|
77
|
+
# Case 3: Expired
|
|
78
|
+
log("Cache expired. Fetching fresh...", self.options.log)
|
|
79
|
+
return await fetch_fresh()
|
|
80
|
+
else:
|
|
81
|
+
# Case 4: Miss
|
|
82
|
+
log("Cache miss. Fetching fresh...", self.options.log)
|
|
83
|
+
return await fetch_fresh()
|
|
84
|
+
|
|
85
|
+
async def init(self):
|
|
86
|
+
"""
|
|
87
|
+
Initializes the environment with secrets.
|
|
88
|
+
Alias for load().
|
|
89
|
+
"""
|
|
90
|
+
await self.load()
|
|
91
|
+
|
|
92
|
+
async def load(self) -> Secrets:
|
|
93
|
+
"""
|
|
94
|
+
Fetches, caches, and injects secrets into the environment.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The Secrets object.
|
|
98
|
+
"""
|
|
99
|
+
secrets = await self._get_secrets()
|
|
100
|
+
|
|
101
|
+
# Ensure env is populated
|
|
102
|
+
await populate_env(secrets, self.options)
|
|
103
|
+
|
|
104
|
+
return secrets
|
|
105
|
+
|
|
106
|
+
async def set(self, key: str, value: str):
|
|
107
|
+
"""
|
|
108
|
+
Adds or updates a secret.
|
|
109
|
+
"""
|
|
110
|
+
if not key or not value:
|
|
111
|
+
raise RedenvError("Key and value are required.", "INVALID_INPUT")
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
await set_secret(self.redis, self.options, key, value)
|
|
115
|
+
log(f'Successfully set secret for key "{key}".', self.options.log)
|
|
116
|
+
|
|
117
|
+
# Invalidate cache
|
|
118
|
+
cache_key = self._get_cache_key()
|
|
119
|
+
if cache_key in self._cache:
|
|
120
|
+
del self._cache[cache_key]
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
msg = str(e)
|
|
124
|
+
error(f"Failed to set secret: {msg}", self.options.log)
|
|
125
|
+
raise RedenvError(f"Failed to set secret: {msg}", "UNKNOWN_ERROR")
|
|
126
|
+
|
|
127
|
+
async def get_version(self, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
|
|
128
|
+
"""
|
|
129
|
+
Fetches a specific version of a secret with caching.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
key: The secret key.
|
|
133
|
+
version: The version ID or index.
|
|
134
|
+
mode: "id" (default) uses positive version numbers, negative for index from end.
|
|
135
|
+
"index" treats version as a 0-based array index (0=latest).
|
|
136
|
+
"""
|
|
137
|
+
return await get_secret_version(self.redis, self.options, self._cache, key, version, mode)
|
redenv/crypto.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
3
|
+
from cryptography.hazmat.primitives import hashes
|
|
4
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
5
|
+
from .errors import RedenvError
|
|
6
|
+
|
|
7
|
+
# --- Configuration ---
|
|
8
|
+
IV_LENGTH = 12
|
|
9
|
+
KEY_LENGTH = 32 # 256 bits = 32 bytes
|
|
10
|
+
PBKDF2_ITERATIONS = 310000
|
|
11
|
+
SALT_LENGTH = 16
|
|
12
|
+
|
|
13
|
+
def buffer_to_hex(buffer: bytes) -> str:
|
|
14
|
+
return buffer.hex()
|
|
15
|
+
|
|
16
|
+
def hex_to_buffer(hex_str: str) -> bytes:
|
|
17
|
+
return bytes.fromhex(hex_str)
|
|
18
|
+
|
|
19
|
+
def random_bytes(length: int = 32) -> bytes:
|
|
20
|
+
return os.urandom(length)
|
|
21
|
+
|
|
22
|
+
def generate_salt() -> bytes:
|
|
23
|
+
return random_bytes(SALT_LENGTH)
|
|
24
|
+
|
|
25
|
+
def derive_key(password: str, salt: bytes) -> bytes:
|
|
26
|
+
"""
|
|
27
|
+
Derives an encryption key from a password and salt using PBKDF2.
|
|
28
|
+
"""
|
|
29
|
+
kdf = PBKDF2HMAC(
|
|
30
|
+
algorithm=hashes.SHA256(),
|
|
31
|
+
length=KEY_LENGTH,
|
|
32
|
+
salt=salt,
|
|
33
|
+
iterations=PBKDF2_ITERATIONS,
|
|
34
|
+
)
|
|
35
|
+
return kdf.derive(password.encode('utf-8'))
|
|
36
|
+
|
|
37
|
+
def encrypt(data: str, key: bytes) -> str:
|
|
38
|
+
"""
|
|
39
|
+
Encrypts data using AES-256-GCM.
|
|
40
|
+
Returns a string containing the iv and the ciphertext, separated by a dot.
|
|
41
|
+
"""
|
|
42
|
+
iv = os.urandom(IV_LENGTH)
|
|
43
|
+
aesgcm = AESGCM(key)
|
|
44
|
+
ciphertext = aesgcm.encrypt(iv, data.encode('utf-8'), None)
|
|
45
|
+
|
|
46
|
+
return f"{buffer_to_hex(iv)}.{buffer_to_hex(ciphertext)}"
|
|
47
|
+
|
|
48
|
+
def decrypt(encrypted_string: str, key: bytes) -> str:
|
|
49
|
+
"""
|
|
50
|
+
Decrypts data that was encrypted with the `encrypt` function.
|
|
51
|
+
"""
|
|
52
|
+
if not encrypted_string:
|
|
53
|
+
raise RedenvError("Encrypted string cannot be empty.", "UNKNOWN_ERROR")
|
|
54
|
+
|
|
55
|
+
parts = encrypted_string.split(".")
|
|
56
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
57
|
+
raise RedenvError("Invalid encrypted string format.", "UNKNOWN_ERROR")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
iv = hex_to_buffer(parts[0])
|
|
61
|
+
ciphertext = hex_to_buffer(parts[1])
|
|
62
|
+
|
|
63
|
+
aesgcm = AESGCM(key)
|
|
64
|
+
decrypted_data = aesgcm.decrypt(iv, ciphertext, None)
|
|
65
|
+
|
|
66
|
+
return decrypted_data.decode('utf-8')
|
|
67
|
+
except Exception:
|
|
68
|
+
raise RedenvError(
|
|
69
|
+
"Decryption failed. This likely means an incorrect password was used or the data is corrupted.",
|
|
70
|
+
"DECRYPTION_FAILED"
|
|
71
|
+
)
|
redenv/errors.py
ADDED
redenv/py.typed
ADDED
|
File without changes
|
redenv/secrets.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Any, Callable, Optional, Type, TypeVar, Union
|
|
3
|
+
from .errors import RedenvError
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T")
|
|
6
|
+
|
|
7
|
+
class Secrets(dict):
|
|
8
|
+
"""
|
|
9
|
+
A specialized dictionary for managing decrypted secrets with
|
|
10
|
+
type-casting, scoping, and validation capabilities.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def get(self, key: str, default: Any = None, cast: Optional[Union[Type[T], Callable[[Any], T]]] = None) -> Union[T, Any]:
|
|
14
|
+
"""
|
|
15
|
+
Retrieves a secret and optionally casts it to a specific type.
|
|
16
|
+
"""
|
|
17
|
+
value = super().get(key)
|
|
18
|
+
|
|
19
|
+
if value is None:
|
|
20
|
+
return default
|
|
21
|
+
|
|
22
|
+
if cast is None:
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
# Special handling for boolean strings
|
|
27
|
+
if cast is bool:
|
|
28
|
+
if isinstance(value, bool):
|
|
29
|
+
return value
|
|
30
|
+
if isinstance(value, str):
|
|
31
|
+
return value.lower() in ("true", "1", "yes", "on", "t")
|
|
32
|
+
return bool(value)
|
|
33
|
+
|
|
34
|
+
# Special handling for JSON types (dict/list)
|
|
35
|
+
if (cast is dict or cast is list) and isinstance(value, str):
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(value)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
return default
|
|
40
|
+
|
|
41
|
+
# General casting (int, float, or custom callable)
|
|
42
|
+
return cast(value) # type: ignore
|
|
43
|
+
|
|
44
|
+
except (ValueError, TypeError):
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, key: str) -> Any:
|
|
48
|
+
"""
|
|
49
|
+
Ensures standard dict access still works but raises a
|
|
50
|
+
helpful error if the key is missing.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
return super().__getitem__(key)
|
|
54
|
+
except KeyError:
|
|
55
|
+
raise KeyError(f"Secret '{key}' not found in Redenv.")
|
|
56
|
+
|
|
57
|
+
def scope(self, prefix: str) -> "Secrets":
|
|
58
|
+
"""
|
|
59
|
+
Returns a new Secrets object containing only the keys that start with
|
|
60
|
+
the given prefix. The prefix is stripped from the keys in the new object.
|
|
61
|
+
"""
|
|
62
|
+
subset = Secrets()
|
|
63
|
+
for key, value in self.items():
|
|
64
|
+
if key.startswith(prefix):
|
|
65
|
+
new_key = key[len(prefix):]
|
|
66
|
+
if new_key:
|
|
67
|
+
subset[new_key] = value
|
|
68
|
+
return subset
|
|
69
|
+
|
|
70
|
+
def require(self, *keys: str) -> "Secrets":
|
|
71
|
+
"""
|
|
72
|
+
Validates that all provided keys exist.
|
|
73
|
+
Raises RedenvError if any key is missing.
|
|
74
|
+
Returns self for chaining.
|
|
75
|
+
"""
|
|
76
|
+
missing = [k for k in keys if k not in self]
|
|
77
|
+
if missing:
|
|
78
|
+
raise RedenvError(
|
|
79
|
+
f"Missing required secrets: {', '.join(missing)}",
|
|
80
|
+
"SECRET_NOT_FOUND"
|
|
81
|
+
)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
"""
|
|
86
|
+
Masks secret values to prevent accidental leakage in logs.
|
|
87
|
+
"""
|
|
88
|
+
masked = {k: "********" for k in self.keys()}
|
|
89
|
+
return f"Secrets({masked})"
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Returns the masked representation of the secrets.
|
|
94
|
+
"""
|
|
95
|
+
return self.__repr__()
|
redenv/sync/__init__.py
ADDED
redenv/sync/client.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Dict, Any, Optional, Literal
|
|
3
|
+
from upstash_redis import Redis
|
|
4
|
+
from cachetools import LRUCache
|
|
5
|
+
from ..types import RedenvOptions
|
|
6
|
+
from ..secrets import Secrets
|
|
7
|
+
from .utils import fetch_and_decrypt, populate_env, log, error, set_secret, get_secret_version
|
|
8
|
+
from ..errors import RedenvError
|
|
9
|
+
|
|
10
|
+
class CacheEntry:
|
|
11
|
+
def __init__(self, value: Any, created_at: float):
|
|
12
|
+
self.value = value
|
|
13
|
+
self.created_at = created_at
|
|
14
|
+
|
|
15
|
+
class Redenv:
|
|
16
|
+
def __init__(self, options: Dict[str, Any]):
|
|
17
|
+
self.options = RedenvOptions.from_dict(options)
|
|
18
|
+
self.validate_options()
|
|
19
|
+
|
|
20
|
+
self._cache = LRUCache(maxsize=1000)
|
|
21
|
+
|
|
22
|
+
self.redis = Redis(
|
|
23
|
+
url=self.options.upstash.url,
|
|
24
|
+
token=self.options.upstash.token
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def validate_options(self):
|
|
28
|
+
if not self.options.project:
|
|
29
|
+
raise RedenvError("Missing required configuration option: project", "MISSING_CONFIG")
|
|
30
|
+
if not self.options.token_id:
|
|
31
|
+
raise RedenvError("Missing required configuration option: token_id", "MISSING_CONFIG")
|
|
32
|
+
if not self.options.token:
|
|
33
|
+
raise RedenvError("Missing required configuration option: token", "MISSING_CONFIG")
|
|
34
|
+
if not self.options.upstash.url or not self.options.upstash.token:
|
|
35
|
+
raise RedenvError("Missing required configuration option: upstash", "MISSING_CONFIG")
|
|
36
|
+
|
|
37
|
+
def _get_cache_key(self) -> str:
|
|
38
|
+
return f"redenv:{self.options.project}:{self.options.environment}"
|
|
39
|
+
|
|
40
|
+
def _get_secrets(self) -> Secrets:
|
|
41
|
+
key = self._get_cache_key()
|
|
42
|
+
entry = self._cache.get(key)
|
|
43
|
+
now = time.time()
|
|
44
|
+
|
|
45
|
+
ttl_seconds = self.options.cache.ttl
|
|
46
|
+
swr_seconds = self.options.cache.swr
|
|
47
|
+
|
|
48
|
+
def fetch_fresh() -> Secrets:
|
|
49
|
+
try:
|
|
50
|
+
log("Fetching fresh secrets...", self.options.log)
|
|
51
|
+
secrets = fetch_and_decrypt(self.redis, self.options)
|
|
52
|
+
self._cache[key] = CacheEntry(secrets, time.time())
|
|
53
|
+
populate_env(secrets, self.options)
|
|
54
|
+
return secrets
|
|
55
|
+
except Exception as e:
|
|
56
|
+
error(f"Failed to fetch secrets: {e}", self.options.log)
|
|
57
|
+
raise e
|
|
58
|
+
|
|
59
|
+
if entry:
|
|
60
|
+
age = now - entry.created_at
|
|
61
|
+
|
|
62
|
+
if age < ttl_seconds:
|
|
63
|
+
log("Cache hit (Fresh).", self.options.log)
|
|
64
|
+
return entry.value
|
|
65
|
+
|
|
66
|
+
elif age < (ttl_seconds + swr_seconds):
|
|
67
|
+
# In Sync mode, we can't easily background refresh without threads.
|
|
68
|
+
# Option 1: Block and refresh (Safe)
|
|
69
|
+
# Option 2: Return stale (Fast, but never updates)
|
|
70
|
+
# We choose Option 1 for correctness in Sync scripts.
|
|
71
|
+
log("Cache stale. Refreshing (Blocking)...", self.options.log)
|
|
72
|
+
return fetch_fresh()
|
|
73
|
+
else:
|
|
74
|
+
log("Cache expired. Fetching fresh...", self.options.log)
|
|
75
|
+
return fetch_fresh()
|
|
76
|
+
else:
|
|
77
|
+
log("Cache miss. Fetching fresh...", self.options.log)
|
|
78
|
+
return fetch_fresh()
|
|
79
|
+
|
|
80
|
+
def init(self):
|
|
81
|
+
"""
|
|
82
|
+
Initializes the environment with secrets.
|
|
83
|
+
Alias for load().
|
|
84
|
+
"""
|
|
85
|
+
self.load()
|
|
86
|
+
|
|
87
|
+
def load(self) -> Secrets:
|
|
88
|
+
"""
|
|
89
|
+
Fetches, caches, and injects secrets into the environment.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
override: If True (default), overwrites existing environment variables.
|
|
93
|
+
"""
|
|
94
|
+
secrets = self._get_secrets()
|
|
95
|
+
|
|
96
|
+
# Ensure env is populated
|
|
97
|
+
populate_env(secrets, self.options)
|
|
98
|
+
|
|
99
|
+
return secrets
|
|
100
|
+
|
|
101
|
+
def set(self, key: str, value: str):
|
|
102
|
+
"""
|
|
103
|
+
Adds or updates a secret.
|
|
104
|
+
"""
|
|
105
|
+
if not key or not value:
|
|
106
|
+
raise RedenvError("Key and value are required.", "INVALID_INPUT")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
set_secret(self.redis, self.options, key, value)
|
|
110
|
+
log(f'Successfully set secret for key "{key}".', self.options.log)
|
|
111
|
+
|
|
112
|
+
cache_key = self._get_cache_key()
|
|
113
|
+
if cache_key in self._cache:
|
|
114
|
+
del self._cache[cache_key]
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
msg = str(e)
|
|
118
|
+
error(f"Failed to set secret: {msg}", self.options.log)
|
|
119
|
+
raise RedenvError(f"Failed to set secret: {msg}", "UNKNOWN_ERROR")
|
|
120
|
+
|
|
121
|
+
def get_version(self, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
|
|
122
|
+
"""
|
|
123
|
+
Fetches a specific version of a secret.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
key: The secret key.
|
|
127
|
+
version: The version ID or index.
|
|
128
|
+
mode: "id" (default) uses positive version numbers, negative for index from end.
|
|
129
|
+
"index" treats version as a 0-based array index (0=latest).
|
|
130
|
+
"""
|
|
131
|
+
return get_secret_version(self.redis, self.options, self._cache, key, version, mode)
|
redenv/sync/utils.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import time
|
|
4
|
+
from typing import Dict, Optional, Any, Union, Literal
|
|
5
|
+
from upstash_redis import Redis as SyncRedis
|
|
6
|
+
from ..crypto import derive_key, decrypt, hex_to_buffer, encrypt
|
|
7
|
+
from ..types import RedenvOptions, CacheEntry
|
|
8
|
+
from ..errors import RedenvError
|
|
9
|
+
from ..secrets import Secrets
|
|
10
|
+
from ..utils import log, error
|
|
11
|
+
from cachetools import LRUCache
|
|
12
|
+
|
|
13
|
+
def get_pek(redis: SyncRedis, options: RedenvOptions, metadata: Optional[Dict[str, Any]] = None) -> bytes:
|
|
14
|
+
"""
|
|
15
|
+
Fetches and decrypts the Project Encryption Key (PEK).
|
|
16
|
+
"""
|
|
17
|
+
if not metadata:
|
|
18
|
+
meta_key = f"meta@{options.project}"
|
|
19
|
+
metadata = redis.hgetall(meta_key)
|
|
20
|
+
|
|
21
|
+
if not metadata:
|
|
22
|
+
raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
|
|
23
|
+
|
|
24
|
+
service_tokens = metadata.get("serviceTokens")
|
|
25
|
+
if isinstance(service_tokens, str):
|
|
26
|
+
service_tokens = json.loads(service_tokens)
|
|
27
|
+
|
|
28
|
+
token_info = service_tokens.get(options.token_id) if service_tokens else None
|
|
29
|
+
|
|
30
|
+
if not token_info:
|
|
31
|
+
ephemeral_field = f"ephemeral:{options.token_id}"
|
|
32
|
+
raw_ephemeral = metadata.get(ephemeral_field)
|
|
33
|
+
if raw_ephemeral:
|
|
34
|
+
token_info = json.loads(raw_ephemeral) if isinstance(raw_ephemeral, str) else raw_ephemeral
|
|
35
|
+
|
|
36
|
+
if not token_info:
|
|
37
|
+
raise RedenvError("Invalid Redenv Token ID.", "INVALID_TOKEN_ID")
|
|
38
|
+
|
|
39
|
+
salt = hex_to_buffer(token_info["salt"])
|
|
40
|
+
token_key = derive_key(options.token, salt)
|
|
41
|
+
|
|
42
|
+
decrypted_pek_hex = decrypt(token_info["encryptedPEK"], token_key)
|
|
43
|
+
|
|
44
|
+
return hex_to_buffer(decrypted_pek_hex)
|
|
45
|
+
|
|
46
|
+
def fetch_and_decrypt(redis: SyncRedis, options: RedenvOptions) -> Secrets:
|
|
47
|
+
"""
|
|
48
|
+
Fetches all secrets for a given environment and decrypts them.
|
|
49
|
+
"""
|
|
50
|
+
log("Expired Cache: Fetching secrets from source...", options.log, "high")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
pek = get_pek(redis, options)
|
|
54
|
+
except Exception as e:
|
|
55
|
+
error(f"Failed to get PEK: {e}", options.log)
|
|
56
|
+
raise e
|
|
57
|
+
|
|
58
|
+
env_key = f"{options.environment}:{options.project}"
|
|
59
|
+
versioned_secrets = redis.hgetall(env_key)
|
|
60
|
+
|
|
61
|
+
secrets = Secrets()
|
|
62
|
+
if not versioned_secrets:
|
|
63
|
+
log("No secrets found for this environment.", options.log)
|
|
64
|
+
return secrets
|
|
65
|
+
|
|
66
|
+
for key, history_str in versioned_secrets.items():
|
|
67
|
+
if key.startswith("__"):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
history = json.loads(history_str) if isinstance(history_str, str) else history_str
|
|
72
|
+
if not isinstance(history, list) or len(history) == 0:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
encrypted_value = history[0]["value"]
|
|
76
|
+
decrypted_value = decrypt(encrypted_value, pek)
|
|
77
|
+
secrets[key] = decrypted_value
|
|
78
|
+
except Exception:
|
|
79
|
+
error(f'Failed to decrypt secret "{key}".', options.log)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
log(f"Successfully loaded {len(secrets)} secrets.", options.log)
|
|
83
|
+
return secrets
|
|
84
|
+
|
|
85
|
+
def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
|
|
86
|
+
"""
|
|
87
|
+
Injects secrets into the current runtime's environment.
|
|
88
|
+
"""
|
|
89
|
+
log("Populating environment with secrets...", options.log)
|
|
90
|
+
injected_count = 0
|
|
91
|
+
|
|
92
|
+
for key, value in secrets.items():
|
|
93
|
+
if not options.env.override and key in os.environ:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
os.environ[key] = value
|
|
97
|
+
injected_count += 1
|
|
98
|
+
|
|
99
|
+
log(f"Injection complete. {injected_count} variables were set.", options.log)
|
|
100
|
+
|
|
101
|
+
def set_secret(redis: SyncRedis, options: RedenvOptions, key: str, value: str):
|
|
102
|
+
"""
|
|
103
|
+
Sets a secret in Redis with versioning and history.
|
|
104
|
+
"""
|
|
105
|
+
env_key = f"{options.environment}:{options.project}"
|
|
106
|
+
meta_key = f"meta@{options.project}"
|
|
107
|
+
|
|
108
|
+
# Sequential fetch (Simpler for sync, parallel requires threads)
|
|
109
|
+
metadata = redis.hgetall(meta_key)
|
|
110
|
+
current_history_str = redis.hget(env_key, key)
|
|
111
|
+
|
|
112
|
+
if not metadata:
|
|
113
|
+
raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
|
|
114
|
+
|
|
115
|
+
pek = get_pek(redis, options, metadata)
|
|
116
|
+
|
|
117
|
+
history_limit = int(metadata.get("historyLimit", 10))
|
|
118
|
+
|
|
119
|
+
history = []
|
|
120
|
+
if current_history_str:
|
|
121
|
+
history = json.loads(current_history_str) if isinstance(current_history_str, str) else current_history_str
|
|
122
|
+
|
|
123
|
+
if not isinstance(history, list):
|
|
124
|
+
history = []
|
|
125
|
+
|
|
126
|
+
last_version = history[0]["version"] if len(history) > 0 else 0
|
|
127
|
+
|
|
128
|
+
encrypted_value = encrypt(value, pek)
|
|
129
|
+
|
|
130
|
+
from datetime import datetime, timezone
|
|
131
|
+
|
|
132
|
+
new_version = {
|
|
133
|
+
"version": last_version + 1,
|
|
134
|
+
"value": encrypted_value,
|
|
135
|
+
"user": options.token_id,
|
|
136
|
+
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
history.insert(0, new_version)
|
|
140
|
+
|
|
141
|
+
if history_limit > 0:
|
|
142
|
+
history = history[:history_limit]
|
|
143
|
+
|
|
144
|
+
return redis.hset(env_key, key, json.dumps(history))
|
|
145
|
+
|
|
146
|
+
def get_secret_version(redis: SyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
|
|
147
|
+
"""
|
|
148
|
+
Fetches a specific version of a secret with optimized caching and smart indexing.
|
|
149
|
+
"""
|
|
150
|
+
hist_cache_key = f"history:{options.project}:{options.environment}:{key}"
|
|
151
|
+
entry = cache.get(hist_cache_key)
|
|
152
|
+
|
|
153
|
+
history_list = []
|
|
154
|
+
|
|
155
|
+
if entry:
|
|
156
|
+
log(f"History cache hit for {key}.", options.log)
|
|
157
|
+
history_list = entry.value
|
|
158
|
+
else:
|
|
159
|
+
log(f"History cache miss for {key}. Fetching full history...", options.log)
|
|
160
|
+
env_key = f"{options.environment}:{options.project}"
|
|
161
|
+
history_str = redis.hget(env_key, key)
|
|
162
|
+
|
|
163
|
+
if history_str:
|
|
164
|
+
try:
|
|
165
|
+
raw_list = json.loads(history_str) if isinstance(history_str, str) else history_str
|
|
166
|
+
if isinstance(raw_list, list):
|
|
167
|
+
history_list = raw_list
|
|
168
|
+
cache[hist_cache_key] = CacheEntry(history_list, time.time())
|
|
169
|
+
except Exception as e:
|
|
170
|
+
error(f"Failed to parse history: {e}", options.log)
|
|
171
|
+
|
|
172
|
+
if not history_list:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
target_record = None
|
|
176
|
+
|
|
177
|
+
if mode == "index":
|
|
178
|
+
try:
|
|
179
|
+
target_record = history_list[version]
|
|
180
|
+
except IndexError:
|
|
181
|
+
return None
|
|
182
|
+
else:
|
|
183
|
+
if version < 0:
|
|
184
|
+
try:
|
|
185
|
+
target_record = history_list[version]
|
|
186
|
+
except IndexError:
|
|
187
|
+
return None
|
|
188
|
+
else:
|
|
189
|
+
target_record = next((item for item in history_list if item.get("version") == version), None)
|
|
190
|
+
|
|
191
|
+
if not target_record:
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
pek = get_pek(redis, options)
|
|
196
|
+
return decrypt(target_record["value"], pek)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
error(f"Failed to decrypt version {version} ({mode}): {e}", options.log)
|
|
199
|
+
return None
|
redenv/types.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Literal, Dict, Any
|
|
3
|
+
|
|
4
|
+
LogPreference = Literal["none", "low", "high"]
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class UpstashConfig:
|
|
8
|
+
url: str
|
|
9
|
+
token: str
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CacheConfig:
|
|
13
|
+
ttl: int = 300
|
|
14
|
+
swr: int = 86400
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EnvConfig:
|
|
18
|
+
override: bool = True
|
|
19
|
+
|
|
20
|
+
class CacheEntry:
|
|
21
|
+
def __init__(self, value: Any, created_at: float):
|
|
22
|
+
self.value = value
|
|
23
|
+
self.created_at = created_at
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RedenvOptions:
|
|
27
|
+
project: str
|
|
28
|
+
token_id: str
|
|
29
|
+
token: str
|
|
30
|
+
upstash: UpstashConfig
|
|
31
|
+
environment: str = "development"
|
|
32
|
+
cache: CacheConfig = field(default_factory=CacheConfig)
|
|
33
|
+
env: EnvConfig = field(default_factory=EnvConfig)
|
|
34
|
+
log: LogPreference = "low"
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'RedenvOptions':
|
|
38
|
+
upstash_data = data.get("upstash", {})
|
|
39
|
+
upstash = UpstashConfig(
|
|
40
|
+
url=upstash_data.get("url", ""),
|
|
41
|
+
token=upstash_data.get("token", "")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
cache_data = data.get("cache", {})
|
|
45
|
+
cache = CacheConfig(
|
|
46
|
+
ttl=cache_data.get("ttl", 300),
|
|
47
|
+
swr=cache_data.get("swr", 86400)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
env_data = data.get("env", {})
|
|
51
|
+
env = EnvConfig(
|
|
52
|
+
override=env_data.get("override", True)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return cls(
|
|
56
|
+
project=data.get("project", ""),
|
|
57
|
+
token_id=data.get("token_id", data.get("tokenId", "")),
|
|
58
|
+
token=data.get("token", ""),
|
|
59
|
+
upstash=upstash,
|
|
60
|
+
environment=data.get("environment", "development"),
|
|
61
|
+
cache=cache,
|
|
62
|
+
env=env,
|
|
63
|
+
log=data.get("log", "low")
|
|
64
|
+
)
|
redenv/utils.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from .crypto import derive_key, decrypt, hex_to_buffer, encrypt
|
|
2
|
+
from .types import RedenvOptions, LogPreference, CacheEntry
|
|
3
|
+
from .errors import RedenvError
|
|
4
|
+
from upstash_redis import AsyncRedis
|
|
5
|
+
from .secrets import Secrets
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from typing import Literal, Optional, Dict, Any, Union
|
|
11
|
+
from cachetools import LRUCache
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("redenv")
|
|
16
|
+
|
|
17
|
+
if not logger.handlers:
|
|
18
|
+
logger.addHandler(logging.NullHandler())
|
|
19
|
+
|
|
20
|
+
def log(message: str, preference: LogPreference = "low", priority: str = "low"):
|
|
21
|
+
"""
|
|
22
|
+
Logs messages using the standard python logging module.
|
|
23
|
+
"""
|
|
24
|
+
if preference == "none":
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
# If preference is "low", we only log high priority messages as INFO
|
|
28
|
+
# If preference is "high", we log everything (low priority as DEBUG, high as INFO)
|
|
29
|
+
|
|
30
|
+
if priority == "high":
|
|
31
|
+
logger.info(message)
|
|
32
|
+
elif preference == "high":
|
|
33
|
+
logger.debug(message)
|
|
34
|
+
|
|
35
|
+
def error(message: str, preference: LogPreference = "low"):
|
|
36
|
+
"""
|
|
37
|
+
Logs errors using the standard python logging module.
|
|
38
|
+
"""
|
|
39
|
+
if preference != "none":
|
|
40
|
+
logger.error(message)
|
|
41
|
+
|
|
42
|
+
async def get_pek(redis: AsyncRedis, options: RedenvOptions, metadata: Optional[Dict[str, Any]] = None) -> bytes:
|
|
43
|
+
"""
|
|
44
|
+
Fetches and decrypts the Project Encryption Key (PEK).
|
|
45
|
+
If metadata is provided, it skips the Redis fetch.
|
|
46
|
+
"""
|
|
47
|
+
if not metadata:
|
|
48
|
+
meta_key = f"meta@{options.project}"
|
|
49
|
+
metadata = await redis.hgetall(meta_key)
|
|
50
|
+
|
|
51
|
+
if not metadata:
|
|
52
|
+
raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
|
|
53
|
+
|
|
54
|
+
service_tokens = metadata.get("serviceTokens")
|
|
55
|
+
if isinstance(service_tokens, str):
|
|
56
|
+
service_tokens = json.loads(service_tokens)
|
|
57
|
+
|
|
58
|
+
token_info = service_tokens.get(options.token_id) if service_tokens else None
|
|
59
|
+
|
|
60
|
+
# If not found in standard service tokens, check for ephemeral token field
|
|
61
|
+
if not token_info:
|
|
62
|
+
ephemeral_field = f"ephemeral:{options.token_id}"
|
|
63
|
+
raw_ephemeral = metadata.get(ephemeral_field)
|
|
64
|
+
if raw_ephemeral:
|
|
65
|
+
token_info = json.loads(raw_ephemeral) if isinstance(raw_ephemeral, str) else raw_ephemeral
|
|
66
|
+
|
|
67
|
+
if not token_info:
|
|
68
|
+
raise RedenvError("Invalid Redenv Token ID.", "INVALID_TOKEN_ID")
|
|
69
|
+
|
|
70
|
+
salt = hex_to_buffer(token_info["salt"])
|
|
71
|
+
# Note: derive_key in python takes string password and bytes salt
|
|
72
|
+
token_key = derive_key(options.token, salt)
|
|
73
|
+
|
|
74
|
+
decrypted_pek_hex = decrypt(token_info["encryptedPEK"], token_key)
|
|
75
|
+
|
|
76
|
+
return hex_to_buffer(decrypted_pek_hex)
|
|
77
|
+
|
|
78
|
+
async def fetch_and_decrypt(redis: AsyncRedis, options: RedenvOptions) -> Secrets:
|
|
79
|
+
"""
|
|
80
|
+
Fetches all secrets for a given environment and decrypts them.
|
|
81
|
+
"""
|
|
82
|
+
log("Expired Cache: Fetching secrets from source...", options.log, "high")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
pek = await get_pek(redis, options)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
error(f"Failed to get PEK: {e}", options.log)
|
|
88
|
+
raise e
|
|
89
|
+
|
|
90
|
+
env_key = f"{options.environment}:{options.project}"
|
|
91
|
+
versioned_secrets = await redis.hgetall(env_key)
|
|
92
|
+
|
|
93
|
+
secrets = Secrets()
|
|
94
|
+
if not versioned_secrets:
|
|
95
|
+
log("No secrets found for this environment.", options.log)
|
|
96
|
+
return secrets
|
|
97
|
+
|
|
98
|
+
# versioned_secrets is Dict[str, str] where values are JSON strings of arrays
|
|
99
|
+
|
|
100
|
+
for key, history_str in versioned_secrets.items():
|
|
101
|
+
if key.startswith("__"):
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
history = json.loads(history_str) if isinstance(history_str, str) else history_str
|
|
106
|
+
if not isinstance(history, list) or len(history) == 0:
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
# history[0] is the latest
|
|
110
|
+
encrypted_value = history[0]["value"]
|
|
111
|
+
decrypted_value = decrypt(encrypted_value, pek)
|
|
112
|
+
secrets[key] = decrypted_value
|
|
113
|
+
except Exception:
|
|
114
|
+
error(f'Failed to decrypt secret "{key}".', options.log)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
log(f"Successfully loaded {len(secrets)} secrets.", options.log)
|
|
118
|
+
return secrets
|
|
119
|
+
|
|
120
|
+
async def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
|
|
121
|
+
"""
|
|
122
|
+
Injects secrets into the current runtime's environment.
|
|
123
|
+
"""
|
|
124
|
+
log("Populating environment with secrets...", options.log)
|
|
125
|
+
injected_count = 0
|
|
126
|
+
|
|
127
|
+
for key, value in secrets.items():
|
|
128
|
+
if not options.env.override and key in os.environ:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
os.environ[key] = value
|
|
132
|
+
injected_count += 1
|
|
133
|
+
|
|
134
|
+
log(f"Injection complete. {injected_count} variables were set.", options.log)
|
|
135
|
+
|
|
136
|
+
async def set_secret(redis: AsyncRedis, options: RedenvOptions, key: str, value: str):
|
|
137
|
+
"""
|
|
138
|
+
Sets a secret in Redis with versioning and history.
|
|
139
|
+
"""
|
|
140
|
+
env_key = f"{options.environment}:{options.project}"
|
|
141
|
+
meta_key = f"meta@{options.project}"
|
|
142
|
+
|
|
143
|
+
# Fetch metadata (for PEK & historyLimit) and current history in parallel
|
|
144
|
+
metadata, current_history = await asyncio.gather(
|
|
145
|
+
redis.hgetall(meta_key),
|
|
146
|
+
redis.hget(env_key, key)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if not metadata:
|
|
150
|
+
raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
|
|
151
|
+
|
|
152
|
+
# Reuse metadata to get PEK without extra fetch
|
|
153
|
+
pek = await get_pek(redis, options, metadata)
|
|
154
|
+
|
|
155
|
+
history_limit = int(metadata.get("historyLimit", 10))
|
|
156
|
+
|
|
157
|
+
# Fetch current history for the key
|
|
158
|
+
history = []
|
|
159
|
+
if current_history:
|
|
160
|
+
history = json.loads(current_history) if isinstance(current_history, str) else current_history
|
|
161
|
+
|
|
162
|
+
if not isinstance(history, list):
|
|
163
|
+
history = []
|
|
164
|
+
|
|
165
|
+
last_version = history[0]["version"] if len(history) > 0 else 0
|
|
166
|
+
|
|
167
|
+
# Encrypt new value
|
|
168
|
+
encrypted_value = encrypt(value, pek)
|
|
169
|
+
|
|
170
|
+
from datetime import datetime, timezone
|
|
171
|
+
|
|
172
|
+
new_version = {
|
|
173
|
+
"version": last_version + 1,
|
|
174
|
+
"value": encrypted_value,
|
|
175
|
+
"user": options.token_id, # Using token_id as the user/auditor
|
|
176
|
+
"createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
# Prepend new version
|
|
180
|
+
history.insert(0, new_version)
|
|
181
|
+
|
|
182
|
+
# Trim history
|
|
183
|
+
if history_limit > 0:
|
|
184
|
+
history = history[:history_limit]
|
|
185
|
+
|
|
186
|
+
# Write back
|
|
187
|
+
return await redis.hset(env_key, key, json.dumps(history))
|
|
188
|
+
|
|
189
|
+
async def get_secret_version(redis: AsyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
|
|
190
|
+
"""
|
|
191
|
+
Fetches a specific version of a secret with optimized caching and smart indexing.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
mode: "id" (default) - positive numbers for version ID, negative for index from end.
|
|
195
|
+
"index" - treats version as array index (0=latest, 1=prev, -1=oldest).
|
|
196
|
+
"""
|
|
197
|
+
hist_cache_key = f"history:{options.project}:{options.environment}:{key}"
|
|
198
|
+
entry = cache.get(hist_cache_key)
|
|
199
|
+
|
|
200
|
+
history_list = []
|
|
201
|
+
|
|
202
|
+
if entry:
|
|
203
|
+
log(f"History cache hit for {key}.", options.log)
|
|
204
|
+
history_list = entry.value
|
|
205
|
+
else:
|
|
206
|
+
log(f"History cache miss for {key}. Fetching full history...", options.log)
|
|
207
|
+
env_key = f"{options.environment}:{options.project}"
|
|
208
|
+
history_str = await redis.hget(env_key, key)
|
|
209
|
+
|
|
210
|
+
if history_str:
|
|
211
|
+
try:
|
|
212
|
+
raw_list = json.loads(history_str) if isinstance(history_str, str) else history_str
|
|
213
|
+
if isinstance(raw_list, list):
|
|
214
|
+
history_list = raw_list
|
|
215
|
+
cache[hist_cache_key] = CacheEntry(history_list, time.time())
|
|
216
|
+
except Exception as e:
|
|
217
|
+
error(f"Failed to parse history: {e}", options.log)
|
|
218
|
+
|
|
219
|
+
if not history_list:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
target_record = None
|
|
223
|
+
|
|
224
|
+
if mode == "index":
|
|
225
|
+
try:
|
|
226
|
+
# history_list is sorted newest-first
|
|
227
|
+
target_record = history_list[version]
|
|
228
|
+
except IndexError:
|
|
229
|
+
return None
|
|
230
|
+
else:
|
|
231
|
+
# Default "id" mode
|
|
232
|
+
if version < 0:
|
|
233
|
+
# Smart fallback: use as index for negative numbers
|
|
234
|
+
try:
|
|
235
|
+
target_record = history_list[version]
|
|
236
|
+
except IndexError:
|
|
237
|
+
return None
|
|
238
|
+
else:
|
|
239
|
+
# Standard search by ID
|
|
240
|
+
target_record = next((item for item in history_list if item.get("version") == version), None)
|
|
241
|
+
|
|
242
|
+
if not target_record:
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
pek = await get_pek(redis, options)
|
|
247
|
+
return decrypt(target_record["value"], pek)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
error(f"Failed to decrypt version {version} ({mode}): {e}", options.log)
|
|
250
|
+
return None
|
|
251
|
+
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redenv
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A zero-knowledge, end-to-end encrypted secret management SDK for Python.
|
|
5
|
+
Project-URL: Homepage, https://github.com/redenv-labs/redenv
|
|
6
|
+
Project-URL: Documentation, https://github.com/redenv-labs/redenv/tree/main/packages/python-client
|
|
7
|
+
Project-URL: Repository, https://github.com/redenv-labs/redenv
|
|
8
|
+
Project-URL: Issues, https://github.com/redenv-labs/redenv/issues
|
|
9
|
+
Author-email: PRAS <prassamin@gmail.com>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 PRAS
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: dotenv,encryption,redis,sdk,secrets,security,upstash
|
|
33
|
+
Classifier: Development Status :: 4 - Beta
|
|
34
|
+
Classifier: Intended Audience :: Developers
|
|
35
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
36
|
+
Classifier: Operating System :: OS Independent
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
43
|
+
Classifier: Topic :: Security
|
|
44
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
|
+
Requires-Python: >=3.8
|
|
46
|
+
Requires-Dist: cachetools>=5.0.0
|
|
47
|
+
Requires-Dist: cryptography>=41.0.0
|
|
48
|
+
Requires-Dist: upstash-redis>=1.0.0
|
|
49
|
+
Provides-Extra: dev
|
|
50
|
+
Requires-Dist: build; extra == 'dev'
|
|
51
|
+
Requires-Dist: pyright; extra == 'dev'
|
|
52
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
53
|
+
Requires-Dist: python-dotenv; extra == 'dev'
|
|
54
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
55
|
+
Requires-Dist: twine; extra == 'dev'
|
|
56
|
+
Description-Content-Type: text/markdown
|
|
57
|
+
|
|
58
|
+
# Redenv Python SDK
|
|
59
|
+
|
|
60
|
+
The official, zero-knowledge Python client for [Redenv](https://github.com/redenv-labs/redenv). Securely fetch, cache, and manage your environment variables at runtime.
|
|
61
|
+
|
|
62
|
+

|
|
63
|
+

|
|
64
|
+

|
|
65
|
+
|
|
66
|
+
## Features
|
|
67
|
+
|
|
68
|
+
- **🔒 Zero-Knowledge:** End-to-End Encryption. Secrets are decrypted locally using your Project Encryption Key (PEK).
|
|
69
|
+
- **⚡ High Performance:** In-memory `LRUCache` with `Stale-While-Revalidate` strategy for zero-latency reads.
|
|
70
|
+
- **🔄 Universal:** Native **Async** (`asyncio`) and **Synchronous** clients included.
|
|
71
|
+
- **🛠️ Developer Experience:**
|
|
72
|
+
- **Smart Casting:** `secrets.get("PORT", cast=int)`
|
|
73
|
+
- **Scoping:** `secrets.scope("STRIPE_")` for namespaced configs.
|
|
74
|
+
- **Validation:** `secrets.require("API_KEY")` fail-fast checks.
|
|
75
|
+
- **Time Travel:** Fetch historical versions of secrets.
|
|
76
|
+
- **🛡️ Secure by Default:** Secrets are masked (`********`) in logs to prevent accidental leaks.
|
|
77
|
+
|
|
78
|
+
## Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install redenv
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Quick Start
|
|
85
|
+
|
|
86
|
+
### Async Client (FastAPI / Modern Apps)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import asyncio
|
|
90
|
+
import os
|
|
91
|
+
from redenv import Redenv
|
|
92
|
+
|
|
93
|
+
async def main():
|
|
94
|
+
client = Redenv({
|
|
95
|
+
"project": os.getenv("REDENV_PROJECT"),
|
|
96
|
+
"token_id": os.getenv("REDENV_TOKEN_ID"),
|
|
97
|
+
"token": os.getenv("REDENV_TOKEN_KEY"),
|
|
98
|
+
"upstash": {
|
|
99
|
+
"url": os.getenv("UPSTASH_REDIS_URL"),
|
|
100
|
+
"token": os.getenv("UPSTASH_REDIS_TOKEN")
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
# 1. Load Secrets (Populates os.environ by default)
|
|
105
|
+
secrets = await client.load()
|
|
106
|
+
|
|
107
|
+
# 2. Access Secrets
|
|
108
|
+
print(f"Database URL: {secrets['DATABASE_URL']}")
|
|
109
|
+
|
|
110
|
+
# 3. Smart Casting
|
|
111
|
+
port = secrets.get("PORT", cast=int)
|
|
112
|
+
debug = secrets.get("DEBUG", cast=bool)
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
asyncio.run(main())
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Synchronous Client (Flask / Scripts / Legacy)
|
|
119
|
+
|
|
120
|
+
Perfect for scripts or frameworks where `async/await` is not available at the top level.
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from redenv import RedenvSync
|
|
124
|
+
|
|
125
|
+
client = RedenvSync({ ... }) # Same config as above
|
|
126
|
+
|
|
127
|
+
# Blocks until secrets are fetched
|
|
128
|
+
secrets = client.load()
|
|
129
|
+
|
|
130
|
+
print(secrets["API_KEY"])
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Advanced Usage
|
|
134
|
+
|
|
135
|
+
### 1. Scoping & Validation
|
|
136
|
+
Organize large configurations and ensure critical keys exist.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
secrets = await client.load()
|
|
140
|
+
|
|
141
|
+
# Fail if these keys are missing
|
|
142
|
+
secrets.require("STRIPE_KEY", "STRIPE_WEBHOOK")
|
|
143
|
+
|
|
144
|
+
# Create a subset of keys (e.g., keys starting with "STRIPE_")
|
|
145
|
+
# The prefix is automatically stripped.
|
|
146
|
+
stripe_config = secrets.scope("STRIPE_")
|
|
147
|
+
|
|
148
|
+
print(stripe_config["KEY"]) # Maps to STRIPE_KEY
|
|
149
|
+
print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### 2. Time Travel (Version History)
|
|
153
|
+
Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# Get the absolute version 5
|
|
157
|
+
v5 = await client.get_version("API_KEY", 5)
|
|
158
|
+
|
|
159
|
+
# Get the previous version (1 version older than latest)
|
|
160
|
+
# Mode="index": 0=Latest, 1=Previous, -1=Oldest
|
|
161
|
+
prev = await client.get_version("API_KEY", 1, mode="index")
|
|
162
|
+
|
|
163
|
+
# Get the oldest version ever created
|
|
164
|
+
first = await client.get_version("API_KEY", -1)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### 3. Writing Secrets
|
|
168
|
+
You can update secrets programmatically. This automatically encrypts the value, increments the version, and updates the history.
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
await client.set("FEATURE_FLAG", "true")
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 4. Configuration Options
|
|
175
|
+
|
|
176
|
+
| Option | Type | Description | Default |
|
|
177
|
+
|:---|:---|:---|:---|
|
|
178
|
+
| `project` | str | Your Project ID | Required |
|
|
179
|
+
| `token_id` | str | Service Token Public ID | Required |
|
|
180
|
+
| `token` | str | Service Token Secret Key | Required |
|
|
181
|
+
| `upstash` | dict | `{ url: ..., token: ... }` | Required |
|
|
182
|
+
| `environment` | str | Target environment (dev, prod) | `development` |
|
|
183
|
+
| `log` | str | Log level (`none`, `low`, `high`) | `low` |
|
|
184
|
+
| `cache` | dict | `{ ttl: 300, swr: 86400 }` (seconds) | 5min / 24h |
|
|
185
|
+
| `env.override` | bool | Overwrite existing `os.environ` keys | `True` |
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
client = Redenv({
|
|
189
|
+
# ...
|
|
190
|
+
"env": {
|
|
191
|
+
"override": False # Protects local env vars from being overwritten
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Security
|
|
197
|
+
|
|
198
|
+
- **Masking:** If you accidentally print the `secrets` object, values are hidden: `Secrets({'API_KEY': '********'})`.
|
|
199
|
+
- **Zero-Knowledge:** The server (Upstash) never sees the plaintext. Decryption happens only in your application's memory.
|
|
200
|
+
|
|
201
|
+
## License
|
|
202
|
+
|
|
203
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
redenv/__init__.py,sha256=FA93IXHIpI5yfm3NeytvuHnE0lcIwmymW7fer9jtV6U,211
|
|
2
|
+
redenv/client.py,sha256=zKy7YHt8su4u7mxAtFRIyYEBU-4x4SYqV1V91oRyb1s,5126
|
|
3
|
+
redenv/crypto.py,sha256=KtN7yv9qVPNdNQcWIQfiUakdLULulfs2oT39-oJq6wc,2196
|
|
4
|
+
redenv/errors.py,sha256=AMCC_ESUnqBfFHhJ_sGrvOzuR9MV-jwvpjQ-6KWQnuA,238
|
|
5
|
+
redenv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
redenv/secrets.py,sha256=jVdjkJPMG4nqppzJ00eBTjyZ0V6FTWmvQ-aBQAnD6eM,3105
|
|
7
|
+
redenv/types.py,sha256=DlTZPTMzwQlqMEQTi2RzYsI4Qu8PZJem8HpjwmcXYyQ,1680
|
|
8
|
+
redenv/utils.py,sha256=ZJm6RIVjZqwe4_kogb8H3W3wY3y90QqSm18-ZuB5oPk,8632
|
|
9
|
+
redenv/sync/__init__.py,sha256=tP9hJvl09rcEWs6ZJWz-Jo7WI2wHGTdsLeMbs8DONgY,49
|
|
10
|
+
redenv/sync/client.py,sha256=d0H1m-k4XZYm9MKR1IIUBIpLd_C3rCb1o5gRshpEsxg,4950
|
|
11
|
+
redenv/sync/utils.py,sha256=9D2ei-4_OSWwbfzA3I_DqeMdLGX2DkUjo-NhCs0b3bU,6819
|
|
12
|
+
redenv-0.2.0.dist-info/METADATA,sha256=WlPSRF1Z8soaDALdr1ycozid47Mo2PdFeGglCpWOuqM,7247
|
|
13
|
+
redenv-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
14
|
+
redenv-0.2.0.dist-info/licenses/LICENSE,sha256=HFevb6E77SZAFe4_NUry6n_xpbs8FMzN8M8Tpvb753M,1061
|
|
15
|
+
redenv-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PRAS
|
|
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.
|