337 lines
13 KiB
Python
337 lines
13 KiB
Python
# #region Api.EncryptionHealth.EncryptionHealthRoutes [C:4] [TYPE Module] [SEMANTICS api,security,encryption,health,recovery]
|
|
# @BRIEF API endpoints for encryption health inventory and key-change recovery.
|
|
# @LAYER API
|
|
# @PRE User is authenticated. Health and fingerprint require security:READ permission.
|
|
# Recover requires security:WRITE permission.
|
|
# @SIDE_EFFECT Reads all LLM provider API keys, DB connection passwords, and profile
|
|
# Git tokens from storage. Writes encrypted replacement values on recover.
|
|
# @RELATION DEPENDS_ON -> [Core.Encryption.EncryptionManager]
|
|
# @RELATION DEPENDS_ON -> [Services.LlmProvider.LLMProviderService]
|
|
# @RELATION DEPENDS_ON -> [Core.ConnectionService]
|
|
# @RATIONALE Centralizes secret inventory and recovery after ENCRYPTION_KEY change.
|
|
# Without this, operators must manually trace decrypt failures across
|
|
# scattered API responses.
|
|
# @TEST_EDGE: empty_payload — POST /recover with empty items list → returns failed/failed
|
|
# @TEST_EDGE: provider_not_found — recovery for non-existent provider ID → skipped
|
|
# @TEST_EDGE: connection_not_found — recovery for non-existent connection ID → skipped
|
|
# @TEST_EDGE: invalid_type — recovery item with unknown type → skipped
|
|
|
|
import hashlib
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...core.config_manager import ConfigManager
|
|
from ...core.connection_service import ConnectionService
|
|
from ...core.database import get_db
|
|
from ...core.encryption import get_encryption_manager, is_fernet_token
|
|
from ...core.logger import belief_scope, logger
|
|
from ...dependencies import get_config_manager, get_current_user, has_permission
|
|
from ...plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
|
from ...schemas.auth import User
|
|
from ...services.llm_provider import LLMProviderService
|
|
|
|
router = APIRouter(prefix="/api/security/encryption", tags=["Security"])
|
|
|
|
# ── Pydantic models ──────────────────────────────────────────────────
|
|
|
|
|
|
class EncryptionHealthItem(BaseModel):
|
|
id: str
|
|
type: str
|
|
label: str
|
|
status: str
|
|
reason: str | None = None
|
|
requires: list[str] = []
|
|
metadata: dict = {}
|
|
|
|
|
|
class EncryptionHealthResponse(BaseModel):
|
|
status: str
|
|
key_fingerprint: str
|
|
summary: dict
|
|
items: list[EncryptionHealthItem]
|
|
|
|
|
|
class RecoveryItem(BaseModel):
|
|
id: str
|
|
type: str
|
|
values: dict = {}
|
|
|
|
|
|
class RecoveryPayload(BaseModel):
|
|
items: list[RecoveryItem]
|
|
|
|
|
|
class RecoveryResultItem(BaseModel):
|
|
id: str
|
|
type: str
|
|
status: str # updated | failed | skipped
|
|
|
|
|
|
class RecoveryResponse(BaseModel):
|
|
status: str
|
|
updated: list[RecoveryResultItem] = []
|
|
failed: list[RecoveryResultItem] = []
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _key_fingerprint() -> str:
|
|
try:
|
|
encryption = get_encryption_manager()
|
|
raw = encryption.key
|
|
return "sha256:" + hashlib.sha256(raw).hexdigest()[:8]
|
|
except Exception:
|
|
return "unavailable"
|
|
|
|
|
|
def _try_decrypt(value: str) -> tuple[bool, str | None]:
|
|
if not is_fernet_token(value):
|
|
return False, "not_fernet_token"
|
|
try:
|
|
get_encryption_manager().decrypt(value)
|
|
return True, None
|
|
except Exception as e:
|
|
return False, str(e)[:200]
|
|
|
|
|
|
def _sanitize_error(msg: str) -> str:
|
|
"""Truncate and scrub error messages before logging to prevent secret leakage."""
|
|
return msg[:100]
|
|
|
|
|
|
# ── Inventory ────────────────────────────────────────────────────────
|
|
|
|
|
|
def _inventory_llm_providers(db: Session) -> list[dict]:
|
|
items = []
|
|
service = LLMProviderService(db)
|
|
providers = service.get_all_providers()
|
|
for p in providers:
|
|
if not p.api_key:
|
|
items.append(
|
|
{
|
|
"id": p.id,
|
|
"type": "llm_provider",
|
|
"label": p.name,
|
|
"status": "missing_key",
|
|
"reason": "no_key_stored",
|
|
"requires": ["api_key"],
|
|
"metadata": {
|
|
"provider_type": p.provider_type,
|
|
"base_url": p.base_url,
|
|
"default_model": p.default_model,
|
|
"is_active": bool(p.is_active),
|
|
},
|
|
}
|
|
)
|
|
continue
|
|
ok, err = _try_decrypt(p.api_key)
|
|
items.append(
|
|
{
|
|
"id": p.id,
|
|
"type": "llm_provider",
|
|
"label": p.name,
|
|
"status": "healthy" if ok else "broken",
|
|
"reason": None if ok else (err or "decrypt_failed"),
|
|
"requires": [] if ok else ["api_key"],
|
|
"metadata": {
|
|
"provider_type": p.provider_type,
|
|
"base_url": p.base_url,
|
|
"default_model": p.default_model,
|
|
"is_active": bool(p.is_active),
|
|
},
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def _inventory_connections(config_manager: ConfigManager) -> list[dict]:
|
|
items = []
|
|
for conn in config_manager.config.settings.connections:
|
|
pwd = conn.password
|
|
if not pwd:
|
|
items.append(
|
|
{
|
|
"id": conn.id,
|
|
"type": "database_connection",
|
|
"label": conn.name,
|
|
"status": "missing_key",
|
|
"reason": "no_password_stored",
|
|
"requires": ["password"],
|
|
"metadata": {"host": conn.host, "database": conn.database, "username": conn.username},
|
|
}
|
|
)
|
|
continue
|
|
ok, err = _try_decrypt(pwd)
|
|
items.append(
|
|
{
|
|
"id": conn.id,
|
|
"type": "database_connection",
|
|
"label": conn.name,
|
|
"status": "healthy" if ok else "broken",
|
|
"reason": None if ok else (err or "decrypt_failed"),
|
|
"requires": [] if ok else ["password"],
|
|
"metadata": {"host": conn.host, "database": conn.database, "username": conn.username},
|
|
}
|
|
)
|
|
return items
|
|
|
|
|
|
def _inventory_profile_tokens() -> list[dict]:
|
|
items = []
|
|
try:
|
|
from ...core.database import SessionLocal
|
|
from ...models.profile import UserDashboardPreference
|
|
|
|
db_auth = SessionLocal()
|
|
try:
|
|
prefs = (
|
|
db_auth.query(UserDashboardPreference)
|
|
.filter(
|
|
UserDashboardPreference.git_personal_access_token_encrypted.isnot(None),
|
|
UserDashboardPreference.git_personal_access_token_encrypted != "",
|
|
)
|
|
.all()
|
|
)
|
|
total = len(prefs)
|
|
broken = 0
|
|
for pref in prefs:
|
|
ok, _ = _try_decrypt(pref.git_personal_access_token_encrypted)
|
|
if not ok:
|
|
broken += 1
|
|
if total > 0:
|
|
items.append(
|
|
{
|
|
"id": "profile:aggregate",
|
|
"type": "profile_git_token",
|
|
"label": f"{broken}/{total} git token(s) need attention",
|
|
"status": "broken" if broken > 0 else "healthy",
|
|
"reason": f"{broken} of {total} cannot decrypt" if broken > 0 else None,
|
|
"requires": [] if broken == 0 else ["user_action_profile"],
|
|
"metadata": {"total": total, "broken": broken},
|
|
}
|
|
)
|
|
finally:
|
|
db_auth.close()
|
|
except Exception as e:
|
|
logger.warning("Failed to inventory profile tokens", extra={"error": _sanitize_error(str(e))})
|
|
return items
|
|
|
|
|
|
# ── GET /api/security/encryption/health ──────────────────────────────
|
|
|
|
|
|
@router.get("/health", response_model=EncryptionHealthResponse)
|
|
async def encryption_health(
|
|
current_user: User = Depends(get_current_user),
|
|
_: None = Depends(has_permission("security", "READ")),
|
|
db: Session = Depends(get_db),
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
):
|
|
with belief_scope("encryption_health"):
|
|
llm_items = _inventory_llm_providers(db)
|
|
conn_items = _inventory_connections(config_manager)
|
|
token_items = _inventory_profile_tokens()
|
|
|
|
all_items = llm_items + conn_items + token_items
|
|
broken = [it for it in all_items if it["status"] == "broken"]
|
|
|
|
summary = {
|
|
"llm_providers_total": len(llm_items),
|
|
"llm_providers_broken": len([it for it in llm_items if it["status"] == "broken"]),
|
|
"connections_total": len(conn_items),
|
|
"connections_broken": len([it for it in conn_items if it["status"] == "broken"]),
|
|
"profile_tokens_broken": len([it for it in token_items if it["status"] == "broken"]),
|
|
}
|
|
|
|
return EncryptionHealthResponse(
|
|
status="needs_recovery" if broken else "healthy",
|
|
key_fingerprint=_key_fingerprint(),
|
|
summary=summary,
|
|
items=[EncryptionHealthItem(**it) for it in all_items],
|
|
)
|
|
|
|
|
|
# ── GET /api/security/encryption/fingerprint ─────────────────────────
|
|
|
|
|
|
@router.get("/fingerprint")
|
|
async def encryption_fingerprint(
|
|
current_user: User = Depends(get_current_user),
|
|
_: None = Depends(has_permission("security", "READ")),
|
|
):
|
|
return {"fingerprint": _key_fingerprint()}
|
|
|
|
|
|
# ── POST /api/security/encryption/recover ────────────────────────────
|
|
|
|
|
|
@router.post("/recover", response_model=RecoveryResponse)
|
|
async def encryption_recover(
|
|
payload: RecoveryPayload,
|
|
current_user: User = Depends(get_current_user),
|
|
_: None = Depends(has_permission("security", "WRITE")),
|
|
db: Session = Depends(get_db),
|
|
config_manager: ConfigManager = Depends(get_config_manager),
|
|
):
|
|
with belief_scope("encryption_recover"):
|
|
updated: list[RecoveryResultItem] = []
|
|
failed: list[RecoveryResultItem] = []
|
|
|
|
conn_service = ConnectionService(config_manager)
|
|
|
|
for item in payload.items:
|
|
try:
|
|
if item.type == "llm_provider":
|
|
service = LLMProviderService(db)
|
|
provider = service.get_provider(item.id)
|
|
if not provider:
|
|
failed.append(RecoveryResultItem(id=item.id, type=item.type, status="failed"))
|
|
continue
|
|
new_key = item.values.get("api_key", "")
|
|
if new_key:
|
|
config = LLMProviderConfig(
|
|
provider_type=LLMProviderType(provider.provider_type),
|
|
name=provider.name,
|
|
base_url=provider.base_url,
|
|
api_key=new_key,
|
|
default_model=provider.default_model,
|
|
is_active=bool(provider.is_active),
|
|
is_multimodal=bool(provider.is_multimodal) if provider.is_multimodal is not None else False,
|
|
max_images=provider.max_images,
|
|
context_window=provider.context_window,
|
|
max_output_tokens=provider.max_output_tokens,
|
|
)
|
|
service.update_provider(item.id, config)
|
|
updated.append(RecoveryResultItem(id=item.id, type=item.type, status="updated"))
|
|
else:
|
|
failed.append(RecoveryResultItem(id=item.id, type=item.type, status="skipped"))
|
|
|
|
elif item.type == "database_connection":
|
|
new_pwd = item.values.get("password", "")
|
|
if new_pwd:
|
|
conn_service.update_connection(item.id, {"password": new_pwd})
|
|
updated.append(RecoveryResultItem(id=item.id, type=item.type, status="updated"))
|
|
else:
|
|
failed.append(RecoveryResultItem(id=item.id, type=item.type, status="skipped"))
|
|
|
|
else:
|
|
failed.append(RecoveryResultItem(id=item.id, type=item.type, status="skipped"))
|
|
|
|
except Exception as e:
|
|
logger.warning("Recovery failed", extra={"id": item.id, "type": item.type, "error": _sanitize_error(str(e))})
|
|
failed.append(RecoveryResultItem(id=item.id, type=item.type, status="failed"))
|
|
|
|
if not updated and failed:
|
|
return RecoveryResponse(status="failed", updated=[], failed=failed)
|
|
if failed:
|
|
return RecoveryResponse(status="partial_success", updated=updated, failed=failed)
|
|
return RecoveryResponse(status="complete", updated=updated, failed=[])
|
|
|
|
|
|
# #endregion Api.EncryptionHealth.EncryptionHealthRoutes
|