vaultdotenv 0.1.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.
- vaultdotenv-0.1.0/PKG-INFO +87 -0
- vaultdotenv-0.1.0/README.md +65 -0
- vaultdotenv-0.1.0/pyproject.toml +36 -0
- vaultdotenv-0.1.0/setup.cfg +4 -0
- vaultdotenv-0.1.0/vaultdotenv/__init__.py +16 -0
- vaultdotenv-0.1.0/vaultdotenv/cli.py +128 -0
- vaultdotenv-0.1.0/vaultdotenv/client.py +360 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/__init__.py +0 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/auth.py +72 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/devices.py +142 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/init.py +103 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/keys.py +60 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/secrets.py +216 -0
- vaultdotenv-0.1.0/vaultdotenv/commands/versions.py +91 -0
- vaultdotenv-0.1.0/vaultdotenv/config.py +83 -0
- vaultdotenv-0.1.0/vaultdotenv/crypto.py +93 -0
- vaultdotenv-0.1.0/vaultdotenv/device.py +82 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/PKG-INFO +87 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/SOURCES.txt +21 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/dependency_links.txt +1 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/entry_points.txt +3 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/requires.txt +5 -0
- vaultdotenv-0.1.0/vaultdotenv.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaultdotenv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Drop-in python-dotenv replacement. One master key locally, everything else encrypted remotely.
|
|
5
|
+
Author: Matt Redman
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://vaultdotenv.io
|
|
8
|
+
Project-URL: Documentation, https://vaultdotenv.io
|
|
9
|
+
Project-URL: Repository, https://github.com/vaultdotenv/vaultdotenv
|
|
10
|
+
Keywords: dotenv,secrets,environment,vault,encryption
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: cryptography>=41.0
|
|
19
|
+
Requires-Dist: httpx>=0.24
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# vaultdotenv
|
|
24
|
+
|
|
25
|
+
Drop-in `python-dotenv` replacement. One master key locally, everything else encrypted remotely.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install vaultdotenv
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
Replace `python-dotenv` with one line:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# Before
|
|
39
|
+
from dotenv import load_dotenv
|
|
40
|
+
load_dotenv()
|
|
41
|
+
|
|
42
|
+
# After
|
|
43
|
+
from vaultdotenv import load_vault
|
|
44
|
+
load_vault()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If a `VAULT_KEY` is found in your `.env` file, secrets are pulled from the vault server and injected into `os.environ`. If no `VAULT_KEY` is present, it behaves exactly like `python-dotenv`.
|
|
48
|
+
|
|
49
|
+
## CLI
|
|
50
|
+
|
|
51
|
+
The package includes a full CLI (`vde` or `vaultdotenv`):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
vde init # Initialize a new vault project
|
|
55
|
+
vde push # Push .env secrets to vault
|
|
56
|
+
vde pull # Pull secrets from vault
|
|
57
|
+
vde set KEY "value" # Set a single secret
|
|
58
|
+
vde get KEY # Get a secret (masked)
|
|
59
|
+
vde versions # List secret versions
|
|
60
|
+
vde rollback --version 3 # Rollback to a version
|
|
61
|
+
vde login # Link CLI to dashboard
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## API
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from vaultdotenv import load_vault, load_vault_sync, pull_secrets, push_secrets, watch
|
|
68
|
+
|
|
69
|
+
# Async load (pulls from server, falls back to cache)
|
|
70
|
+
secrets = load_vault()
|
|
71
|
+
|
|
72
|
+
# Sync load (cache only, no network)
|
|
73
|
+
secrets = load_vault_sync()
|
|
74
|
+
|
|
75
|
+
# Direct pull/push
|
|
76
|
+
result = pull_secrets(vault_key, environment="production")
|
|
77
|
+
push_secrets(vault_key, {"DB_URL": "postgres://..."}, environment="production")
|
|
78
|
+
|
|
79
|
+
# Hot reload (polls every 30s)
|
|
80
|
+
watch(on_change=lambda changed, all: print("Updated:", list(changed.keys())))
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Links
|
|
84
|
+
|
|
85
|
+
- [Documentation](https://vaultdotenv.io)
|
|
86
|
+
- [Dashboard](https://app.vaultdotenv.io)
|
|
87
|
+
- [GitHub](https://github.com/vaultdotenv/vaultdotenv)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# vaultdotenv
|
|
2
|
+
|
|
3
|
+
Drop-in `python-dotenv` replacement. One master key locally, everything else encrypted remotely.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install vaultdotenv
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
Replace `python-dotenv` with one line:
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
# Before
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
load_dotenv()
|
|
19
|
+
|
|
20
|
+
# After
|
|
21
|
+
from vaultdotenv import load_vault
|
|
22
|
+
load_vault()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If a `VAULT_KEY` is found in your `.env` file, secrets are pulled from the vault server and injected into `os.environ`. If no `VAULT_KEY` is present, it behaves exactly like `python-dotenv`.
|
|
26
|
+
|
|
27
|
+
## CLI
|
|
28
|
+
|
|
29
|
+
The package includes a full CLI (`vde` or `vaultdotenv`):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
vde init # Initialize a new vault project
|
|
33
|
+
vde push # Push .env secrets to vault
|
|
34
|
+
vde pull # Pull secrets from vault
|
|
35
|
+
vde set KEY "value" # Set a single secret
|
|
36
|
+
vde get KEY # Get a secret (masked)
|
|
37
|
+
vde versions # List secret versions
|
|
38
|
+
vde rollback --version 3 # Rollback to a version
|
|
39
|
+
vde login # Link CLI to dashboard
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from vaultdotenv import load_vault, load_vault_sync, pull_secrets, push_secrets, watch
|
|
46
|
+
|
|
47
|
+
# Async load (pulls from server, falls back to cache)
|
|
48
|
+
secrets = load_vault()
|
|
49
|
+
|
|
50
|
+
# Sync load (cache only, no network)
|
|
51
|
+
secrets = load_vault_sync()
|
|
52
|
+
|
|
53
|
+
# Direct pull/push
|
|
54
|
+
result = pull_secrets(vault_key, environment="production")
|
|
55
|
+
push_secrets(vault_key, {"DB_URL": "postgres://..."}, environment="production")
|
|
56
|
+
|
|
57
|
+
# Hot reload (polls every 30s)
|
|
58
|
+
watch(on_change=lambda changed, all: print("Updated:", list(changed.keys())))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Links
|
|
62
|
+
|
|
63
|
+
- [Documentation](https://vaultdotenv.io)
|
|
64
|
+
- [Dashboard](https://app.vaultdotenv.io)
|
|
65
|
+
- [GitHub](https://github.com/vaultdotenv/vaultdotenv)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaultdotenv"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Drop-in python-dotenv replacement. One master key locally, everything else encrypted remotely."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Matt Redman" }]
|
|
13
|
+
keywords = ["dotenv", "secrets", "environment", "vault", "encryption"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Topic :: Security",
|
|
18
|
+
"Topic :: Software Development",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"cryptography>=41.0",
|
|
23
|
+
"httpx>=0.24",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://vaultdotenv.io"
|
|
31
|
+
Documentation = "https://vaultdotenv.io"
|
|
32
|
+
Repository = "https://github.com/vaultdotenv/vaultdotenv"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
vaultdotenv = "vaultdotenv.cli:main"
|
|
36
|
+
vde = "vaultdotenv.cli:main"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""vaultdotenv — Drop-in python-dotenv replacement with remote encrypted secrets."""
|
|
2
|
+
|
|
3
|
+
from vaultdotenv.client import load_vault, load_vault_sync, pull_secrets, push_secrets, watch, unwatch
|
|
4
|
+
from vaultdotenv.device import register_device, load_device_secret, save_device_secret
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"load_vault",
|
|
8
|
+
"load_vault_sync",
|
|
9
|
+
"pull_secrets",
|
|
10
|
+
"push_secrets",
|
|
11
|
+
"watch",
|
|
12
|
+
"unwatch",
|
|
13
|
+
"register_device",
|
|
14
|
+
"load_device_secret",
|
|
15
|
+
"save_device_secret",
|
|
16
|
+
]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""vaultdotenv CLI — remote secrets manager, drop-in dotenv replacement."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
args = sys.argv[1:]
|
|
10
|
+
command = args[0] if args else None
|
|
11
|
+
|
|
12
|
+
if command == "login":
|
|
13
|
+
from vaultdotenv.commands.auth import login
|
|
14
|
+
login(args)
|
|
15
|
+
elif command == "logout":
|
|
16
|
+
from vaultdotenv.commands.auth import logout
|
|
17
|
+
logout(args)
|
|
18
|
+
elif command == "whoami":
|
|
19
|
+
from vaultdotenv.commands.auth import whoami
|
|
20
|
+
whoami(args)
|
|
21
|
+
elif command == "init":
|
|
22
|
+
from vaultdotenv.commands.init import init
|
|
23
|
+
init(args)
|
|
24
|
+
elif command == "push":
|
|
25
|
+
from vaultdotenv.commands.secrets import push
|
|
26
|
+
push(args)
|
|
27
|
+
elif command == "pull":
|
|
28
|
+
from vaultdotenv.commands.secrets import pull
|
|
29
|
+
pull(args)
|
|
30
|
+
elif command == "set":
|
|
31
|
+
from vaultdotenv.commands.secrets import set_secret
|
|
32
|
+
set_secret(args)
|
|
33
|
+
elif command == "delete":
|
|
34
|
+
from vaultdotenv.commands.secrets import delete
|
|
35
|
+
delete(args)
|
|
36
|
+
elif command == "get":
|
|
37
|
+
from vaultdotenv.commands.secrets import get
|
|
38
|
+
get(args)
|
|
39
|
+
elif command == "versions":
|
|
40
|
+
from vaultdotenv.commands.versions import versions
|
|
41
|
+
versions(args)
|
|
42
|
+
elif command == "rollback":
|
|
43
|
+
from vaultdotenv.commands.versions import rollback
|
|
44
|
+
rollback(args)
|
|
45
|
+
elif command == "register-device":
|
|
46
|
+
from vaultdotenv.commands.devices import register
|
|
47
|
+
register(args)
|
|
48
|
+
elif command == "approve-device":
|
|
49
|
+
from vaultdotenv.commands.devices import approve
|
|
50
|
+
approve(args)
|
|
51
|
+
elif command == "list-devices":
|
|
52
|
+
from vaultdotenv.commands.devices import list_devices
|
|
53
|
+
list_devices(args)
|
|
54
|
+
elif command == "revoke-device":
|
|
55
|
+
from vaultdotenv.commands.devices import revoke
|
|
56
|
+
revoke(args)
|
|
57
|
+
elif command == "key":
|
|
58
|
+
sub = args[1] if len(args) > 1 else None
|
|
59
|
+
if sub == "save":
|
|
60
|
+
from vaultdotenv.commands.keys import save
|
|
61
|
+
save(args[1:])
|
|
62
|
+
elif sub == "list":
|
|
63
|
+
from vaultdotenv.commands.keys import list_keys
|
|
64
|
+
list_keys(args[1:])
|
|
65
|
+
elif sub == "remove":
|
|
66
|
+
from vaultdotenv.commands.keys import remove
|
|
67
|
+
remove(args[1:])
|
|
68
|
+
else:
|
|
69
|
+
print_key_help()
|
|
70
|
+
else:
|
|
71
|
+
print_help()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_help():
|
|
75
|
+
print("""vaultdotenv — Remote secrets manager, drop-in dotenv replacement
|
|
76
|
+
|
|
77
|
+
Auth:
|
|
78
|
+
vde login Log in via browser (links CLI to dashboard)
|
|
79
|
+
vde logout Remove saved auth token
|
|
80
|
+
vde whoami Show current logged-in user
|
|
81
|
+
|
|
82
|
+
Usage:
|
|
83
|
+
vde init [--name project] Initialize a new vault project
|
|
84
|
+
vde push [--env production] Push .env secrets to vault
|
|
85
|
+
vde pull [--env staging] Pull secrets from vault
|
|
86
|
+
vde set KEY "value" [--env prod] Set a single secret
|
|
87
|
+
vde delete KEY [--env prod] Remove a secret (with confirmation)
|
|
88
|
+
vde get KEY [--env prod] Get a single secret (masked)
|
|
89
|
+
vde get KEY --raw --token T Reveal cleartext (requires token)
|
|
90
|
+
vde versions [--env prod] List secret versions
|
|
91
|
+
vde rollback --version 5 Rollback to a specific version
|
|
92
|
+
|
|
93
|
+
Device management:
|
|
94
|
+
vde register-device [--name X] Register this machine with the vault
|
|
95
|
+
vde approve-device --id X Approve a pending device
|
|
96
|
+
vde list-devices List all registered devices
|
|
97
|
+
vde revoke-device --id X Revoke a device's access
|
|
98
|
+
|
|
99
|
+
Key management:
|
|
100
|
+
vde key save --project X --key vk_... Save a vault key locally
|
|
101
|
+
vde key list List saved project keys
|
|
102
|
+
vde key remove --project X Remove a saved key
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
--project <name> Use saved key for project (from key save)
|
|
106
|
+
--env <name> Environment (default: development)
|
|
107
|
+
--url <url> Vault server URL (default: api.vaultdotenv.io)
|
|
108
|
+
--file <path> Source .env file for push (default: .env)
|
|
109
|
+
--output <path> Output file for pull (default: stdout)
|
|
110
|
+
--name <name> Device or project name
|
|
111
|
+
--id <id> Device ID (for approve/revoke)
|
|
112
|
+
""")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_key_help():
|
|
116
|
+
print("""Key management:
|
|
117
|
+
vde key save --project X --key vk_... Save a vault key locally
|
|
118
|
+
vde key list List saved project keys
|
|
119
|
+
vde key remove --project X Remove a saved key
|
|
120
|
+
""")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
try:
|
|
125
|
+
main()
|
|
126
|
+
except Exception as e:
|
|
127
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Core vaultdotenv client — drop-in replacement for python-dotenv."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from vaultdotenv.crypto import decrypt, encrypt, hash_device_secret, parse_vault_key, sign
|
|
12
|
+
from vaultdotenv.device import load_device_secret
|
|
13
|
+
|
|
14
|
+
DEFAULT_VAULT_URL = "https://api.vaultdotenv.io"
|
|
15
|
+
CACHE_FILE = ".vault-cache"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_dotenv(content: str) -> dict[str, str]:
|
|
19
|
+
"""Parse a .env file into key-value pairs."""
|
|
20
|
+
result = {}
|
|
21
|
+
for line in content.splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if not line or line.startswith("#"):
|
|
24
|
+
continue
|
|
25
|
+
if "=" not in line:
|
|
26
|
+
continue
|
|
27
|
+
key, _, val = line.partition("=")
|
|
28
|
+
key = key.strip()
|
|
29
|
+
val = val.strip()
|
|
30
|
+
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
31
|
+
val = val[1:-1]
|
|
32
|
+
result[key] = val
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_cache(vault_key: str, cache_dir: Path, device_secret: str | None) -> dict | None:
|
|
37
|
+
cache_path = cache_dir / CACHE_FILE
|
|
38
|
+
if not cache_path.exists():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
encrypted = cache_path.read_text()
|
|
42
|
+
decrypted = decrypt(encrypted, vault_key, device_secret)
|
|
43
|
+
return json.loads(decrypted)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save_cache(vault_key: str, secrets: dict, cache_dir: Path, device_secret: str | None) -> None:
|
|
49
|
+
cache_path = cache_dir / CACHE_FILE
|
|
50
|
+
encrypted = encrypt(json.dumps(secrets), vault_key, device_secret)
|
|
51
|
+
cache_path.write_text(encrypted)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pull_secrets(
|
|
55
|
+
vault_key: str,
|
|
56
|
+
environment: str = "development",
|
|
57
|
+
vault_url: str = DEFAULT_VAULT_URL,
|
|
58
|
+
device_secret: str | None = None,
|
|
59
|
+
) -> dict:
|
|
60
|
+
"""Pull secrets from the vault server. Returns {secrets: dict, version: int}."""
|
|
61
|
+
parsed = parse_vault_key(vault_key)
|
|
62
|
+
if not parsed:
|
|
63
|
+
raise ValueError("Invalid VAULT_KEY format. Expected: vk_<projectId>_<secret>")
|
|
64
|
+
|
|
65
|
+
if device_secret is None:
|
|
66
|
+
device_secret = load_device_secret(parsed["project_id"])
|
|
67
|
+
|
|
68
|
+
body_dict = {
|
|
69
|
+
"project_id": parsed["project_id"],
|
|
70
|
+
"environment": environment,
|
|
71
|
+
}
|
|
72
|
+
if device_secret:
|
|
73
|
+
body_dict["device_hash"] = hash_device_secret(device_secret)
|
|
74
|
+
|
|
75
|
+
body = json.dumps(body_dict)
|
|
76
|
+
signature = sign(vault_key, body)
|
|
77
|
+
|
|
78
|
+
resp = httpx.post(
|
|
79
|
+
f"{vault_url}/api/v1/secrets/pull",
|
|
80
|
+
content=body,
|
|
81
|
+
headers={
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
"X-Vault-Signature": signature,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if not resp.is_success:
|
|
88
|
+
if resp.status_code == 403:
|
|
89
|
+
text = resp.text
|
|
90
|
+
if "pending" in text:
|
|
91
|
+
raise RuntimeError("Device not yet approved. Ask the project owner to run: vaultdotenv approve-device")
|
|
92
|
+
raise RuntimeError("Device not registered. Run: vaultdotenv register-device")
|
|
93
|
+
raise RuntimeError(f"Vault pull failed ({resp.status_code}): {resp.text}")
|
|
94
|
+
|
|
95
|
+
data = resp.json()
|
|
96
|
+
decrypted = decrypt(data["secrets"], vault_key, device_secret)
|
|
97
|
+
return {"secrets": json.loads(decrypted), "version": data["version"]}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def push_secrets(
|
|
101
|
+
vault_key: str,
|
|
102
|
+
secrets: dict,
|
|
103
|
+
environment: str = "development",
|
|
104
|
+
vault_url: str = DEFAULT_VAULT_URL,
|
|
105
|
+
device_secret: str | None = None,
|
|
106
|
+
) -> dict:
|
|
107
|
+
"""Push secrets to the vault server. Returns {version: int}."""
|
|
108
|
+
parsed = parse_vault_key(vault_key)
|
|
109
|
+
if not parsed:
|
|
110
|
+
raise ValueError("Invalid VAULT_KEY format. Expected: vk_<projectId>_<secret>")
|
|
111
|
+
|
|
112
|
+
if device_secret is None:
|
|
113
|
+
device_secret = load_device_secret(parsed["project_id"])
|
|
114
|
+
|
|
115
|
+
encrypted_secrets = encrypt(json.dumps(secrets), vault_key, device_secret)
|
|
116
|
+
|
|
117
|
+
body_dict = {
|
|
118
|
+
"project_id": parsed["project_id"],
|
|
119
|
+
"environment": environment,
|
|
120
|
+
"secrets": encrypted_secrets,
|
|
121
|
+
"key_names": list(secrets.keys()),
|
|
122
|
+
}
|
|
123
|
+
if device_secret:
|
|
124
|
+
body_dict["device_hash"] = hash_device_secret(device_secret)
|
|
125
|
+
|
|
126
|
+
body = json.dumps(body_dict)
|
|
127
|
+
signature = sign(vault_key, body)
|
|
128
|
+
|
|
129
|
+
resp = httpx.post(
|
|
130
|
+
f"{vault_url}/api/v1/secrets/push",
|
|
131
|
+
content=body,
|
|
132
|
+
headers={
|
|
133
|
+
"Content-Type": "application/json",
|
|
134
|
+
"X-Vault-Signature": signature,
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if not resp.is_success:
|
|
139
|
+
raise RuntimeError(f"Vault push failed ({resp.status_code}): {resp.text}")
|
|
140
|
+
|
|
141
|
+
return resp.json()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _check_version(vault_key: str, environment: str, vault_url: str) -> dict:
|
|
145
|
+
"""Lightweight version check — no secrets transferred."""
|
|
146
|
+
parsed = parse_vault_key(vault_key)
|
|
147
|
+
body = json.dumps({"project_id": parsed["project_id"], "environment": environment})
|
|
148
|
+
signature = sign(vault_key, body)
|
|
149
|
+
|
|
150
|
+
resp = httpx.post(
|
|
151
|
+
f"{vault_url}/api/v1/secrets/current-version",
|
|
152
|
+
content=body,
|
|
153
|
+
headers={
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
"X-Vault-Signature": signature,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
if not resp.is_success:
|
|
159
|
+
raise RuntimeError(f"Version check failed ({resp.status_code})")
|
|
160
|
+
return resp.json()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def load_vault(
|
|
164
|
+
path: str | Path = ".env",
|
|
165
|
+
environment: str | None = None,
|
|
166
|
+
vault_url: str | None = None,
|
|
167
|
+
override: bool = False,
|
|
168
|
+
cache: bool = True,
|
|
169
|
+
) -> dict:
|
|
170
|
+
"""
|
|
171
|
+
Drop-in replacement for dotenv.load_dotenv().
|
|
172
|
+
|
|
173
|
+
Reads VAULT_KEY from .env, pulls secrets from vault server,
|
|
174
|
+
injects into os.environ.
|
|
175
|
+
"""
|
|
176
|
+
env_path = Path(path).resolve()
|
|
177
|
+
environment = environment or os.environ.get("NODE_ENV") or os.environ.get("ENVIRONMENT") or "development"
|
|
178
|
+
vault_url = vault_url or os.environ.get("VAULT_URL") or DEFAULT_VAULT_URL
|
|
179
|
+
|
|
180
|
+
# Step 1: Read .env for VAULT_KEY
|
|
181
|
+
vault_key = os.environ.get("VAULT_KEY")
|
|
182
|
+
|
|
183
|
+
if not vault_key and env_path.exists():
|
|
184
|
+
local_env = _parse_dotenv(env_path.read_text())
|
|
185
|
+
vault_key = local_env.get("VAULT_KEY")
|
|
186
|
+
|
|
187
|
+
for key, val in local_env.items():
|
|
188
|
+
if key == "VAULT_KEY":
|
|
189
|
+
continue
|
|
190
|
+
if not override and key in os.environ:
|
|
191
|
+
continue
|
|
192
|
+
os.environ[key] = val
|
|
193
|
+
|
|
194
|
+
# No VAULT_KEY — plain dotenv behavior
|
|
195
|
+
if not vault_key:
|
|
196
|
+
if env_path.exists():
|
|
197
|
+
parsed = _parse_dotenv(env_path.read_text())
|
|
198
|
+
for key, val in parsed.items():
|
|
199
|
+
if not override and key in os.environ:
|
|
200
|
+
continue
|
|
201
|
+
os.environ[key] = val
|
|
202
|
+
return parsed
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
# Step 2: Load device secret
|
|
206
|
+
parsed_key = parse_vault_key(vault_key)
|
|
207
|
+
device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
|
|
208
|
+
|
|
209
|
+
# Step 3: Pull from vault
|
|
210
|
+
secrets = None
|
|
211
|
+
version = None
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
result = pull_secrets(vault_key, environment, vault_url)
|
|
215
|
+
secrets = result["secrets"]
|
|
216
|
+
version = result["version"]
|
|
217
|
+
|
|
218
|
+
if cache:
|
|
219
|
+
try:
|
|
220
|
+
_save_cache(vault_key, secrets, env_path.parent, device_secret)
|
|
221
|
+
except Exception:
|
|
222
|
+
pass
|
|
223
|
+
except Exception as err:
|
|
224
|
+
if cache:
|
|
225
|
+
secrets = _load_cache(vault_key, env_path.parent, device_secret)
|
|
226
|
+
if secrets:
|
|
227
|
+
import sys
|
|
228
|
+
print("[vaultdotenv] Remote fetch failed, using cached secrets", file=sys.stderr)
|
|
229
|
+
else:
|
|
230
|
+
raise RuntimeError(f"[vaultdotenv] Failed to fetch secrets and no cache available: {err}") from err
|
|
231
|
+
else:
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
# Step 4: Inject into os.environ
|
|
235
|
+
for key, val in secrets.items():
|
|
236
|
+
if not override and key in os.environ:
|
|
237
|
+
continue
|
|
238
|
+
os.environ[key] = str(val)
|
|
239
|
+
|
|
240
|
+
return secrets
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_vault_sync(path: str | Path = ".env", override: bool = False) -> dict:
|
|
244
|
+
"""Synchronous config — reads from cache only (matches Node.js configSync)."""
|
|
245
|
+
env_path = Path(path).resolve()
|
|
246
|
+
|
|
247
|
+
if not env_path.exists():
|
|
248
|
+
return {}
|
|
249
|
+
|
|
250
|
+
local_env = _parse_dotenv(env_path.read_text())
|
|
251
|
+
vault_key = local_env.get("VAULT_KEY")
|
|
252
|
+
|
|
253
|
+
if not vault_key:
|
|
254
|
+
for key, val in local_env.items():
|
|
255
|
+
if not override and key in os.environ:
|
|
256
|
+
continue
|
|
257
|
+
os.environ[key] = val
|
|
258
|
+
return local_env
|
|
259
|
+
|
|
260
|
+
parsed_key = parse_vault_key(vault_key)
|
|
261
|
+
device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
|
|
262
|
+
|
|
263
|
+
cached = _load_cache(vault_key, env_path.parent, device_secret)
|
|
264
|
+
if cached:
|
|
265
|
+
for key, val in cached.items():
|
|
266
|
+
if not override and key in os.environ:
|
|
267
|
+
continue
|
|
268
|
+
os.environ[key] = str(val)
|
|
269
|
+
return cached
|
|
270
|
+
|
|
271
|
+
import sys
|
|
272
|
+
print("[vaultdotenv] No cache available, falling back to local .env", file=sys.stderr)
|
|
273
|
+
for key, val in local_env.items():
|
|
274
|
+
if not override and key in os.environ:
|
|
275
|
+
continue
|
|
276
|
+
os.environ[key] = val
|
|
277
|
+
return local_env
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ── Watch / Hot Reload ────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
_watcher_stop = threading.Event()
|
|
283
|
+
_watcher_thread: threading.Thread | None = None
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def watch(
|
|
287
|
+
interval: float = 30.0,
|
|
288
|
+
environment: str | None = None,
|
|
289
|
+
vault_url: str | None = None,
|
|
290
|
+
on_change: callable = None,
|
|
291
|
+
on_error: callable = None,
|
|
292
|
+
):
|
|
293
|
+
"""
|
|
294
|
+
Watch for secret changes and hot-reload into os.environ.
|
|
295
|
+
|
|
296
|
+
Usage:
|
|
297
|
+
vault_env.watch(
|
|
298
|
+
interval=30,
|
|
299
|
+
on_change=lambda changed, all_secrets: print("Updated:", changed.keys()),
|
|
300
|
+
)
|
|
301
|
+
"""
|
|
302
|
+
global _watcher_thread
|
|
303
|
+
|
|
304
|
+
vault_key = os.environ.get("VAULT_KEY")
|
|
305
|
+
if not vault_key:
|
|
306
|
+
raise RuntimeError("[vaultdotenv] watch() requires VAULT_KEY — call load_vault() first")
|
|
307
|
+
|
|
308
|
+
environment = environment or os.environ.get("NODE_ENV") or os.environ.get("ENVIRONMENT") or "development"
|
|
309
|
+
vault_url = vault_url or os.environ.get("VAULT_URL") or DEFAULT_VAULT_URL
|
|
310
|
+
|
|
311
|
+
parsed_key = parse_vault_key(vault_key)
|
|
312
|
+
device_secret = load_device_secret(parsed_key["project_id"]) if parsed_key else None
|
|
313
|
+
|
|
314
|
+
_watcher_stop.clear()
|
|
315
|
+
current_version = None
|
|
316
|
+
|
|
317
|
+
def _poll():
|
|
318
|
+
nonlocal current_version
|
|
319
|
+
|
|
320
|
+
while not _watcher_stop.is_set():
|
|
321
|
+
try:
|
|
322
|
+
data = _check_version(vault_key, environment, vault_url)
|
|
323
|
+
version = data.get("version", 0)
|
|
324
|
+
|
|
325
|
+
if current_version is None:
|
|
326
|
+
current_version = version
|
|
327
|
+
elif version != current_version:
|
|
328
|
+
result = pull_secrets(vault_key, environment, vault_url)
|
|
329
|
+
current_version = result["version"]
|
|
330
|
+
|
|
331
|
+
changed = {}
|
|
332
|
+
for key, val in result["secrets"].items():
|
|
333
|
+
str_val = str(val)
|
|
334
|
+
if os.environ.get(key) != str_val:
|
|
335
|
+
changed[key] = str_val
|
|
336
|
+
os.environ[key] = str_val
|
|
337
|
+
|
|
338
|
+
if changed and on_change:
|
|
339
|
+
on_change(changed, result["secrets"])
|
|
340
|
+
|
|
341
|
+
except Exception as err:
|
|
342
|
+
if on_error:
|
|
343
|
+
on_error(err)
|
|
344
|
+
else:
|
|
345
|
+
import sys
|
|
346
|
+
print(f"[vaultdotenv] Watch poll failed: {err}", file=sys.stderr)
|
|
347
|
+
|
|
348
|
+
_watcher_stop.wait(interval)
|
|
349
|
+
|
|
350
|
+
_watcher_thread = threading.Thread(target=_poll, daemon=True, name="vaultdotenv-watcher")
|
|
351
|
+
_watcher_thread.start()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def unwatch():
|
|
355
|
+
"""Stop the active watcher."""
|
|
356
|
+
global _watcher_thread
|
|
357
|
+
_watcher_stop.set()
|
|
358
|
+
if _watcher_thread:
|
|
359
|
+
_watcher_thread.join(timeout=5)
|
|
360
|
+
_watcher_thread = None
|
|
File without changes
|