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.
- {redenv-0.2.0 → redenv-0.4.0}/.gitignore +3 -1
- {redenv-0.2.0 → redenv-0.4.0}/CHANGELOG.md +20 -0
- {redenv-0.2.0 → redenv-0.4.0}/Makefile +5 -2
- {redenv-0.2.0 → redenv-0.4.0}/PKG-INFO +33 -3
- {redenv-0.2.0 → redenv-0.4.0}/README.md +31 -2
- redenv-0.4.0/atomic-write-test.py +147 -0
- {redenv-0.2.0 → redenv-0.4.0}/pyproject.toml +6 -1
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/__init__.py +1 -1
- redenv-0.4.0/src/redenv/expand.py +45 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/secrets.py +26 -9
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/sync/utils.py +68 -26
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/utils.py +73 -35
- redenv-0.4.0/tests/test_client.py +144 -0
- redenv-0.4.0/tests/test_expand.py +59 -0
- redenv-0.4.0/tests/test_secrets.py +96 -0
- redenv-0.4.0/tests/test_sync.py +135 -0
- {redenv-0.2.0 → redenv-0.4.0}/LICENSE +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/client.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/crypto.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/errors.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/py.typed +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/sync/__init__.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/sync/client.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/src/redenv/types.py +0 -0
- {redenv-0.2.0 → redenv-0.4.0}/tests/test_crypto.py +0 -0
|
@@ -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
|
|
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/
|
|
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.
|
|
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.
|
|
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
|
-
###
|
|
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.
|
|
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
|
-
###
|
|
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",
|
|
@@ -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
|
-
|
|
50
|
-
helpful error if the key is missing.
|
|
61
|
+
Returns None if the key is not found instead of raising KeyError.
|
|
51
62
|
"""
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
#
|
|
144
|
-
metadata
|
|
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
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if history_limit > 0
|
|
184
|
-
history
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
File without changes
|