feat(provider): 添加API密钥加密存储
This commit is contained in:
@@ -6,13 +6,18 @@ from app.contracts import ModelCapability, ProviderConfig, ProviderType
|
||||
from app.config import BACKEND_DIR
|
||||
from app.extensions import PluginRuntime, SkillRuntime
|
||||
from app.providers import MockProvider, ProviderFactory, ProviderRegistry
|
||||
from app.providers.credentials import EnvironmentCredentialResolver
|
||||
from app.providers.credentials import (
|
||||
ChainedCredentialResolver,
|
||||
EncryptedCredentialStore,
|
||||
EnvironmentCredentialResolver,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationContainer:
|
||||
providers: ProviderRegistry
|
||||
provider_factory: ProviderFactory
|
||||
credentials: EncryptedCredentialStore
|
||||
tools: ToolRegistry
|
||||
permissions: PermissionManager
|
||||
skills: SkillRuntime
|
||||
@@ -21,7 +26,10 @@ class ApplicationContainer:
|
||||
|
||||
|
||||
def build_container() -> ApplicationContainer:
|
||||
provider_factory = ProviderFactory(EnvironmentCredentialResolver())
|
||||
credentials = EncryptedCredentialStore()
|
||||
provider_factory = ProviderFactory(
|
||||
ChainedCredentialResolver(credentials, EnvironmentCredentialResolver())
|
||||
)
|
||||
providers = ProviderRegistry()
|
||||
providers.register(
|
||||
ProviderConfig(
|
||||
@@ -61,6 +69,7 @@ def build_container() -> ApplicationContainer:
|
||||
return ApplicationContainer(
|
||||
providers=providers,
|
||||
provider_factory=provider_factory,
|
||||
credentials=credentials,
|
||||
tools=tools,
|
||||
permissions=permissions,
|
||||
skills=skills,
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||
|
||||
|
||||
class Contract(BaseModel):
|
||||
@@ -451,6 +451,15 @@ class ProviderPresetListResponse(Contract):
|
||||
items: list[ProviderPreset] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CredentialWriteRequest(Contract):
|
||||
api_key: SecretStr = Field(min_length=1, max_length=8192)
|
||||
|
||||
|
||||
class CredentialStatus(Contract):
|
||||
credential_id: str
|
||||
configured: bool
|
||||
|
||||
|
||||
class ModelInfo(Contract):
|
||||
model: str
|
||||
display_name: str
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_CREDENTIAL_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
|
||||
|
||||
class CredentialStoreError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class CredentialResolver(Protocol):
|
||||
def resolve(self, credential_id: str | None) -> str | None: ...
|
||||
@@ -24,3 +38,128 @@ class EnvironmentCredentialResolver:
|
||||
return injected
|
||||
alias = self._development_aliases.get(credential_id.lower())
|
||||
return os.getenv(alias) if alias else None
|
||||
|
||||
|
||||
class EncryptedCredentialStore:
|
||||
"""将本地开发凭据作为 Fernet 密文存储,Provider 使用时按 ID 解密。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def _validate_id(credential_id: str) -> None:
|
||||
if not _CREDENTIAL_ID.fullmatch(credential_id):
|
||||
raise CredentialStoreError("Credential ID contains unsupported characters.")
|
||||
|
||||
@staticmethod
|
||||
def _paths() -> tuple[Path, Path]:
|
||||
directory = get_settings().data_dir / "credentials"
|
||||
return directory / "master.key", directory / "credentials.json"
|
||||
|
||||
@staticmethod
|
||||
def _restrict(path: Path, mode: int) -> None:
|
||||
try:
|
||||
path.chmod(mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _fernet(self) -> Fernet:
|
||||
key_path, _ = self._paths()
|
||||
environment_key = os.getenv("APP_CREDENTIAL_MASTER_KEY")
|
||||
if environment_key:
|
||||
try:
|
||||
return Fernet(environment_key.encode("ascii"))
|
||||
except (ValueError, UnicodeEncodeError) as exc:
|
||||
raise CredentialStoreError("APP_CREDENTIAL_MASTER_KEY is invalid.") from exc
|
||||
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(key_path.parent, 0o700)
|
||||
if not key_path.exists():
|
||||
temporary = key_path.with_suffix(".tmp")
|
||||
temporary.write_bytes(Fernet.generate_key())
|
||||
self._restrict(temporary, 0o600)
|
||||
try:
|
||||
temporary.replace(key_path)
|
||||
except FileExistsError:
|
||||
temporary.unlink(missing_ok=True)
|
||||
self._restrict(key_path, 0o600)
|
||||
try:
|
||||
return Fernet(key_path.read_bytes().strip())
|
||||
except (OSError, ValueError) as exc:
|
||||
raise CredentialStoreError("Credential master key cannot be loaded.") from exc
|
||||
|
||||
def _read_tokens(self) -> dict[str, str]:
|
||||
_, store_path = self._paths()
|
||||
if not store_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(store_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise CredentialStoreError("Encrypted credential store cannot be loaded.") from exc
|
||||
if not isinstance(data, dict) or not all(
|
||||
isinstance(key, str) and isinstance(value, str) for key, value in data.items()
|
||||
):
|
||||
raise CredentialStoreError("Encrypted credential store has an invalid format.")
|
||||
return data
|
||||
|
||||
def _write_tokens(self, tokens: dict[str, str]) -> None:
|
||||
_, store_path = self._paths()
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._restrict(store_path.parent, 0o700)
|
||||
temporary = store_path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(tokens, ensure_ascii=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
self._restrict(temporary, 0o600)
|
||||
temporary.replace(store_path)
|
||||
self._restrict(store_path, 0o600)
|
||||
|
||||
def put(self, credential_id: str, secret: str) -> None:
|
||||
self._validate_id(credential_id)
|
||||
if not secret:
|
||||
raise CredentialStoreError("Credential secret cannot be empty.")
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
token = self._fernet().encrypt(secret.encode("utf-8")).decode("ascii")
|
||||
tokens[credential_id] = token
|
||||
self._write_tokens(tokens)
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
if not credential_id:
|
||||
return None
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
token = self._read_tokens().get(credential_id)
|
||||
if token is None:
|
||||
return None
|
||||
try:
|
||||
return self._fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except (InvalidToken, UnicodeDecodeError) as exc:
|
||||
raise CredentialStoreError("Credential cannot be decrypted.") from exc
|
||||
|
||||
def has(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
return credential_id in self._read_tokens()
|
||||
|
||||
def delete(self, credential_id: str) -> bool:
|
||||
self._validate_id(credential_id)
|
||||
with self._lock:
|
||||
tokens = self._read_tokens()
|
||||
removed = tokens.pop(credential_id, None) is not None
|
||||
if removed:
|
||||
self._write_tokens(tokens)
|
||||
return removed
|
||||
|
||||
|
||||
class ChainedCredentialResolver:
|
||||
def __init__(self, *resolvers: CredentialResolver) -> None:
|
||||
self._resolvers = resolvers
|
||||
|
||||
def resolve(self, credential_id: str | None) -> str | None:
|
||||
for resolver in self._resolvers:
|
||||
value = resolver.resolve(credential_id)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.contracts import (
|
||||
ModelRequest,
|
||||
)
|
||||
from app.providers.base import ProviderError, ProviderToolCall, ProviderTurn
|
||||
from app.providers.credentials import CredentialResolver
|
||||
from app.providers.credentials import CredentialResolver, CredentialStoreError
|
||||
from app.providers.http_base import TurnStreamingMixin, decode_tool_arguments
|
||||
|
||||
|
||||
@@ -263,7 +263,13 @@ class OpenAICompatibleProvider(TurnStreamingMixin):
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
try:
|
||||
api_key = self.credentials.resolve(self.credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE",
|
||||
"Credential could not be decrypted by the AI Core.",
|
||||
) from exc
|
||||
if self.credential_id and not api_key:
|
||||
raise ProviderError(
|
||||
"PROVIDER_CREDENTIAL_MISSING",
|
||||
|
||||
@@ -10,6 +10,8 @@ from app.contracts import (
|
||||
AgentRunCreateRequest,
|
||||
AgentRunListResponse,
|
||||
ChatRequest,
|
||||
CredentialStatus,
|
||||
CredentialWriteRequest,
|
||||
ExtensionInstallRequest,
|
||||
IndexJob,
|
||||
IndexRebuildRequest,
|
||||
@@ -54,6 +56,7 @@ from app.extensions import ExtensionError
|
||||
from app.providers.registry import ProviderNotFoundError
|
||||
from app.providers.factory import UnsupportedProviderError
|
||||
from app.providers.base import ProviderError
|
||||
from app.providers.credentials import CredentialStoreError
|
||||
from app.retrieval.engine import engine
|
||||
from app.services import index_service, note_service, task_service, transcription_service
|
||||
|
||||
@@ -413,6 +416,47 @@ async def uninstall_plugin(plugin_id: str) -> OperationResponse:
|
||||
|
||||
|
||||
# Providers
|
||||
@router.get(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def get_credential_status(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
configured = container.credentials.has(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_INVALID", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=configured)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def put_credential(
|
||||
credential_id: str, request: CredentialWriteRequest
|
||||
) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.put(credential_id, request.api_key.get_secret_value())
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=True)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/credentials/{credential_id}",
|
||||
response_model=CredentialStatus,
|
||||
tags=["Providers"],
|
||||
)
|
||||
async def delete_credential(credential_id: str) -> CredentialStatus:
|
||||
try:
|
||||
container.credentials.delete(credential_id)
|
||||
except CredentialStoreError as exc:
|
||||
raise ApiError(422, "CREDENTIAL_STORE_ERROR", str(exc)) from exc
|
||||
return CredentialStatus(credential_id=credential_id, configured=False)
|
||||
|
||||
|
||||
@router.get("/providers", response_model=ProviderListResponse, tags=["Providers"])
|
||||
async def list_providers() -> ProviderListResponse:
|
||||
return ProviderListResponse(items=container.providers.list_configs())
|
||||
@@ -518,6 +562,7 @@ async def list_provider_models(provider_id: str) -> ProviderModelsResponse:
|
||||
except ProviderError as exc:
|
||||
status_code = {
|
||||
"PROVIDER_CREDENTIAL_MISSING": 422,
|
||||
"PROVIDER_CREDENTIAL_UNAVAILABLE": 500,
|
||||
"PROVIDER_AUTH_FAILED": 401,
|
||||
"MODEL_NOT_FOUND": 404,
|
||||
"PROVIDER_RATE_LIMITED": 429,
|
||||
|
||||
Reference in New Issue
Block a user