feat(config): cles des sources connectees et recherche a cle editables depuis la page Configurations (#103)
This commit is contained in:
@@ -3679,6 +3679,68 @@ async def api_delete_ai_key(provider_env: str, current_user=Depends(require_admi
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool & connected-source keys (#103) — same store as the AI provider keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from backend.tools.secrets import (
|
||||
TOOL_KEY_NAMES as _TOOL_KEY_NAMES,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
delete_tool_key as _delete_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
get_tool_key as _get_tool_key,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
mask_value as _mask_tool_value,
|
||||
)
|
||||
from backend.tools.secrets import (
|
||||
set_tool_key as _set_tool_key,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/config/tool-keys", response_model=AIKeysResponse)
|
||||
async def api_get_tool_keys(current_user=Depends(require_admin)):
|
||||
"""Return tool/connected-source configuration (tokens masked, URLs clear)."""
|
||||
masked = {}
|
||||
for name in _TOOL_KEY_NAMES:
|
||||
masked[name] = _mask_tool_value(name, _get_tool_key(name))
|
||||
return masked
|
||||
|
||||
|
||||
@app.post("/api/config/tool-keys", response_model=StatusResponse)
|
||||
async def api_set_tool_keys(body: dict = Body(...), current_user=Depends(require_admin)):
|
||||
"""Save tool/connected-source keys.
|
||||
|
||||
Only whitelisted names (``backend.tools.secrets.TOOL_KEY_NAMES``) are
|
||||
accepted: Tavily/Brave/SerpAPI/Exa API keys, Gitea URL + token, GitHub
|
||||
token. Empty values delete the stored entry.
|
||||
"""
|
||||
updated = []
|
||||
for name, value in body.items():
|
||||
if name not in _TOOL_KEY_NAMES:
|
||||
raise HTTPException(status_code=400, detail=f"Clé inconnue: {name}")
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail=f"Type invalide pour {name}")
|
||||
_set_tool_key(name, value or "")
|
||||
updated.append(name)
|
||||
logger.info(f"Tool keys updated: {updated}")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.delete("/api/config/tool-keys/{name}", response_model=AIKeyDeleteResponse)
|
||||
async def api_delete_tool_key(name: str, current_user=Depends(require_admin)):
|
||||
"""Delete a stored tool key (the environment fallback still applies)."""
|
||||
key_name = name.upper()
|
||||
try:
|
||||
existed = _delete_tool_key(key_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
logger.info(f"Tool key deleted: {key_name} (existed={existed})")
|
||||
return {"status": "deleted", "key": key_name}
|
||||
|
||||
|
||||
@app.post("/api/config/ai-keys/test", response_model=AITestResponse)
|
||||
async def api_test_ai_keys(current_user=Depends(require_admin)):
|
||||
"""Test which AI providers are configured.
|
||||
|
||||
@@ -11,7 +11,9 @@ hard-coded):
|
||||
* ``OBSIGATE_GITEA_URL`` — base URL of the self-hosted instance (e.g.
|
||||
``https://git.example.net``); the ``gitea`` provider is only available when
|
||||
this variable is set. Admin-controlled, so the SSRF guard does not apply
|
||||
(unlike user-supplied URLs).
|
||||
(unlike user-supplied URLs). Both the URL and the tokens can also be set
|
||||
from the configuration page (stored in ``data/api_keys.json``, #103) —
|
||||
the stored value takes precedence over the environment.
|
||||
* ``OBSIGATE_GITEA_TOKEN`` — optional personal access token (private repos).
|
||||
* ``OBSIGATE_GITHUB_TOKEN`` — optional token (raises the API rate limits and
|
||||
unlocks private repositories).
|
||||
@@ -26,7 +28,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -34,6 +35,7 @@ import httpx
|
||||
from backend.tools.context import ToolError, ToolRisk
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import GitGetFileInput, GitProviderInput, GitSearchIssuesInput
|
||||
from backend.tools.secrets import get_tool_key
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.connected")
|
||||
|
||||
@@ -46,16 +48,16 @@ GITHUB_API = "https://api.github.com"
|
||||
def _provider_base(provider: str) -> tuple[str, str]:
|
||||
"""Return (base_url, auth_header_value) for the requested provider."""
|
||||
if provider == "gitea":
|
||||
base = os.environ.get("OBSIGATE_GITEA_URL", "").strip().rstrip("/")
|
||||
base = get_tool_key("OBSIGATE_GITEA_URL").rstrip("/")
|
||||
if not base:
|
||||
raise ToolError(
|
||||
"Source Gitea non configurée (OBSIGATE_GITEA_URL absente).",
|
||||
code="provider_not_configured",
|
||||
)
|
||||
token = os.environ.get("OBSIGATE_GITEA_TOKEN", "").strip()
|
||||
token = get_tool_key("OBSIGATE_GITEA_TOKEN")
|
||||
return base, f"token {token}" if token else ""
|
||||
if provider == "github":
|
||||
token = os.environ.get("OBSIGATE_GITHUB_TOKEN", "").strip()
|
||||
token = get_tool_key("OBSIGATE_GITHUB_TOKEN")
|
||||
return GITHUB_API, f"Bearer {token}" if token else ""
|
||||
raise ToolError(
|
||||
f"Fournisseur inconnu : {provider} ('gitea' ou 'github')",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
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")
|
||||
|
||||
|
||||
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()
|
||||
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}")
|
||||
keys = _read_keys()
|
||||
if name in keys:
|
||||
del keys[name]
|
||||
_write_keys(keys)
|
||||
return True
|
||||
return False
|
||||
@@ -57,6 +57,7 @@ from backend.tools import webcache
|
||||
from backend.tools.context import ToolError, ToolRisk, ToolScope
|
||||
from backend.tools.registry import tool
|
||||
from backend.tools.schemas import FetchUrlInput, WebSearchInput
|
||||
from backend.tools.secrets import get_tool_key
|
||||
|
||||
logger = logging.getLogger("obsigate.tools.web")
|
||||
|
||||
@@ -202,8 +203,8 @@ def _with_retry(call: Callable[[], Any]) -> Any:
|
||||
|
||||
|
||||
def _env_key(name: str) -> str:
|
||||
"""Read an API key from the environment (Infisical-injected in prod)."""
|
||||
return os.environ.get(name, "").strip()
|
||||
"""Read an API key: configuration-page store first, then environment."""
|
||||
return get_tool_key(name)
|
||||
|
||||
|
||||
def _search_tavily(query: str, params: WebSearchInput) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
|
||||
Reference in New Issue
Block a user