redenv 0.2.0__tar.gz → 0.4.0__tar.gz

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.
@@ -6,4 +6,6 @@ build
6
6
  dist
7
7
  .eggs
8
8
  __pycache__
9
- .pytest_cache
9
+ .pytest_cache
10
+ .ruff_cache
11
+ .venv
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Changed
8
+
9
+ - **Atomic Secret Updates:** Refactored `client.set()` (async and sync) to use a Lua script for atomic "read-modify-write" operations in Redis. This prevents race conditions and data loss during concurrent secret updates.
10
+ - **Cluster-Safe Architecture:** Optimized the update flow to be Redis Cluster compatible by separating metadata retrieval from the atomic write operation, avoiding CROSSSLOT errors.
11
+
12
+ ## [0.3.0] - 2026-01-25
13
+
14
+ ### Added
15
+
16
+ - **Secret Expansion:** Support for `${VAR_NAME}` syntax for referencing other secrets within values.
17
+ - **Raw Value Access:** Added `.raw` property to `Secrets` object to access unexpanded values.
18
+ - **Recursive Resolution:** Variable expansion supports multi-level recursion with circular dependency detection.
19
+
20
+ ### Changed
21
+
22
+ - **Safe Access:** Accessing a non-existent secret via `secrets["KEY"]` now returns `None` instead of raising a `KeyError`.
23
+ - **Improved Scoping:** `secrets.scope()` now correctly preserves both expanded and raw values in the resulting subset.
24
+
5
25
  ## [0.2.0] - 2026-01-22
6
26
 
7
27
  ### Added
@@ -10,15 +10,18 @@ test:
10
10
 
11
11
  # Run linting and type checking
12
12
  lint:
13
- python -m ruff check src tests
13
+ python -m ruff check src
14
14
  python -m pyright src
15
15
 
16
16
  # Build the package artifacts
17
17
  build:
18
- rm -rf dist/ build/
18
+ rm -rf dist/
19
19
  python -m build
20
20
 
21
21
  # Clean up build artifacts and caches
22
22
  clean:
23
23
  rm -rf dist/ build/ *.egg-info .pytest_cache .ruff_cache
24
24
  find . -type d -name "__pycache__" -exec rm -rf {} +
25
+
26
+ publish:
27
+ twine upload dist/*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redenv
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: A zero-knowledge, end-to-end encrypted secret management SDK for Python.
5
5
  Project-URL: Homepage, https://github.com/redenv-labs/redenv
6
6
  Project-URL: Documentation, https://github.com/redenv-labs/redenv/tree/main/packages/python-client
@@ -50,6 +50,7 @@ Provides-Extra: dev
50
50
  Requires-Dist: build; extra == 'dev'
51
51
  Requires-Dist: pyright; extra == 'dev'
52
52
  Requires-Dist: pytest; extra == 'dev'
53
+ Requires-Dist: pytest-asyncio; extra == 'dev'
53
54
  Requires-Dist: python-dotenv; extra == 'dev'
54
55
  Requires-Dist: ruff; extra == 'dev'
55
56
  Requires-Dist: twine; extra == 'dev'
@@ -110,6 +111,12 @@ async def main():
110
111
  # 3. Smart Casting
111
112
  port = secrets.get("PORT", cast=int)
112
113
  debug = secrets.get("DEBUG", cast=bool)
114
+
115
+ # 4. Safe Access (Returns None if missing)
116
+ missing = secrets["MISSING_KEY"] # None
117
+
118
+ # 5. Fallback Values
119
+ timeout = secrets.get("TIMEOUT", default=30, cast=int)
113
120
 
114
121
  if __name__ == "__main__":
115
122
  asyncio.run(main())
@@ -132,7 +139,30 @@ print(secrets["API_KEY"])
132
139
 
133
140
  ## Advanced Usage
134
141
 
135
- ### 1. Scoping & Validation
142
+ ### 1. Secret Expansion (Reference other keys)
143
+ Redenv supports referencing other secrets using the `${VAR_NAME}` syntax. This helps avoid duplication.
144
+
145
+ **Example Configuration:**
146
+ - `BASE_URL`: `https://api.example.com`
147
+ - `AUTH_URL`: `${BASE_URL}/auth`
148
+
149
+ **Usage:**
150
+ ```python
151
+ secrets = await client.load()
152
+
153
+ print(secrets["AUTH_URL"])
154
+ # Output: https://api.example.com/auth
155
+ ```
156
+
157
+ ### 2. Raw Values
158
+ You can access the unexpanded, raw value of a secret using the `.raw` property. This is useful for debugging or editing.
159
+
160
+ ```python
161
+ print(secrets["AUTH_URL"]) # https://api.example.com/auth
162
+ print(secrets.raw["AUTH_URL"]) # ${BASE_URL}/auth
163
+ ```
164
+
165
+ ### 3. Scoping & Validation
136
166
  Organize large configurations and ensure critical keys exist.
137
167
 
138
168
  ```python
@@ -149,7 +179,7 @@ print(stripe_config["KEY"]) # Maps to STRIPE_KEY
149
179
  print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
150
180
  ```
151
181
 
152
- ### 2. Time Travel (Version History)
182
+ ### 4. Time Travel (Version History)
153
183
  Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
154
184
 
155
185
  ```python
@@ -53,6 +53,12 @@ async def main():
53
53
  # 3. Smart Casting
54
54
  port = secrets.get("PORT", cast=int)
55
55
  debug = secrets.get("DEBUG", cast=bool)
56
+
57
+ # 4. Safe Access (Returns None if missing)
58
+ missing = secrets["MISSING_KEY"] # None
59
+
60
+ # 5. Fallback Values
61
+ timeout = secrets.get("TIMEOUT", default=30, cast=int)
56
62
 
57
63
  if __name__ == "__main__":
58
64
  asyncio.run(main())
@@ -75,7 +81,30 @@ print(secrets["API_KEY"])
75
81
 
76
82
  ## Advanced Usage
77
83
 
78
- ### 1. Scoping & Validation
84
+ ### 1. Secret Expansion (Reference other keys)
85
+ Redenv supports referencing other secrets using the `${VAR_NAME}` syntax. This helps avoid duplication.
86
+
87
+ **Example Configuration:**
88
+ - `BASE_URL`: `https://api.example.com`
89
+ - `AUTH_URL`: `${BASE_URL}/auth`
90
+
91
+ **Usage:**
92
+ ```python
93
+ secrets = await client.load()
94
+
95
+ print(secrets["AUTH_URL"])
96
+ # Output: https://api.example.com/auth
97
+ ```
98
+
99
+ ### 2. Raw Values
100
+ You can access the unexpanded, raw value of a secret using the `.raw` property. This is useful for debugging or editing.
101
+
102
+ ```python
103
+ print(secrets["AUTH_URL"]) # https://api.example.com/auth
104
+ print(secrets.raw["AUTH_URL"]) # ${BASE_URL}/auth
105
+ ```
106
+
107
+ ### 3. Scoping & Validation
79
108
  Organize large configurations and ensure critical keys exist.
80
109
 
81
110
  ```python
@@ -92,7 +121,7 @@ print(stripe_config["KEY"]) # Maps to STRIPE_KEY
92
121
  print(stripe_config["WEBHOOK"]) # Maps to STRIPE_WEBHOOK
93
122
  ```
94
123
 
95
- ### 2. Time Travel (Version History)
124
+ ### 4. Time Travel (Version History)
96
125
  Redenv stores a history of every secret change. You can access older versions for rollbacks or auditing.
97
126
 
98
127
  ```python
@@ -0,0 +1,147 @@
1
+ import asyncio
2
+ import json
3
+ import time
4
+ import os
5
+ from upstash_redis.asyncio import Redis
6
+ from redenv.utils import set_secret
7
+ from redenv.crypto import derive_key, generate_salt, random_bytes, encrypt, buffer_to_hex, decrypt
8
+ from redenv.types import RedenvOptions, UpstashConfig
9
+
10
+ # Credentials from client/example.ts
11
+ UPSTASH_URL = os.getenv("UPSTASH_URL")
12
+ UPSTASH_TOKEN = os.getenv("UPSTASH_TOKEN")
13
+
14
+ async def main():
15
+ if not UPSTASH_URL or not UPSTASH_TOKEN:
16
+ raise ValueError("UPSTASH_URL and UPSTASH_TOKEN must be set")
17
+
18
+ project_name = f"test-atomic-py-{int(time.time())}"
19
+ environment = "dev"
20
+ key = "ATOMIC_KEY_PY"
21
+ value1 = "value-1"
22
+ value2 = "value-2"
23
+ user = "python-integration-test"
24
+
25
+ # Direct Redis access for verification
26
+ redis = Redis(url=UPSTASH_URL, token=UPSTASH_TOKEN)
27
+
28
+ # Setup Options object manually since we are testing set_secret util directly
29
+ options = RedenvOptions(
30
+ project=project_name,
31
+ token_id="stk_test",
32
+ token="redenv_sk_test",
33
+ upstash=UpstashConfig(url=UPSTASH_URL, token=UPSTASH_TOKEN),
34
+ environment=environment,
35
+ log="none"
36
+ )
37
+
38
+ print(f"\n--- Starting Python Real Integration Test ---")
39
+ print(f"Project: {project_name}")
40
+
41
+ try:
42
+ # 1. Setup Metadata & Keys
43
+ print("1. Creating project metadata...")
44
+
45
+ # We need a valid PEK encrypted in metadata for get_pek to work
46
+ # Generate real PEK
47
+ salt = generate_salt()
48
+ pek = random_bytes(32) # PEK is 32 bytes (256 bits)
49
+
50
+ # Wrap PEK with our mock service token
51
+ token_key = derive_key("redenv_sk_test", salt)
52
+ # Encrypt the HEX representation of PEK
53
+ encrypted_pek = encrypt(buffer_to_hex(pek), token_key)
54
+
55
+ await redis.hset(f"meta@{project_name}", values={
56
+ "historyLimit": 5,
57
+ "serviceTokens": json.dumps({
58
+ "stk_test": {
59
+ "salt": buffer_to_hex(salt),
60
+ "encryptedPEK": encrypted_pek,
61
+ "name": "Test Token"
62
+ }
63
+ })
64
+ })
65
+
66
+ # 2. First Write
67
+ print(f"2. Writing first version: '{value1}'...")
68
+ await set_secret(redis, options, key, value1)
69
+ print(" ✓ Write successful")
70
+
71
+ # 3. Second Write
72
+ print(f"3. Writing second version: '{value2}'...")
73
+ await set_secret(redis, options, key, value2)
74
+ print(" ✓ Update successful")
75
+
76
+ # 4. Verification
77
+ print("4. Verifying data in Redis...")
78
+ raw_data = await redis.hget(f"{environment}:{project_name}", key)
79
+ history = json.loads(raw_data) if isinstance(raw_data, str) else raw_data
80
+
81
+ print(f" History length: {len(history)} (Expected: 2)")
82
+ if len(history) != 2:
83
+ raise Exception(f"History length mismatch! Got {len(history)}")
84
+
85
+ # Check V2 (Latest)
86
+ v2 = history[0]
87
+ decrypted_v2 = decrypt(v2["value"], pek)
88
+ print(f" v{v2['version']} Value: '{decrypted_v2}' (Expected: '{value2}')")
89
+ if decrypted_v2 != value2:
90
+ raise Exception("Latest value mismatch!")
91
+
92
+ # Check V1
93
+ v1 = history[1]
94
+ decrypted_v1 = decrypt(v1["value"], pek)
95
+ print(f" v{v1['version']} Value: '{decrypted_v1}' (Expected: '{value1}')")
96
+ if decrypted_v1 != value1:
97
+ raise Exception("Previous value mismatch!")
98
+
99
+ # 5. Concurrency Test
100
+ print("\n5. Testing Concurrency (Race Conditions)...")
101
+ parallel_writes = 5
102
+ print(f" Firing {parallel_writes} writes in parallel...")
103
+
104
+ tasks = []
105
+ for i in range(parallel_writes):
106
+ tasks.append(set_secret(redis, options, key, f"concurrent-{i}"))
107
+
108
+ await asyncio.gather(*tasks)
109
+ print(" ✓ Parallel writes completed")
110
+
111
+ # 6. Verify Concurrency
112
+ print("6. Verifying concurrency results...")
113
+ raw_data_concurrent = await redis.hget(f"{environment}:{project_name}", key)
114
+ history = json.loads(raw_data_concurrent) if isinstance(raw_data_concurrent, str) else raw_data_concurrent
115
+
116
+ # Initial 2 + 5 = 7 total versions created.
117
+ # But historyLimit is 5.
118
+ print(f" History length: {len(history)} (Expected Cap: 5)")
119
+ if len(history) != 5:
120
+ raise Exception(f"History should be capped at 5! Got {len(history)}")
121
+
122
+ latest_version = history[0]['version']
123
+ expected_version = 2 + parallel_writes # 7
124
+ print(f" Latest Version: {latest_version} (Expected: {expected_version})")
125
+
126
+ if latest_version != expected_version:
127
+ raise Exception(f"Race condition detected! Expected version {expected_version}, got {latest_version}. Updates were lost.")
128
+
129
+ # Ensure all versions are unique
130
+ versions = [h['version'] for h in history]
131
+ unique_versions = set(versions)
132
+ if len(versions) != len(unique_versions):
133
+ raise Exception("Duplicate version numbers detected!")
134
+
135
+ print("\n✅ SUCCESS: Python Atomic set_secret is working correctly on real Redis.")
136
+
137
+ except Exception as e:
138
+ print(f"\n❌ FAILED: {e}")
139
+ import traceback
140
+ traceback.print_exc()
141
+ finally:
142
+ print("\nCleaning up...")
143
+ await redis.delete(f"meta@{project_name}")
144
+ await redis.delete(f"{environment}:{project_name}")
145
+
146
+ if __name__ == "__main__":
147
+ asyncio.run(main())
@@ -4,7 +4,6 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "redenv"
7
- version = "0.2.0"
8
7
  description = "A zero-knowledge, end-to-end encrypted secret management SDK for Python."
9
8
  readme = "README.md"
10
9
  requires-python = ">=3.8"
@@ -33,6 +32,11 @@ dependencies = [
33
32
  "cachetools>=5.0.0",
34
33
  ]
35
34
 
35
+ dynamic = ["version"]
36
+
37
+ [tool.hatch.version]
38
+ path = "src/redenv/__init__.py"
39
+
36
40
  [project.urls]
37
41
  Homepage = "https://github.com/redenv-labs/redenv"
38
42
  Documentation = "https://github.com/redenv-labs/redenv/tree/main/packages/python-client"
@@ -42,6 +46,7 @@ Issues = "https://github.com/redenv-labs/redenv/issues"
42
46
  [project.optional-dependencies]
43
47
  dev = [
44
48
  "pytest",
49
+ "pytest-asyncio",
45
50
  "python-dotenv",
46
51
  "build",
47
52
  "twine",
@@ -3,5 +3,5 @@ from .errors import RedenvError
3
3
  from .secrets import Secrets
4
4
  from .sync import Redenv as RedenvSync
5
5
 
6
- __version__ = "0.2.0"
6
+ __version__ = "0.4.0"
7
7
  __all__ = ["Redenv", "RedenvSync", "RedenvError", "Secrets"]
@@ -0,0 +1,45 @@
1
+ import re
2
+ from typing import Dict
3
+ from .errors import RedenvError
4
+
5
+ # Match ${VAR} not preceded by a backslash
6
+ REFERENCE_REGEX = re.compile(r"(?<!\\)\$\{([a-zA-Z0-9_]+)\}")
7
+
8
+ def expand_secrets(secrets: Dict[str, str]) -> Dict[str, str]:
9
+ r"""
10
+ Expands variable references in a dictionary of secrets.
11
+ Supports recursion, cycle detection, and escaped references (\${VAR}).
12
+ """
13
+ expanded: Dict[str, str] = {}
14
+ cache: Dict[str, str] = {}
15
+ stack: list[str] = []
16
+
17
+ def resolve(key: str) -> str:
18
+ if key in cache:
19
+ return cache[key]
20
+
21
+ if key in stack:
22
+ cycle = " -> ".join(stack + [key])
23
+ raise RedenvError(f"Circular dependency detected: {cycle}", "INVALID_INPUT")
24
+
25
+ stack.append(key)
26
+ value = secrets[key]
27
+
28
+ def replacer(match: re.Match) -> str:
29
+ ref_key = match.group(1)
30
+ return resolve(ref_key) if ref_key in secrets else match.group(0)
31
+
32
+ # Expand all unescaped references
33
+ resolved = REFERENCE_REGEX.sub(replacer, value)
34
+
35
+ # Convert escaped references \${VAR} -> ${VAR}
36
+ resolved = resolved.replace(r"\${", "${")
37
+
38
+ cache[key] = resolved
39
+ stack.pop()
40
+ return resolved
41
+
42
+ for key in secrets:
43
+ expanded[key] = resolve(key)
44
+
45
+ return expanded
@@ -10,6 +10,18 @@ class Secrets(dict):
10
10
  type-casting, scoping, and validation capabilities.
11
11
  """
12
12
 
13
+ def __init__(self, data: Optional[dict] = None, raw_data: Optional[dict] = None):
14
+ super().__init__(data or {})
15
+ self._raw_data = raw_data or data or {}
16
+
17
+ @property
18
+ def raw(self) -> "Secrets":
19
+ """
20
+ Returns a Secrets object containing the raw, unexpanded values.
21
+ """
22
+ # Create a new Secrets object with the raw data, but mark it as its own raw
23
+ return Secrets(self._raw_data, raw_data=self._raw_data)
24
+
13
25
  def get(self, key: str, default: Any = None, cast: Optional[Union[Type[T], Callable[[Any], T]]] = None) -> Union[T, Any]:
14
26
  """
15
27
  Retrieves a secret and optionally casts it to a specific type.
@@ -46,26 +58,31 @@ class Secrets(dict):
46
58
 
47
59
  def __getitem__(self, key: str) -> Any:
48
60
  """
49
- Ensures standard dict access still works but raises a
50
- helpful error if the key is missing.
61
+ Returns None if the key is not found instead of raising KeyError.
51
62
  """
52
- try:
53
- return super().__getitem__(key)
54
- except KeyError:
55
- raise KeyError(f"Secret '{key}' not found in Redenv.")
63
+ return self.get(key)
56
64
 
57
65
  def scope(self, prefix: str) -> "Secrets":
58
66
  """
59
67
  Returns a new Secrets object containing only the keys that start with
60
68
  the given prefix. The prefix is stripped from the keys in the new object.
61
69
  """
62
- subset = Secrets()
70
+ subset_data = {}
71
+ subset_raw = {}
72
+
63
73
  for key, value in self.items():
64
74
  if key.startswith(prefix):
65
75
  new_key = key[len(prefix):]
66
76
  if new_key:
67
- subset[new_key] = value
68
- return subset
77
+ subset_data[new_key] = value
78
+
79
+ for key, value in self._raw_data.items():
80
+ if key.startswith(prefix):
81
+ new_key = key[len(prefix):]
82
+ if new_key:
83
+ subset_raw[new_key] = value
84
+
85
+ return Secrets(subset_data, raw_data=subset_raw)
69
86
 
70
87
  def require(self, *keys: str) -> "Secrets":
71
88
  """
@@ -7,6 +7,7 @@ from ..crypto import derive_key, decrypt, hex_to_buffer, encrypt
7
7
  from ..types import RedenvOptions, CacheEntry
8
8
  from ..errors import RedenvError
9
9
  from ..secrets import Secrets
10
+ from ..expand import expand_secrets
10
11
  from ..utils import log, error
11
12
  from cachetools import LRUCache
12
13
 
@@ -79,8 +80,17 @@ def fetch_and_decrypt(redis: SyncRedis, options: RedenvOptions) -> Secrets:
79
80
  error(f'Failed to decrypt secret "{key}".', options.log)
80
81
  continue
81
82
 
82
- log(f"Successfully loaded {len(secrets)} secrets.", options.log)
83
- return secrets
83
+ # Capture raw decrypted secrets before expansion
84
+ raw_decrypted = dict(secrets)
85
+
86
+ # Expand variables
87
+ expanded_secrets = expand_secrets(raw_decrypted)
88
+
89
+ # Create final Secrets object with both expanded and raw data
90
+ result = Secrets(expanded_secrets, raw_data=raw_decrypted)
91
+
92
+ log(f"Successfully loaded {len(result)} secrets.", options.log)
93
+ return result
84
94
 
85
95
  def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
86
96
  """
@@ -100,14 +110,12 @@ def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions
100
110
 
101
111
  def set_secret(redis: SyncRedis, options: RedenvOptions, key: str, value: str):
102
112
  """
103
- Sets a secret in Redis with versioning and history.
113
+ Sets a secret in Redis.
104
114
  """
105
115
  env_key = f"{options.environment}:{options.project}"
106
116
  meta_key = f"meta@{options.project}"
107
117
 
108
- # Sequential fetch (Simpler for sync, parallel requires threads)
109
118
  metadata = redis.hgetall(meta_key)
110
- current_history_str = redis.hget(env_key, key)
111
119
 
112
120
  if not metadata:
113
121
  raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
@@ -116,32 +124,66 @@ def set_secret(redis: SyncRedis, options: RedenvOptions, key: str, value: str):
116
124
 
117
125
  history_limit = int(metadata.get("historyLimit", 10))
118
126
 
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
127
  encrypted_value = encrypt(value, pek)
129
128
 
130
129
  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")
130
+ created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
131
+
132
+ script = """
133
+ local env_key = KEYS[1]
134
+ local field = ARGV[1]
135
+ local encrypted_value = ARGV[2]
136
+ local user = ARGV[3]
137
+ local created_at = ARGV[4]
138
+ local history_limit = tonumber(ARGV[5])
139
+
140
+ -- Fetch Current History
141
+ local current_data = redis.call('HGET', env_key, field)
142
+ local history = {}
143
+
144
+ if current_data then
145
+ local status, res = pcall(cjson.decode, current_data)
146
+ if status then
147
+ history = res
148
+ end
149
+ end
150
+
151
+ -- Determine Next Version
152
+ local last_version = 0
153
+ if #history > 0 and history[1] and history[1]['version'] then
154
+ last_version = history[1]['version']
155
+ end
156
+
157
+ -- Create New Record
158
+ local new_version = {
159
+ version = last_version + 1,
160
+ value = encrypted_value,
161
+ user = user,
162
+ createdAt = created_at
137
163
  }
164
+
165
+ -- Prepend (Newest First)
166
+ table.insert(history, 1, new_version)
167
+
168
+ -- Trim History
169
+ if history_limit > 0 then
170
+ while #history > history_limit do
171
+ table.remove(history)
172
+ end
173
+ end
174
+
175
+ -- Save and Return
176
+ local encoded = cjson.encode(history)
177
+ redis.call('HSET', env_key, field, encoded)
178
+
179
+ return encoded
180
+ """
138
181
 
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))
182
+ return redis.eval(
183
+ script,
184
+ [env_key],
185
+ [key, encrypted_value, options.token_id, created_at, str(history_limit)]
186
+ )
145
187
 
146
188
  def get_secret_version(redis: SyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
147
189
  """
@@ -1,9 +1,9 @@
1
1
  from .crypto import derive_key, decrypt, hex_to_buffer, encrypt
2
2
  from .types import RedenvOptions, LogPreference, CacheEntry
3
3
  from .errors import RedenvError
4
+ from .expand import expand_secrets
4
5
  from upstash_redis import AsyncRedis
5
6
  from .secrets import Secrets
6
- import asyncio
7
7
  import json
8
8
  import os
9
9
  import time
@@ -114,8 +114,17 @@ async def fetch_and_decrypt(redis: AsyncRedis, options: RedenvOptions) -> Secret
114
114
  error(f'Failed to decrypt secret "{key}".', options.log)
115
115
  continue
116
116
 
117
- log(f"Successfully loaded {len(secrets)} secrets.", options.log)
118
- return secrets
117
+ # Capture raw decrypted secrets before expansion
118
+ raw_decrypted = dict(secrets)
119
+
120
+ # Expand variables
121
+ expanded_secrets = expand_secrets(raw_decrypted)
122
+
123
+ # Create final Secrets object with both expanded and raw data
124
+ result = Secrets(expanded_secrets, raw_data=raw_decrypted)
125
+
126
+ log(f"Successfully loaded {len(result)} secrets.", options.log)
127
+ return result
119
128
 
120
129
  async def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvOptions):
121
130
  """
@@ -135,56 +144,85 @@ async def populate_env(secrets: Union[Dict[str, str], Secrets], options: RedenvO
135
144
 
136
145
  async def set_secret(redis: AsyncRedis, options: RedenvOptions, key: str, value: str):
137
146
  """
138
- Sets a secret in Redis with versioning and history.
147
+ Sets a secret in Redis.
139
148
  """
140
149
  env_key = f"{options.environment}:{options.project}"
141
150
  meta_key = f"meta@{options.project}"
142
151
 
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
- )
152
+ # We do this outside Lua to avoid CROSSSLOT errors in Redis Cluster/Upstash
153
+ metadata = await redis.hgetall(meta_key)
148
154
 
149
155
  if not metadata:
150
156
  raise RedenvError(f'Project "{options.project}" not found.', "PROJECT_NOT_FOUND")
151
157
 
152
- # Reuse metadata to get PEK without extra fetch
158
+ # Reuse metadata to get PEK
153
159
  pek = await get_pek(redis, options, metadata)
154
160
 
155
161
  history_limit = int(metadata.get("historyLimit", 10))
156
162
 
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
163
  # Encrypt new value
168
164
  encrypted_value = encrypt(value, pek)
169
165
 
170
166
  from datetime import datetime, timezone
167
+ created_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
171
168
 
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")
169
+ # KEYS[1] = env_key
170
+ # ARGV[1] = field (key), ARGV[2] = encrypted_value, ARGV[3] = user, ARGV[4] = created_at, ARGV[5] = history_limit
171
+ script = """
172
+ local env_key = KEYS[1]
173
+ local field = ARGV[1]
174
+ local encrypted_value = ARGV[2]
175
+ local user = ARGV[3]
176
+ local created_at = ARGV[4]
177
+ local history_limit = tonumber(ARGV[5])
178
+
179
+ -- Fetch Current History
180
+ local current_data = redis.call('HGET', env_key, field)
181
+ local history = {}
182
+
183
+ if current_data then
184
+ local status, res = pcall(cjson.decode, current_data)
185
+ if status then
186
+ history = res
187
+ end
188
+ end
189
+
190
+ -- Determine Next Version
191
+ local last_version = 0
192
+ if #history > 0 and history[1] and history[1]['version'] then
193
+ last_version = history[1]['version']
194
+ end
195
+
196
+ -- Create New Record
197
+ local new_version = {
198
+ version = last_version + 1,
199
+ value = encrypted_value,
200
+ user = user,
201
+ createdAt = created_at
177
202
  }
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))
203
+
204
+ -- Prepend (Newest First)
205
+ table.insert(history, 1, new_version)
206
+
207
+ -- Trim History
208
+ if history_limit > 0 then
209
+ while #history > history_limit do
210
+ table.remove(history)
211
+ end
212
+ end
213
+
214
+ -- Save and Return
215
+ local encoded = cjson.encode(history)
216
+ redis.call('HSET', env_key, field, encoded)
217
+
218
+ return encoded
219
+ """
220
+
221
+ return await redis.eval(
222
+ script,
223
+ [env_key],
224
+ [key, encrypted_value, options.token_id, created_at, str(history_limit)]
225
+ )
188
226
 
189
227
  async def get_secret_version(redis: AsyncRedis, options: RedenvOptions, cache: LRUCache, key: str, version: int, mode: Literal["id", "index"] = "id") -> Optional[str]:
190
228
  """
@@ -0,0 +1,144 @@
1
+ import pytest
2
+ import json
3
+ from unittest.mock import AsyncMock, patch
4
+ from redenv import Redenv
5
+ from redenv.crypto import encrypt, derive_key, buffer_to_hex, random_bytes
6
+
7
+ # --- Test Data Setup ---
8
+ PASSWORD = "masterpassword"
9
+ SALT = random_bytes(16)
10
+ TOKEN_SALT = random_bytes(16)
11
+ TOKEN_SECRET = "redenv_sk_test"
12
+ TOKEN_ID = "stk_test"
13
+
14
+ # 1. Derive PEK
15
+ PEK = derive_key(PASSWORD, SALT) # Project Encryption Key
16
+
17
+ # 2. Encrypt PEK with Service Token
18
+ token_key = derive_key(TOKEN_SECRET, TOKEN_SALT)
19
+ encrypted_pek = encrypt(buffer_to_hex(PEK), token_key) # Wait, exportKey not implemented in python utils, we used bytes.
20
+ # In utils.py get_pek: decrypted_pek_hex = decrypt(..., token_key) -> return hex_to_buffer(decrypted_pek_hex)
21
+ # So we need to encrypt the HEX string of the PEK.
22
+ pek_hex = buffer_to_hex(PEK)
23
+ encrypted_pek_str = encrypt(pek_hex, token_key)
24
+
25
+ # 3. Create Metadata
26
+ METADATA = {
27
+ "serviceTokens": json.dumps({
28
+ TOKEN_ID: {
29
+ "salt": buffer_to_hex(TOKEN_SALT),
30
+ "encryptedPEK": encrypted_pek_str,
31
+ "name": "Test Token"
32
+ }
33
+ }),
34
+ "historyLimit": "10"
35
+ }
36
+
37
+ # 4. Create Secret Data
38
+ SECRET_KEY = "API_KEY"
39
+ SECRET_VAL = "super-secret-value"
40
+ encrypted_secret = encrypt(SECRET_VAL, PEK)
41
+
42
+ SECRET_HISTORY = [
43
+ {
44
+ "version": 1,
45
+ "value": encrypted_secret,
46
+ "user": "test-user",
47
+ "createdAt": "2023-01-01T00:00:00Z"
48
+ }
49
+ ]
50
+
51
+ ENVIRONMENT_DATA = {
52
+ SECRET_KEY: json.dumps(SECRET_HISTORY)
53
+ }
54
+
55
+ # --- Client Setup ---
56
+ @pytest.fixture
57
+ def mock_redis():
58
+ mock = AsyncMock()
59
+ # Mock hgetall responses
60
+ def side_effect(key):
61
+ if key == "meta@test-project":
62
+ return METADATA
63
+ if key == "dev:test-project":
64
+ return ENVIRONMENT_DATA
65
+ return {}
66
+
67
+ mock.hgetall.side_effect = side_effect
68
+
69
+ # Mock hget response (for set_secret and get_version)
70
+ async def hget_side_effect(key, field):
71
+ if key == "dev:test-project" and field == SECRET_KEY:
72
+ return json.dumps(SECRET_HISTORY)
73
+ return None
74
+
75
+ mock.hget.side_effect = hget_side_effect
76
+
77
+ return mock
78
+
79
+ @pytest.fixture
80
+ def client(mock_redis):
81
+ with patch("redenv.client.Redis", return_value=mock_redis):
82
+ client = Redenv({
83
+ "project": "test-project",
84
+ "token_id": TOKEN_ID,
85
+ "token": TOKEN_SECRET,
86
+ "environment": "dev",
87
+ "upstash": {"url": "https://mock", "token": "mock"},
88
+ "log": "none"
89
+ })
90
+ return client
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_load_decrypts_successfully(client, mock_redis):
94
+ secrets = await client.load()
95
+ assert secrets[SECRET_KEY] == SECRET_VAL
96
+ assert mock_redis.hgetall.call_count == 2 # 1 meta, 1 env
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_caching_behavior(client, mock_redis):
100
+ # First load
101
+ await client.load()
102
+ assert mock_redis.hgetall.call_count == 2
103
+
104
+ # Second load (should hit cache)
105
+ await client.load()
106
+ assert mock_redis.hgetall.call_count == 2 # Counts shouldn't increase
107
+
108
+ @pytest.mark.asyncio
109
+ async def test_write_secret(client, mock_redis):
110
+ new_val = "new-value"
111
+ await client.set(SECRET_KEY, new_val)
112
+
113
+ # Verify eval was called (for Lua script)
114
+ assert mock_redis.eval.called
115
+
116
+ args = mock_redis.eval.call_args
117
+ script = args[0][0]
118
+ keys = args[0][1]
119
+ argv = args[0][2]
120
+
121
+ # Check script content basics
122
+ assert "local env_key = KEYS[1]" in script
123
+ assert "redis.call('HSET', env_key, field, encoded)" in script
124
+
125
+ # Check keys
126
+ assert keys[0] == "dev:test-project"
127
+
128
+ # Check args: [key, encrypted_value, user, created_at, history_limit]
129
+ assert argv[0] == SECRET_KEY
130
+ # We can't verify encrypted value exactly without decrypting, but it should be a string
131
+ assert isinstance(argv[1], str)
132
+ assert argv[2] == TOKEN_ID
133
+ # History Limit
134
+ assert int(argv[4]) == 10
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_get_version(client, mock_redis):
138
+ # Version 1 exists in our mock data
139
+ val = await client.get_version(SECRET_KEY, 1)
140
+ assert val == SECRET_VAL
141
+
142
+ # Version 99 does not exist
143
+ val_missing = await client.get_version(SECRET_KEY, 99)
144
+ assert val_missing is None
@@ -0,0 +1,59 @@
1
+ import pytest
2
+ from redenv.expand import expand_secrets
3
+ from redenv.errors import RedenvError
4
+
5
+ def test_basic_expansion():
6
+ secrets = {
7
+ "BASE_URL": "https://api.example.com",
8
+ "AUTH_URL": "${BASE_URL}/auth"
9
+ }
10
+ expanded = expand_secrets(secrets)
11
+ assert expanded["AUTH_URL"] == "https://api.example.com/auth"
12
+
13
+ def test_recursive_expansion():
14
+ secrets = {
15
+ "A": "value",
16
+ "B": "${A}",
17
+ "C": "${B}"
18
+ }
19
+ expanded = expand_secrets(secrets)
20
+ assert expanded["C"] == "value"
21
+
22
+ def test_multiple_references():
23
+ secrets = {
24
+ "PROTOCOL": "https",
25
+ "DOMAIN": "example.com",
26
+ "URL": "${PROTOCOL}://${DOMAIN}"
27
+ }
28
+ expanded = expand_secrets(secrets)
29
+ assert expanded["URL"] == "https://example.com"
30
+
31
+ def test_circular_dependency():
32
+ secrets = {
33
+ "A": "${B}",
34
+ "B": "${A}"
35
+ }
36
+ with pytest.raises(RedenvError, match="Circular dependency detected"):
37
+ expand_secrets(secrets)
38
+
39
+ def test_missing_reference():
40
+ secrets = {
41
+ "A": "${MISSING}"
42
+ }
43
+ expanded = expand_secrets(secrets)
44
+ assert expanded["A"] == "${MISSING}"
45
+
46
+ def test_escaped_reference():
47
+ secrets = {
48
+ "A": r"\${NOT_EXPANDED}"
49
+ }
50
+ expanded = expand_secrets(secrets)
51
+ assert expanded["A"] == "${NOT_EXPANDED}"
52
+
53
+ def test_mixed_escaped_and_normal():
54
+ secrets = {
55
+ "VAR": "value",
56
+ "A": r"\${VAR} and ${VAR}"
57
+ }
58
+ expanded = expand_secrets(secrets)
59
+ assert expanded["A"] == "${VAR} and value"
@@ -0,0 +1,96 @@
1
+ import pytest
2
+ from redenv.secrets import Secrets
3
+ from redenv.errors import RedenvError
4
+
5
+ def test_secrets_access():
6
+ data = {"KEY": "value"}
7
+ secrets = Secrets(data)
8
+ assert secrets["KEY"] == "value"
9
+ assert secrets.get("KEY") == "value"
10
+
11
+ # Missing keys should return None instead of raising KeyError
12
+ assert secrets["MISSING"] is None
13
+
14
+ def test_secrets_cast_int():
15
+ secrets = Secrets({"PORT": "8080", "BAD": "abc"})
16
+ assert secrets.get("PORT", cast=int) == 8080
17
+ assert secrets.get("BAD", cast=int) == None # Defaults to None on failure
18
+ assert secrets.get("BAD", default=0, cast=int) == 0
19
+
20
+ def test_secrets_cast_bool():
21
+ secrets = Secrets({
22
+ "FLAG_TRUE": "true",
23
+ "FLAG_1": "1",
24
+ "FLAG_YES": "yes",
25
+ "FLAG_FALSE": "false",
26
+ "FLAG_0": "0"
27
+ })
28
+
29
+ assert secrets.get("FLAG_TRUE", cast=bool) is True
30
+ assert secrets.get("FLAG_1", cast=bool) is True
31
+ assert secrets.get("FLAG_YES", cast=bool) is True
32
+ assert secrets.get("FLAG_FALSE", cast=bool) is False
33
+ assert secrets.get("FLAG_0", cast=bool) is False
34
+
35
+ def test_secrets_cast_json():
36
+ secrets = Secrets({"CONFIG": '{"foo": "bar"}'})
37
+ config = secrets.get("CONFIG", cast=dict)
38
+ assert isinstance(config, dict)
39
+ assert config["foo"] == "bar"
40
+
41
+ def test_secrets_scoping():
42
+ secrets = Secrets({
43
+ "APP_NAME": "MyApp",
44
+ "STRIPE_KEY": "sk_123",
45
+ "STRIPE_SECRET": "wh_456"
46
+ })
47
+
48
+ stripe = secrets.scope("STRIPE_")
49
+
50
+ # Prefix should be stripped
51
+ assert stripe["KEY"] == "sk_123"
52
+ assert stripe["SECRET"] == "wh_456"
53
+ # Unrelated keys should be ignored
54
+ assert "APP_NAME" not in stripe
55
+ # Original object should be untouched
56
+ assert secrets["STRIPE_KEY"] == "sk_123"
57
+
58
+ def test_secrets_require():
59
+ secrets = Secrets({"A": "1", "B": "2"})
60
+
61
+ # Should pass (chainable)
62
+ assert secrets.require("A", "B") is secrets
63
+
64
+ # Should fail
65
+ with pytest.raises(RedenvError, match="Missing required secrets: C"):
66
+ secrets.require("A", "C")
67
+
68
+ def test_secrets_masking():
69
+ secrets = Secrets({"API_KEY": "secret_value"})
70
+ output = str(secrets)
71
+
72
+ assert "API_KEY" in output
73
+ assert "secret_value" not in output
74
+ assert "********" in output
75
+
76
+ def test_secrets_raw_access():
77
+ raw_data = {"BASE": "val", "URL": "${BASE}/path"}
78
+ expanded_data = {"BASE": "val", "URL": "val/path"}
79
+
80
+ secrets = Secrets(expanded_data, raw_data=raw_data)
81
+
82
+ assert secrets["URL"] == "val/path"
83
+ assert secrets.raw["URL"] == "${BASE}/path"
84
+ assert secrets.raw["BASE"] == "val"
85
+
86
+ # Test scoping raw access
87
+ stripe_raw = {"STRIPE_KEY": "${BASE}/key"}
88
+ stripe_expanded = {"STRIPE_KEY": "val/key"}
89
+ full_secrets = Secrets(
90
+ {**expanded_data, **stripe_expanded},
91
+ raw_data={**raw_data, **stripe_raw}
92
+ )
93
+
94
+ stripe_scope = full_secrets.scope("STRIPE_")
95
+ assert stripe_scope["KEY"] == "val/key"
96
+ assert stripe_scope.raw["KEY"] == "${BASE}/key"
@@ -0,0 +1,135 @@
1
+ import pytest
2
+ import json
3
+ from unittest.mock import MagicMock, patch
4
+ from redenv.sync import Redenv as RedenvSync
5
+ from redenv.crypto import encrypt, derive_key, buffer_to_hex, random_bytes
6
+
7
+ # --- Test Data Setup (Mirroring test_client.py) ---
8
+ PASSWORD = "masterpassword"
9
+ SALT = random_bytes(16)
10
+ TOKEN_SALT = random_bytes(16)
11
+ TOKEN_SECRET = "redenv_sk_test"
12
+ TOKEN_ID = "stk_test"
13
+
14
+ # 1. Setup Keys
15
+ PEK = derive_key(PASSWORD, SALT)
16
+ token_key = derive_key(TOKEN_SECRET, TOKEN_SALT)
17
+ pek_hex = buffer_to_hex(PEK)
18
+ encrypted_pek_str = encrypt(pek_hex, token_key)
19
+
20
+ # 2. Metadata
21
+ METADATA = {
22
+ "serviceTokens": json.dumps({
23
+ TOKEN_ID: {
24
+ "salt": buffer_to_hex(TOKEN_SALT),
25
+ "encryptedPEK": encrypted_pek_str,
26
+ "name": "Test Token"
27
+ }
28
+ }),
29
+ "historyLimit": "10"
30
+ }
31
+
32
+ # 3. Secret Data
33
+ SECRET_KEY = "SYNC_API_KEY"
34
+ SECRET_VAL = "sync-secret-value"
35
+ encrypted_secret = encrypt(SECRET_VAL, PEK)
36
+
37
+ SECRET_HISTORY = [
38
+ {
39
+ "version": 1,
40
+ "value": encrypted_secret,
41
+ "user": "sync-user",
42
+ "createdAt": "2023-01-01T00:00:00Z"
43
+ }
44
+ ]
45
+
46
+ ENVIRONMENT_DATA = {
47
+ SECRET_KEY: json.dumps(SECRET_HISTORY)
48
+ }
49
+
50
+ # --- Sync Client Fixtures ---
51
+ @pytest.fixture
52
+ def mock_redis_sync():
53
+ mock = MagicMock()
54
+
55
+ # Mock hgetall
56
+ def hgetall_side_effect(key):
57
+ if key == "meta@sync-project":
58
+ return METADATA
59
+ if key == "prod:sync-project":
60
+ return ENVIRONMENT_DATA
61
+ return {}
62
+ mock.hgetall.side_effect = hgetall_side_effect
63
+
64
+ # Mock hget
65
+ def hget_side_effect(key, field):
66
+ if key == "prod:sync-project" and field == SECRET_KEY:
67
+ return json.dumps(SECRET_HISTORY)
68
+ return None
69
+ mock.hget.side_effect = hget_side_effect
70
+
71
+ return mock
72
+
73
+ @pytest.fixture
74
+ def sync_client(mock_redis_sync):
75
+ # Important: patch where Redis is IMPORTED in the sync client
76
+ with patch("redenv.sync.client.Redis", return_value=mock_redis_sync):
77
+ client = RedenvSync({
78
+ "project": "sync-project",
79
+ "token_id": TOKEN_ID,
80
+ "token": TOKEN_SECRET,
81
+ "environment": "prod",
82
+ "upstash": {"url": "https://mock", "token": "mock"},
83
+ "log": "none"
84
+ })
85
+ return client
86
+
87
+ # --- Sync Tests ---
88
+ def test_sync_load_decrypts(sync_client, mock_redis_sync):
89
+ secrets = sync_client.load()
90
+ assert secrets[SECRET_KEY] == SECRET_VAL
91
+ # Check that it called Redis
92
+ assert mock_redis_sync.hgetall.called
93
+
94
+ def test_sync_caching(sync_client, mock_redis_sync):
95
+ # First load
96
+ sync_client.load()
97
+ count_after_first = mock_redis_sync.hgetall.call_count
98
+
99
+ # Second load (should hit cache)
100
+ sync_client.load()
101
+ assert mock_redis_sync.hgetall.call_count == count_after_first
102
+
103
+ def test_sync_set_secret(sync_client, mock_redis_sync):
104
+ new_val = "new-sync-val"
105
+ sync_client.set(SECRET_KEY, new_val)
106
+
107
+ # Verify eval was called (for Lua script)
108
+ assert mock_redis_sync.eval.called
109
+
110
+ args = mock_redis_sync.eval.call_args
111
+ script = args[0][0]
112
+ keys = args[0][1]
113
+ argv = args[0][2]
114
+
115
+ # Check script content basics
116
+ assert "local env_key = KEYS[1]" in script
117
+ assert "redis.call('HSET', env_key, field, encoded)" in script
118
+
119
+ # Check keys
120
+ assert keys[0] == "prod:sync-project"
121
+
122
+ # Check args: [key, encrypted_value, user, created_at, history_limit]
123
+ assert argv[0] == SECRET_KEY
124
+ assert isinstance(argv[1], str)
125
+ assert argv[2] == TOKEN_ID
126
+ # History Limit
127
+ assert int(argv[4]) == 10
128
+
129
+ def test_sync_get_version(sync_client, mock_redis_sync):
130
+ # Valid version
131
+ val = sync_client.get_version(SECRET_KEY, 1)
132
+ assert val == SECRET_VAL
133
+
134
+ # Invalid version
135
+ assert sync_client.get_version(SECRET_KEY, 999) is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes