Add protected soft deletion for research runs
This commit is contained in:
23
API.md
23
API.md
@@ -359,17 +359,34 @@ Identisch zu `GET /v1/research/{id}` — gleicher Response.
|
|||||||
|
|
||||||
### DELETE /v1/research/{id}
|
### DELETE /v1/research/{id}
|
||||||
|
|
||||||
**Beschreibung:** Löscht einen Research-Run (nur wenn nicht completed).
|
**Beschreibung:** Verschiebt einen Research-Run (auch einen abgeschlossenen) als
|
||||||
|
Soft Delete in den Papierkorb. Der Run ist anschließend in normalen Listen und
|
||||||
|
Abrufen nicht mehr sichtbar. Bei aktivem Löschschutz muss das Passwort im Body
|
||||||
|
übergeben werden; Administratoren benötigen es nicht.
|
||||||
|
|
||||||
**Response (200 OK):**
|
**Response (200 OK):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "deleted",
|
"status": "hidden",
|
||||||
"research_id": "uuid-..."
|
"research_id": "uuid-..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Error (400):** Completed runs sind immutable.
|
### PUT /v1/research/{id}/deletion-protection
|
||||||
|
|
||||||
|
Setzt einen Löschschutz mit einem Passwort von mindestens acht Zeichen. Das
|
||||||
|
Passwort wird ausschließlich als scrypt-Hash gespeichert und nie ausgegeben.
|
||||||
|
Bei einer Änderung eines bestehenden Schutzes ist `current_password` nötig.
|
||||||
|
|
||||||
|
### GET /v1/admin/research/deleted
|
||||||
|
|
||||||
|
Administrator-Endpunkt für den Papierkorb. Die Administratorrolle wird lokal
|
||||||
|
mit `nsct-api-key grant-admin <username>` vergeben.
|
||||||
|
|
||||||
|
### DELETE /v1/admin/research/{id}
|
||||||
|
|
||||||
|
Endgültiges Löschen eines bereits ausgeblendeten Eintrags. Sowohl Soft Delete
|
||||||
|
als auch Purge werden in `research_deletion_audit` protokolliert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy import select
|
|||||||
from nsct.config import AppSettings
|
from nsct.config import AppSettings
|
||||||
from nsct.security.api_keys import create_api_key, revoke_api_key
|
from nsct.security.api_keys import create_api_key, revoke_api_key
|
||||||
from nsct.storage.engine import close_engine, get_engine, get_session_factory
|
from nsct.storage.engine import close_engine, get_engine, get_session_factory
|
||||||
from nsct.storage.models import APIKeyModel, UserModel
|
from nsct.storage.models import APIKeyModel, AdminUserModel, UserModel
|
||||||
|
|
||||||
|
|
||||||
def _parse_expiry(value: str | None) -> datetime | None:
|
def _parse_expiry(value: str | None) -> datetime | None:
|
||||||
@@ -51,6 +51,25 @@ async def _run(args: argparse.Namespace) -> int:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
print(f"Revoked API key {args.key_id}.")
|
print(f"Revoked API key {args.key_id}.")
|
||||||
return 0
|
return 0
|
||||||
|
if args.command in {"grant-admin", "revoke-admin"}:
|
||||||
|
user = await session.scalar(select(UserModel).where(UserModel.username == args.username))
|
||||||
|
if user is None:
|
||||||
|
print("No user found for that username.")
|
||||||
|
return 1
|
||||||
|
grant = await session.get(AdminUserModel, user.id)
|
||||||
|
if args.command == "grant-admin":
|
||||||
|
if grant is None:
|
||||||
|
session.add(AdminUserModel(user_id=user.id))
|
||||||
|
await session.commit()
|
||||||
|
print(f"Granted administrator privileges to {user.username}.")
|
||||||
|
return 0
|
||||||
|
if grant is None:
|
||||||
|
print(f"{user.username} is not an administrator.")
|
||||||
|
return 1
|
||||||
|
await session.delete(grant)
|
||||||
|
await session.commit()
|
||||||
|
print(f"Revoked administrator privileges from {user.username}.")
|
||||||
|
return 0
|
||||||
|
|
||||||
rows = await session.execute(
|
rows = await session.execute(
|
||||||
select(APIKeyModel, UserModel)
|
select(APIKeyModel, UserModel)
|
||||||
@@ -77,6 +96,10 @@ def main() -> int:
|
|||||||
create.add_argument("--expires-at", help="ISO-8601 timestamp; omitted means no expiry")
|
create.add_argument("--expires-at", help="ISO-8601 timestamp; omitted means no expiry")
|
||||||
revoke = commands.add_parser("revoke", help="Revoke a key by its public key_id")
|
revoke = commands.add_parser("revoke", help="Revoke a key by its public key_id")
|
||||||
revoke.add_argument("key_id")
|
revoke.add_argument("key_id")
|
||||||
|
grant_admin = commands.add_parser("grant-admin", help="Grant administrator privileges to an existing user")
|
||||||
|
grant_admin.add_argument("username")
|
||||||
|
revoke_admin = commands.add_parser("revoke-admin", help="Revoke administrator privileges from a user")
|
||||||
|
revoke_admin.add_argument("username")
|
||||||
commands.add_parser("list", help="List key metadata (never plaintext or hashes)")
|
commands.add_parser("list", help="List key metadata (never plaintext or hashes)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import uuid
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
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 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.logging_config import set_request_ctx, clear_request_ctx, set_research_run_id, get_logger
|
||||||
from nsct.orchestration.budget import HardBudgetConfig
|
from nsct.orchestration.budget import HardBudgetConfig
|
||||||
@@ -27,6 +29,14 @@ from nsct.metrics import (
|
|||||||
G_ACTIVE_RESEARCH_RUNS,
|
G_ACTIVE_RESEARCH_RUNS,
|
||||||
)
|
)
|
||||||
from nsct.security.api_keys import require_api_key
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -114,6 +124,7 @@ class ResearchListItem(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
source_count: int = 0
|
source_count: int = 0
|
||||||
claim_count: int = 0
|
claim_count: int = 0
|
||||||
|
is_deletion_protected: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ResearchListResponse(BaseModel):
|
class ResearchListResponse(BaseModel):
|
||||||
@@ -150,6 +161,27 @@ class StatusResponse(BaseModel):
|
|||||||
error: str | None = None
|
error: str | None = None
|
||||||
is_completed: bool = False
|
is_completed: bool = False
|
||||||
is_running: 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):
|
class SourceListItem(BaseModel):
|
||||||
@@ -272,6 +304,8 @@ class _ResearchRunState(BaseModel):
|
|||||||
evidence_scores: list[dict[str, Any]] = Field(default_factory=list)
|
evidence_scores: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
report: dict[str, Any] | None = None
|
report: dict[str, Any] | None = None
|
||||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
owner_user_id: str | None = None
|
||||||
|
is_hidden: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Global in-memory store.
|
# Global in-memory store.
|
||||||
@@ -283,7 +317,7 @@ def _save_run(run: _ResearchRunState) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _load_run(research_id: str) -> _ResearchRunState:
|
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")
|
raise HTTPException(status_code=404, detail=f"Research run {research_id} not found")
|
||||||
return _research_store[research_id]
|
return _research_store[research_id]
|
||||||
|
|
||||||
@@ -292,6 +326,32 @@ def _now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
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
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -302,9 +362,23 @@ def _now() -> str:
|
|||||||
response_model=ResearchListResponse,
|
response_model=ResearchListResponse,
|
||||||
summary="List all research runs (paginated)",
|
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)."""
|
"""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)
|
total = len(all_runs)
|
||||||
all_runs.sort(key=lambda r: r.created_at, reverse=True)
|
all_runs.sort(key=lambda r: r.created_at, reverse=True)
|
||||||
sliced = all_runs[offset : offset + limit]
|
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,
|
created_at=r.created_at,
|
||||||
source_count=len(r.sources),
|
source_count=len(r.sources),
|
||||||
claim_count=len(r.claims),
|
claim_count=len(r.claims),
|
||||||
|
is_deletion_protected=r.research_id in protected_ids,
|
||||||
)
|
)
|
||||||
for r in sliced
|
for r in sliced
|
||||||
]
|
]
|
||||||
@@ -331,6 +406,8 @@ async def list_research(limit: int = 10, offset: int = 0) -> ResearchListRespons
|
|||||||
async def start_research(
|
async def start_research(
|
||||||
request: ResearchRequest,
|
request: ResearchRequest,
|
||||||
bg: BackgroundTasks,
|
bg: BackgroundTasks,
|
||||||
|
session: AsyncSession | None = Depends(get_optional_session),
|
||||||
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
||||||
) -> ResearchCreateResponse:
|
) -> ResearchCreateResponse:
|
||||||
"""Startet eine neue Recherche asynchron.
|
"""Startet eine neue Recherche asynchron.
|
||||||
|
|
||||||
@@ -376,8 +453,20 @@ async def start_research(
|
|||||||
state=ResearchRunState.CREATED.value,
|
state=ResearchRunState.CREATED.value,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
updated_at=created_at,
|
updated_at=created_at,
|
||||||
|
owner_user_id=api_key.user_id if api_key else None,
|
||||||
)
|
)
|
||||||
_save_run(run)
|
_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
|
# Increment active gauge
|
||||||
import nsct.metrics as _m
|
import nsct.metrics as _m
|
||||||
@@ -528,7 +617,10 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
|
|||||||
response_model=StatusResponse,
|
response_model=StatusResponse,
|
||||||
summary="Get research run metadata",
|
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."""
|
"""Metadaten eines Research-Runs abrufen."""
|
||||||
run = _load_run(research_id)
|
run = _load_run(research_id)
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
@@ -548,6 +640,7 @@ async def get_research(research_id: str) -> StatusResponse:
|
|||||||
ResearchRunState.FAILED.value,
|
ResearchRunState.FAILED.value,
|
||||||
ResearchRunState.CANCELLED.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,
|
response_model=StatusResponse,
|
||||||
summary="Get research status",
|
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."""
|
"""Status eines Research-Runs abrufen."""
|
||||||
run = _load_run(research_id)
|
run = _load_run(research_id)
|
||||||
return StatusResponse(
|
return StatusResponse(
|
||||||
@@ -576,6 +672,7 @@ async def get_status(research_id: str) -> StatusResponse:
|
|||||||
ResearchRunState.FAILED.value,
|
ResearchRunState.FAILED.value,
|
||||||
ResearchRunState.CANCELLED.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(
|
@router.delete(
|
||||||
"/v1/research/{research_id}",
|
"/v1/research/{research_id}",
|
||||||
response_model=dict[str, str],
|
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]:
|
async def delete_research(
|
||||||
"""Research-Run löschen (nur bei 'created' oder 'failed').
|
research_id: str,
|
||||||
|
request: DeletionRequest | None = None,
|
||||||
Läuft ein Run gerade, wird er zunächst abgebrochen.
|
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)
|
run = _load_run(research_id)
|
||||||
if run.state == ResearchRunState.COMPLETED.value:
|
record, admin = await _require_retention_access(session, research_id, api_key)
|
||||||
raise HTTPException(
|
if record and record.deletion_password_hash and not admin:
|
||||||
status_code=400,
|
if request is None or not await verify_deletion_password(request.password or "", record.deletion_password_hash):
|
||||||
detail="Cannot delete a completed research run. It is immutable.",
|
raise HTTPException(status_code=403, detail="A valid deletion-protection password is required.")
|
||||||
)
|
|
||||||
|
run.is_hidden = True
|
||||||
run.state = ResearchRunState.CANCELLED.value
|
run.state = ResearchRunState.CANCELLED.value
|
||||||
run.updated_at = _now()
|
run.updated_at = _now()
|
||||||
del _research_store[research_id]
|
_save_run(run)
|
||||||
return {"status": "deleted", "research_id": research_id}
|
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}
|
||||||
|
|||||||
63
src/nsct/security/deletion_protection.py
Normal file
63
src/nsct/security/deletion_protection.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Password hashing and authorization helpers for research deletion protection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
|
from nsct.storage.models import AdminUserModel, ResearchRetentionModel
|
||||||
|
|
||||||
|
_SCRYPT_N = 2**15
|
||||||
|
_SCRYPT_R = 8
|
||||||
|
_SCRYPT_P = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_password(password: str, salt: bytes | None = None) -> str:
|
||||||
|
salt = salt or secrets.token_bytes(16)
|
||||||
|
digest = hashlib.scrypt(
|
||||||
|
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, maxmem=64 * 1024 * 1024
|
||||||
|
)
|
||||||
|
return "scrypt$32768$8$1${}${}".format(
|
||||||
|
base64.urlsafe_b64encode(salt).decode("ascii"),
|
||||||
|
base64.urlsafe_b64encode(digest).decode("ascii"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(password: str, stored_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
algorithm, n, r, p, salt_b64, digest_b64 = stored_hash.split("$")
|
||||||
|
if algorithm != "scrypt":
|
||||||
|
return False
|
||||||
|
candidate = hashlib.scrypt(
|
||||||
|
password.encode("utf-8"),
|
||||||
|
salt=base64.urlsafe_b64decode(salt_b64.encode("ascii")),
|
||||||
|
n=int(n), r=int(r), p=int(p), maxmem=64 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(candidate, base64.urlsafe_b64decode(digest_b64.encode("ascii")))
|
||||||
|
except (ValueError, TypeError, UnicodeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def hash_deletion_password(password: str) -> str:
|
||||||
|
return await run_in_threadpool(_hash_password, password)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_deletion_password(password: str, stored_hash: str | None) -> bool:
|
||||||
|
return bool(stored_hash) and await run_in_threadpool(_verify_password, password, stored_hash)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_administrator(session: AsyncSession, user_id: str | None) -> bool:
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
return await session.scalar(select(AdminUserModel.user_id).where(AdminUserModel.user_id == user_id)) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def may_manage_retention(record: ResearchRetentionModel | None, user_id: str | None, is_admin: bool) -> bool:
|
||||||
|
"""Owners manage their own run; an explicit administrator may manage any run."""
|
||||||
|
return bool(record) and (is_admin or (user_id is not None and record.owner_user_id == user_id))
|
||||||
@@ -9,7 +9,13 @@ from sqlalchemy import event
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from nsct.config import AppSettings
|
from nsct.config import AppSettings
|
||||||
from nsct.storage.models import APIKeyModel, UserModel
|
from nsct.storage.models import (
|
||||||
|
APIKeyModel,
|
||||||
|
AdminUserModel,
|
||||||
|
ResearchDeletionAuditModel,
|
||||||
|
ResearchRetentionModel,
|
||||||
|
UserModel,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,14 +44,19 @@ async def get_engine(config: AppSettings) -> None:
|
|||||||
engine_options.update(pool_size=10, max_overflow=20, pool_recycle=1800)
|
engine_options.update(pool_size=10, max_overflow=20, pool_recycle=1800)
|
||||||
_engine = create_async_engine(db_url, **engine_options)
|
_engine = create_async_engine(db_url, **engine_options)
|
||||||
|
|
||||||
# Auth is the only persistent HTTP concern currently using this engine.
|
# Only tables used by the HTTP control plane are created here. The legacy
|
||||||
# Other legacy model metadata contains tables that are not yet deployable
|
# evidence models deliberately remain outside this small deployable schema.
|
||||||
# as a single schema, so creating all of it here can prevent API startup.
|
|
||||||
async with _engine.begin() as conn:
|
async with _engine.begin() as conn:
|
||||||
await conn.run_sync(
|
await conn.run_sync(
|
||||||
lambda sync_conn: UserModel.metadata.create_all(
|
lambda sync_conn: UserModel.metadata.create_all(
|
||||||
sync_conn,
|
sync_conn,
|
||||||
tables=[UserModel.__table__, APIKeyModel.__table__],
|
tables=[
|
||||||
|
UserModel.__table__,
|
||||||
|
APIKeyModel.__table__,
|
||||||
|
AdminUserModel.__table__,
|
||||||
|
ResearchRetentionModel.__table__,
|
||||||
|
ResearchDeletionAuditModel.__table__,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -77,6 +88,20 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_session() -> AsyncGenerator[AsyncSession | None, None]:
|
||||||
|
"""Yield a session when persistence is configured, otherwise ``None``.
|
||||||
|
|
||||||
|
This keeps the legacy in-memory research endpoints usable in isolated unit
|
||||||
|
tests while production requests always receive a database session through
|
||||||
|
API-key authentication.
|
||||||
|
"""
|
||||||
|
if _session_factory is None:
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
async for session in get_session():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
"""Return the initialized factory for trusted administrative commands."""
|
"""Return the initialized factory for trusted administrative commands."""
|
||||||
if _session_factory is None:
|
if _session_factory is None:
|
||||||
|
|||||||
@@ -126,6 +126,62 @@ class APIKeyModel(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Research retention and deletion audit
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchRetentionModel(Base):
|
||||||
|
"""Persistent lifecycle metadata for a research run.
|
||||||
|
|
||||||
|
The pipeline payload is currently held by the runtime store, but retention
|
||||||
|
decisions must survive an API restart. Passwords are stored only as a
|
||||||
|
salted hash; ``is_hidden`` implements the user-facing soft delete.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "research_retention"
|
||||||
|
|
||||||
|
research_id = Column(String(36), primary_key=True)
|
||||||
|
owner_user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
|
query = Column(Text, nullable=False)
|
||||||
|
deletion_password_hash = Column(String(255), nullable=True)
|
||||||
|
is_hidden = Column(Boolean, nullable=False, default=False)
|
||||||
|
hidden_at = Column(DateTime, nullable=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_research_retention_owner", "owner_user_id"),
|
||||||
|
Index("ix_research_retention_hidden", "is_hidden"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResearchDeletionAuditModel(Base):
|
||||||
|
"""Append-only audit trail for soft and final research deletions."""
|
||||||
|
|
||||||
|
__tablename__ = "research_deletion_audit"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
research_id = Column(String(36), nullable=False)
|
||||||
|
actor_user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||||
|
action = Column(String(32), nullable=False)
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_research_deletion_audit_research", "research_id"),
|
||||||
|
Index("ix_research_deletion_audit_created", "created_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserModel(Base):
|
||||||
|
"""Explicit administrator grant, separate from API-key ownership."""
|
||||||
|
|
||||||
|
__tablename__ = "admin_users"
|
||||||
|
|
||||||
|
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||||
|
granted_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SearchQuery
|
# SearchQuery
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
19
tests/test_deletion_protection.py
Normal file
19
tests/test_deletion_protection.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Focused tests for deletion-protection primitives without external providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nsct.security.deletion_protection import hash_deletion_password, verify_deletion_password
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deletion_password_is_salted_and_verifiable() -> None:
|
||||||
|
password = "a-long-enough-deletion-password"
|
||||||
|
first = await hash_deletion_password(password)
|
||||||
|
second = await hash_deletion_password(password)
|
||||||
|
|
||||||
|
assert first != password
|
||||||
|
assert first != second
|
||||||
|
assert await verify_deletion_password(password, first)
|
||||||
|
assert not await verify_deletion_password("incorrect-password", first)
|
||||||
@@ -413,8 +413,8 @@ def test_get_report_not_completed(client: TestClient) -> None:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def test_delete_research_active_fails(client: TestClient) -> None:
|
def test_delete_research_soft_deletes_all_states(client: TestClient) -> None:
|
||||||
"""DELETE handles both active and finished research."""
|
"""DELETE hides a run, including a completed one, instead of destroying it."""
|
||||||
from nsct.api.rest_research import _research_store, ResearchRunState
|
from nsct.api.rest_research import _research_store, ResearchRunState
|
||||||
|
|
||||||
_research_store.clear()
|
_research_store.clear()
|
||||||
@@ -434,14 +434,12 @@ def test_delete_research_active_fails(client: TestClient) -> None:
|
|||||||
# Already deleted by previous tests — skip
|
# Already deleted by previous tests — skip
|
||||||
return
|
return
|
||||||
|
|
||||||
# If completed, delete should return 400 (immutable)
|
|
||||||
# If failed/created, delete should succeed
|
|
||||||
resp = client.delete(f"/v1/research/{research_id}")
|
resp = client.delete(f"/v1/research/{research_id}")
|
||||||
assert resp.status_code in (200, 400)
|
assert resp.status_code == 200
|
||||||
if resp.status_code == 200:
|
body = resp.json()
|
||||||
body = resp.json()
|
assert body["status"] == "hidden"
|
||||||
assert body["status"] == "deleted"
|
assert body["research_id"] == research_id
|
||||||
assert body["research_id"] == research_id
|
assert research_id not in _research_store or _research_store[research_id].is_hidden
|
||||||
|
|
||||||
|
|
||||||
def test_delete_research_not_found(client: TestClient) -> None:
|
def test_delete_research_not_found(client: TestClient) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user