Add protected soft deletion for research runs

This commit is contained in:
faligam
2026-09-07 12:34:41 +02:00
parent 2c532bc9bb
commit f921e978a0
8 changed files with 445 additions and 37 deletions

View File

@@ -8,8 +8,10 @@ import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import BackgroundTasks, APIRouter, Depends, HTTPException
from fastapi import BackgroundTasks, APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from nsct.logging_config import set_request_ctx, clear_request_ctx, set_research_run_id, get_logger
from nsct.orchestration.budget import HardBudgetConfig
@@ -27,6 +29,14 @@ from nsct.metrics import (
G_ACTIVE_RESEARCH_RUNS,
)
from nsct.security.api_keys import require_api_key
from nsct.security.deletion_protection import (
hash_deletion_password,
is_administrator,
may_manage_retention,
verify_deletion_password,
)
from nsct.storage.engine import get_optional_session
from nsct.storage.models import APIKeyModel, ResearchDeletionAuditModel, ResearchRetentionModel
logger = logging.getLogger(__name__)
@@ -114,6 +124,7 @@ class ResearchListItem(BaseModel):
created_at: str
source_count: int = 0
claim_count: int = 0
is_deletion_protected: bool = False
class ResearchListResponse(BaseModel):
@@ -150,6 +161,27 @@ class StatusResponse(BaseModel):
error: str | None = None
is_completed: bool = False
is_running: bool = False
is_deletion_protected: bool = False
class DeletionProtectionRequest(BaseModel):
"""Set or replace a password that guards a research against deletion."""
password: str = Field(..., min_length=8, max_length=256)
current_password: str | None = Field(default=None, max_length=256)
class DeletionRequest(BaseModel):
"""Optional password used when deleting a protected research."""
password: str | None = Field(default=None, max_length=256)
class DeletedResearchItem(BaseModel):
research_id: str
query: str
hidden_at: str
is_deletion_protected: bool
class SourceListItem(BaseModel):
@@ -272,6 +304,8 @@ class _ResearchRunState(BaseModel):
evidence_scores: list[dict[str, Any]] = Field(default_factory=list)
report: dict[str, Any] | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
owner_user_id: str | None = None
is_hidden: bool = False
# Global in-memory store.
@@ -283,7 +317,7 @@ def _save_run(run: _ResearchRunState) -> None:
def _load_run(research_id: str) -> _ResearchRunState:
if research_id not in _research_store:
if research_id not in _research_store or _research_store[research_id].is_hidden:
raise HTTPException(status_code=404, detail=f"Research run {research_id} not found")
return _research_store[research_id]
@@ -292,6 +326,32 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
async def _retention_record(session: AsyncSession | None, research_id: str) -> ResearchRetentionModel | None:
if session is None:
return None
return await session.scalar(select(ResearchRetentionModel).where(ResearchRetentionModel.research_id == research_id))
async def _is_deletion_protected(session: AsyncSession | None, research_id: str) -> bool:
record = await _retention_record(session, research_id)
return bool(record and record.deletion_password_hash)
async def _require_retention_access(
session: AsyncSession | None, research_id: str, api_key: APIKeyModel | None
) -> tuple[ResearchRetentionModel | None, bool]:
"""Return retention metadata and admin status, rejecting another user's run."""
record = await _retention_record(session, research_id)
# Older in-memory-only runs have no owner metadata. Keep their legacy
# behavior while all newly authenticated runs receive a persistent record.
if record is None:
return None, False
admin = await is_administrator(session, api_key.user_id if api_key else None) if session else False
if not may_manage_retention(record, api_key.user_id if api_key else None, admin):
raise HTTPException(status_code=403, detail="You may only manage your own research runs.")
return record, admin
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -302,9 +362,23 @@ def _now() -> str:
response_model=ResearchListResponse,
summary="List all research runs (paginated)",
)
async def list_research(limit: int = 10, offset: int = 0) -> ResearchListResponse:
async def list_research(
limit: int = 10,
offset: int = 0,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> ResearchListResponse:
"""Liste aller Research-Runs (paginiert)."""
all_runs = list(_research_store.values())
all_runs = [r for r in _research_store.values() if not r.is_hidden]
protected_ids: set[str] = set()
if all_runs and session is not None:
rows = await session.scalars(
select(ResearchRetentionModel.research_id).where(
ResearchRetentionModel.research_id.in_([r.research_id for r in all_runs]),
ResearchRetentionModel.deletion_password_hash.is_not(None),
)
)
protected_ids = set(rows)
total = len(all_runs)
all_runs.sort(key=lambda r: r.created_at, reverse=True)
sliced = all_runs[offset : offset + limit]
@@ -317,6 +391,7 @@ async def list_research(limit: int = 10, offset: int = 0) -> ResearchListRespons
created_at=r.created_at,
source_count=len(r.sources),
claim_count=len(r.claims),
is_deletion_protected=r.research_id in protected_ids,
)
for r in sliced
]
@@ -331,6 +406,8 @@ async def list_research(limit: int = 10, offset: int = 0) -> ResearchListRespons
async def start_research(
request: ResearchRequest,
bg: BackgroundTasks,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> ResearchCreateResponse:
"""Startet eine neue Recherche asynchron.
@@ -376,8 +453,20 @@ async def start_research(
state=ResearchRunState.CREATED.value,
created_at=created_at,
updated_at=created_at,
owner_user_id=api_key.user_id if api_key else None,
)
_save_run(run)
# The retention row is intentionally created before background processing:
# an immediately deleted run still has an accountable lifecycle record.
if api_key is not None and session is not None:
session.add(
ResearchRetentionModel(
research_id=research_id,
owner_user_id=api_key.user_id,
query=request.query,
)
)
await session.flush()
# Increment active gauge
import nsct.metrics as _m
@@ -528,7 +617,10 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
response_model=StatusResponse,
summary="Get research run metadata",
)
async def get_research(research_id: str) -> StatusResponse:
async def get_research(
research_id: str,
session: AsyncSession | None = Depends(get_optional_session),
) -> StatusResponse:
"""Metadaten eines Research-Runs abrufen."""
run = _load_run(research_id)
return StatusResponse(
@@ -548,6 +640,7 @@ async def get_research(research_id: str) -> StatusResponse:
ResearchRunState.FAILED.value,
ResearchRunState.CANCELLED.value,
),
is_deletion_protected=await _is_deletion_protected(session, research_id),
)
@@ -556,7 +649,10 @@ async def get_research(research_id: str) -> StatusResponse:
response_model=StatusResponse,
summary="Get research status",
)
async def get_status(research_id: str) -> StatusResponse:
async def get_status(
research_id: str,
session: AsyncSession | None = Depends(get_optional_session),
) -> StatusResponse:
"""Status eines Research-Runs abrufen."""
run = _load_run(research_id)
return StatusResponse(
@@ -576,6 +672,7 @@ async def get_status(research_id: str) -> StatusResponse:
ResearchRunState.FAILED.value,
ResearchRunState.CANCELLED.value,
),
is_deletion_protected=await _is_deletion_protected(session, research_id),
)
@@ -700,20 +797,130 @@ async def get_report(research_id: str) -> ReportResponse:
@router.delete(
"/v1/research/{research_id}",
response_model=dict[str, str],
summary="Cancel and delete a research run",
summary="Soft-delete a research run",
)
async def delete_research(research_id: str) -> dict[str, str]:
"""Research-Run löschen (nur bei 'created' oder 'failed').
Läuft ein Run gerade, wird er zunächst abgebrochen.
"""
async def delete_research(
research_id: str,
request: DeletionRequest | None = None,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> dict[str, str]:
"""Hide a run without destroying it; protected runs require their password."""
run = _load_run(research_id)
if run.state == ResearchRunState.COMPLETED.value:
raise HTTPException(
status_code=400,
detail="Cannot delete a completed research run. It is immutable.",
)
record, admin = await _require_retention_access(session, research_id, api_key)
if record and record.deletion_password_hash and not admin:
if request is None or not await verify_deletion_password(request.password or "", record.deletion_password_hash):
raise HTTPException(status_code=403, detail="A valid deletion-protection password is required.")
run.is_hidden = True
run.state = ResearchRunState.CANCELLED.value
run.updated_at = _now()
del _research_store[research_id]
return {"status": "deleted", "research_id": research_id}
_save_run(run)
if record and session is not None:
record.is_hidden = True
record.hidden_at = datetime.now(timezone.utc).replace(tzinfo=None)
session.add(
ResearchDeletionAuditModel(
research_id=research_id,
actor_user_id=api_key.user_id if api_key else None,
action="soft_deleted",
)
)
await session.flush()
return {"status": "hidden", "research_id": research_id}
@router.put(
"/v1/research/{research_id}/deletion-protection",
response_model=dict[str, bool],
summary="Set or replace deletion protection",
)
async def set_deletion_protection(
research_id: str,
request: DeletionProtectionRequest,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> dict[str, bool]:
"""Set a password without ever returning it or its hash to the client."""
_load_run(research_id)
record, admin = await _require_retention_access(session, research_id, api_key)
if record is None:
raise HTTPException(status_code=409, detail="Research retention metadata is not available for this legacy run.")
if record.deletion_password_hash and not admin:
if not await verify_deletion_password(request.current_password or "", record.deletion_password_hash):
raise HTTPException(status_code=403, detail="The current deletion-protection password is required.")
record.deletion_password_hash = await hash_deletion_password(request.password)
await session.flush()
return {"is_deletion_protected": True}
@router.delete(
"/v1/research/{research_id}/deletion-protection",
response_model=dict[str, bool],
summary="Remove deletion protection",
)
async def remove_deletion_protection(
research_id: str,
request: DeletionRequest,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> dict[str, bool]:
_load_run(research_id)
record, admin = await _require_retention_access(session, research_id, api_key)
if record is None or not record.deletion_password_hash:
return {"is_deletion_protected": False}
if not admin and not await verify_deletion_password(request.password or "", record.deletion_password_hash):
raise HTTPException(
status_code=403,
detail="A valid deletion-protection password is required.",
)
record.deletion_password_hash = None
await session.flush()
return {"is_deletion_protected": False}
async def _require_admin(session: AsyncSession | None, api_key: APIKeyModel | None) -> None:
if session is None or not await is_administrator(session, api_key.user_id if api_key else None):
raise HTTPException(status_code=403, detail="Administrator privileges are required.")
@router.get("/v1/admin/research/deleted", response_model=list[DeletedResearchItem], summary="List soft-deleted research")
async def list_deleted_research(
session: AsyncSession | None = Depends(get_optional_session), api_key: APIKeyModel | None = Depends(require_api_key)
) -> list[DeletedResearchItem]:
await _require_admin(session, api_key)
assert session is not None
records = await session.scalars(
select(ResearchRetentionModel)
.where(ResearchRetentionModel.is_hidden.is_(True))
.order_by(ResearchRetentionModel.hidden_at.desc())
)
return [
DeletedResearchItem(
research_id=record.research_id,
query=record.query,
hidden_at=(record.hidden_at or record.updated_at).replace(tzinfo=timezone.utc).isoformat(),
is_deletion_protected=bool(record.deletion_password_hash),
)
for record in records
]
@router.delete("/v1/admin/research/{research_id}", response_model=dict[str, str], summary="Permanently purge hidden research")
async def purge_deleted_research(
research_id: str,
session: AsyncSession | None = Depends(get_optional_session),
api_key: APIKeyModel | None = Depends(require_api_key),
) -> dict[str, str]:
await _require_admin(session, api_key)
assert session is not None
record = await _retention_record(session, research_id)
if record is None or not record.is_hidden:
raise HTTPException(status_code=404, detail="No hidden research run found.")
session.add(
ResearchDeletionAuditModel(research_id=research_id, actor_user_id=api_key.user_id if api_key else None, action="purged")
)
await session.delete(record)
_research_store.pop(research_id, None)
await session.flush()
return {"status": "purged", "research_id": research_id}