Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/envault/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import time
from typing import Any
from urllib.error import URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


Expand Down Expand Up @@ -164,7 +165,7 @@ def _introspect(self, token: str) -> AuthResult:
import base64

url = f"{self._provider_url}/introspect"
body = f"token={token}".encode()
body = urlencode({"token": token}).encode()
headers: dict[str, str] = {
"Content-Type": "application/x-www-form-urlencoded",
}
Expand Down
22 changes: 19 additions & 3 deletions src/envault/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,32 @@ def _get_backup_dir(project_dir: Path | str = ".") -> Path:


def _load_manifest(backup_dir: Path) -> list[BackupEntry]:
"""Load the backup manifest from disk."""
"""Load the backup manifest from disk.

Skips individual corrupt entries rather than discarding the entire
manifest, preserving valid backups when one entry is malformed.
"""
manifest_path = backup_dir / BACKUP_MANIFEST
if not manifest_path.exists():
return []
try:
data = json.loads(manifest_path.read_text(encoding="utf-8"))
return [BackupEntry.from_dict(entry) for entry in data]
except (json.JSONDecodeError, KeyError):
except json.JSONDecodeError:
return []

if not isinstance(data, list):
return []

entries: list[BackupEntry] = []
for entry in data:
if not isinstance(entry, dict):
continue
try:
entries.append(BackupEntry.from_dict(entry))
except (KeyError, TypeError):
continue
return entries


def _save_manifest(backup_dir: Path, entries: list[BackupEntry]) -> None:
"""Save the backup manifest to disk."""
Expand Down
34 changes: 3 additions & 31 deletions src/envault/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import base64
import json
import os
import secrets as _secrets
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
Expand Down Expand Up @@ -69,36 +68,6 @@ def _send_error(self, status: int, message: str) -> None:
"""Send a JSON error payload."""
self._send_json({"error": message}, status=status)

def _check_auth(self) -> bool:
"""Validate the Bearer token if API auth is enabled.

Returns True if the request is authorized (or auth is disabled).
Returns False if auth is required but missing/invalid (and sends 401).
"""
if not self.api_key:
# Auth not configured — allow all requests
return True

auth_header = self.headers.get("Authorization", "")
if not auth_header:
self._send_error(401, "Unauthorized: valid Bearer token required")
return False

token = auth_header[len("Bearer ") :] if auth_header.startswith("Bearer ") else auth_header
if not token or not token.strip():
self._send_error(401, "Unauthorized: valid Bearer token required")
return False

if (
_secrets.compare_digest(token.strip(), self.api_key)
if self.api_key
else _secrets.compare_digest(token.strip(), "")
):
return True

self._send_error(401, "Unauthorized: valid Bearer token required")
return False

# ── Routing ──────────────────────────────────────────────────────────────

def _check_bearer_token(self) -> bool:
Expand Down Expand Up @@ -330,6 +299,9 @@ def do_GET(self) -> None: # noqa: N802 -- stdlib naming convention
if path == "/health":
# /health is always accessible (useful for load balancers)
self._handle_health()
elif path == "/auth/info":
# /auth/info is always accessible so clients can discover auth methods
self._handle_auth_info()
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
elif path == "/secrets":
if not self._check_auth():
return
Expand Down
62 changes: 54 additions & 8 deletions src/envault/stores/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ class SecretStoreError(Exception):
pass


def _is_missing_path_error(exc: BaseException) -> bool:
"""Heuristically decide whether an exception means 'key does not exist'.

Matches hvac's InvalidPath (checked by class name so hvac stays an optional
dependency) plus common HTTP-404 style messages. Anything else — auth
failures, connection errors, server errors — is NOT a missing key and must
surface to the caller instead of being silently swallowed.
"""
if type(exc).__name__ == "InvalidPath":
return True
message = str(exc).lower()
return "404" in message or "not found" in message or "path" in message and "missing" in message

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match an actual 404 response instead of arbitrary text

When a real Vault failure contains 404 for another reason—such as a connection error to vault.internal:4040 or a permission error whose requested path contains 404—this predicate classifies it as a missing secret. The new get, delete, and list_keys handlers then silently return None, False, or [] instead of surfacing the operational failure they are intended to preserve; inspect the exception type or structured HTTP status rather than searching the entire message for this substring.

Useful? React with 👍 / 👎.



class SecretStore(ABC):
"""Abstract base class for secret store integrations."""

Expand Down Expand Up @@ -210,8 +224,10 @@ def get(self, key: str) -> str | None:
)
data = response.get("data", {}).get("data", {})
return data.get("value")
except Exception:
return None
except Exception as exc:
if _is_missing_path_error(exc):
return None
raise SecretStoreError(f"Vault read failed for {key!r}: {exc}") from exc

def set(self, key: str, value: str) -> bool:
client = self._get_client()
Expand All @@ -230,8 +246,10 @@ def delete(self, key: str) -> bool:
mount_point=self.mount_point,
)
return True
except Exception:
return False
except Exception as exc:
if _is_missing_path_error(exc):
return False
raise SecretStoreError(f"Vault delete failed for {key!r}: {exc}") from exc

def list_keys(self, prefix: str = "") -> list[str]:
client = self._get_client()
Expand All @@ -242,8 +260,10 @@ def list_keys(self, prefix: str = "") -> list[str]:
mount_point=self.mount_point,
)
return response.get("data", {}).get("keys", [])
except Exception:
return []
except Exception as exc:
if _is_missing_path_error(exc):
return []
raise SecretStoreError(f"Vault list failed at {list_path!r}: {exc}") from exc


class DopplerStore(SecretStore):
Expand Down Expand Up @@ -279,6 +299,26 @@ def get(self, key: str) -> str | None:
return secrets[key].get("raw", "").strip() or secrets[key].get("computed", "").strip()
return None

def get_many(self, keys: list[str]) -> dict[str, str]:
"""Batch fetch: Doppler returns the whole config's secrets per request,
so one request serves all keys instead of one request per key."""
import requests

url = f"{self._base_url}/configs/config/secrets"
params = {"project": self.project, "config": self.config}
resp = requests.get(url, headers=self._headers(), params=params, timeout=10)
if resp.status_code != 200:
return {}
data = resp.json()
secrets = data.get("secrets", {})
result: dict[str, str] = {}
for key in keys:
if key in secrets:
value = secrets[key].get("raw", "").strip() or secrets[key].get("computed", "").strip()
if value:
result[key] = value
return result

def set(self, key: str, value: str) -> bool:
import requests

Expand Down Expand Up @@ -346,7 +386,10 @@ def _api_post(self, path: str, data: dict) -> bool:
return resp.status_code in (200, 201)

def get(self, key: str) -> str | None:
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{key}%22")
from urllib.parse import quote

encoded_key = quote(key, safe="")
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{encoded_key}%22")
Comment thread
Coding-Dev-Tools marked this conversation as resolved.
if not items:
return None
item_list = items if isinstance(items, list) else items.get("items", [])
Expand All @@ -370,9 +413,12 @@ def set(self, key: str, value: str) -> bool:
return self._api_post(f"/v1/vaults/{self.vault_id}/items", payload)

def delete(self, key: str) -> bool:
from urllib.parse import quote

import requests

items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{key}%22")
encoded_key = quote(key, safe="")
items = self._api_get(f"/v1/vaults/{self.vault_id}/items?filter=title%20eq%20%22{encoded_key}%22")
if not items:
return False
item_list = items if isinstance(items, list) else items.get("items", [])
Expand Down
37 changes: 37 additions & 0 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

import json

from envault.auth import OAuth2Auth


def test_oauth2_introspection_url_encodes_reserved_token_characters(monkeypatch):
captured: dict[str, object] = {}

class _Response:
status = 200

def __enter__(self):
return self

def __exit__(self, exc_type, exc_value, traceback):
return False

def read(self):
return json.dumps({"active": True, "sub": "synthetic-user"}).encode()

def fake_urlopen(request, timeout):
captured["request"] = request
captured["timeout"] = timeout
return _Response()

monkeypatch.setattr("envault.auth.urlopen", fake_urlopen)

result = OAuth2Auth(provider_url="https://identity.example", strategy="introspect").check(
{"Authorization": "Bearer token+with&reserved=value"}
)

assert result.success
request = captured["request"]
assert request.data == b"token=token%2Bwith%26reserved%3Dvalue"
assert captured["timeout"] == 10
Loading
Loading