116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
"""Tool-layer secrets — user-configured tokens & API keys (#103).
|
|
|
|
The connected-source (Gitea / GitHub) and keyed web-search (Tavily, Brave,
|
|
SerpAPI, Exa) tools read their credentials through this module instead of
|
|
``os.environ`` directly. The value comes from the store the user edits in the
|
|
configuration page (``data/api_keys.json`` — the same file the AI provider
|
|
keys use) first, then falls back to the environment (Infisical-injected in
|
|
production). Nothing is ever hard-coded and no tool result carries a secret
|
|
(the registry redacts payloads).
|
|
|
|
Allowed names are whitelisted: only the variables below can be stored or
|
|
deleted from the configuration page.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger("obsigate.tools.secrets")
|
|
|
|
# Whitelisted configuration names (config page « Sources connectées & recherche »).
|
|
TOOL_KEY_NAMES: tuple[str, ...] = (
|
|
"OBSIGATE_TAVILY_API_KEY",
|
|
"OBSIGATE_BRAVE_API_KEY",
|
|
"OBSIGATE_SERPAPI_API_KEY",
|
|
"OBSIGATE_EXA_API_KEY",
|
|
"OBSIGATE_GITEA_URL",
|
|
"OBSIGATE_GITEA_TOKEN",
|
|
"OBSIGATE_GITHUB_TOKEN",
|
|
)
|
|
|
|
_SECRET_MARKERS = ("API_KEY", "TOKEN")
|
|
|
|
# ROADMAP #85 T10a — verrou autour des read-modify-write du store de clés.
|
|
_lock = threading.RLock()
|
|
|
|
|
|
def _keys_file() -> Path:
|
|
base = os.environ.get("OBSIGATE_DATA_DIR", "data")
|
|
return Path(base) / "api_keys.json"
|
|
|
|
|
|
def _read_keys() -> dict:
|
|
path = _keys_file()
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as e:
|
|
logger.warning("tool key store unreadable (%s): %s", path, e)
|
|
return {}
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
def _write_keys(data: dict) -> None:
|
|
path = _keys_file()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def is_secret_name(name: str) -> bool:
|
|
"""True for API keys / tokens (masked in API responses); URLs are clear."""
|
|
return any(marker in name for marker in _SECRET_MARKERS)
|
|
|
|
|
|
def mask_value(name: str, value: str) -> str:
|
|
"""Mask a secret for display; non-secret values (URLs) are returned as-is."""
|
|
if not value:
|
|
return ""
|
|
if not is_secret_name(name):
|
|
return value
|
|
return value[:4] + "..." + value[-4:] if len(value) > 8 else "***"
|
|
|
|
|
|
def get_tool_key(name: str) -> str:
|
|
"""Stored (configuration page) value first, then environment fallback."""
|
|
if name not in TOOL_KEY_NAMES:
|
|
return os.environ.get(name, "").strip()
|
|
stored = _read_keys().get(name)
|
|
if isinstance(stored, str) and stored.strip():
|
|
return stored.strip()
|
|
return os.environ.get(name, "").strip()
|
|
|
|
|
|
def set_tool_key(name: str, value: str) -> None:
|
|
"""Persist one whitelisted key into the store (admin configuration page)."""
|
|
if name not in TOOL_KEY_NAMES:
|
|
raise ValueError(f"Clé non prise en charge: {name}")
|
|
value = (value or "").strip()
|
|
with _lock:
|
|
keys = _read_keys()
|
|
if value:
|
|
keys[name] = value
|
|
else:
|
|
keys.pop(name, None)
|
|
_write_keys(keys)
|
|
|
|
|
|
def delete_tool_key(name: str) -> bool:
|
|
"""Remove one key from the store; return True when it existed."""
|
|
if name not in TOOL_KEY_NAMES:
|
|
raise ValueError(f"Clé non prise en charge: {name}")
|
|
with _lock:
|
|
keys = _read_keys()
|
|
if name in keys:
|
|
del keys[name]
|
|
_write_keys(keys)
|
|
return True
|
|
return False
|