Add protected soft deletion for research runs
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user