Spaces:
Sleeping
feat(#77 / #52-residual-5): admin prune for persisted uploaded docs
Browse filesPersisted uploaded marketplace docs have NO TTL by design (public
catalogue cards). Added the sanctioned operator removal path:
- backend/uploaded_docs.prune_persisted_upload(policy_id | prefix=...) β
HARD path-safety guard: only ever rmtrees a DIRECT child of
UPLOADED_DOCS_DIR (can never touch rag/corpus, 40-data, curated/
extracted); traversal attempt is skipped (resolves inside root) /
empty prefix RAISES (no silent no-op).
- POST /api/admin/uploaded-docs/prune (password-gated, reuses _check_admin):
prunes dir(s) + deletes the doc's chunks from the GLOBAL policies Chroma
collection + busts _MG_CACHE/_CORPUS_PDF_IDX so /api/policies/all drops
the card immediately; collects (never swallows) Chroma errors.
- tests/test_uploaded_docs_prune.py β exact+prefix removal, non-matching
docs preserved, traversal-safe, empty-prefix rejected (3/3).
Needed because my own live #52 verification created disposable
'Zzz E2E Verify' (user-upload__e2e-verify-*) cards on prod; this is how
they (and any future test/abuse upload) get cleanly removed. Full
pytest rc=0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/admin.py +57 -0
- backend/uploaded_docs.py +41 -0
- tests/test_uploaded_docs_prune.py +75 -0
|
@@ -1139,3 +1139,60 @@ async def admin_recommendation_history(
|
|
| 1139 |
"total": len(events),
|
| 1140 |
"snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 1141 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1139 |
"total": len(events),
|
| 1140 |
"snapshot_ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 1141 |
}
|
| 1142 |
+
|
| 1143 |
+
|
| 1144 |
+
# ---------------------------------------------------------------------------
|
| 1145 |
+
# #77 / #52-residual-5 β operator prune of persisted uploaded marketplace
|
| 1146 |
+
# docs. Persisted uploads have NO TTL by design (they are public catalogue
|
| 1147 |
+
# cards); this is the sanctioned way to remove a test/abuse upload. Removes
|
| 1148 |
+
# the persisted dir (path-safety-guarded in backend.uploaded_docs) + the
|
| 1149 |
+
# doc's chunks from the GLOBAL policies collection + busts the marketplace
|
| 1150 |
+
# grade / corpus-pdf caches so /api/policies/all drops the card at once.
|
| 1151 |
+
# ---------------------------------------------------------------------------
|
| 1152 |
+
class UploadedDocPruneRequest(BaseModel):
|
| 1153 |
+
policy_id: Optional[str] = None
|
| 1154 |
+
prefix: Optional[str] = None # e.g. "user-upload__e2e-verify" β bulk
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
@router.post("/api/admin/uploaded-docs/prune")
|
| 1158 |
+
async def admin_prune_uploaded_docs(
|
| 1159 |
+
body: UploadedDocPruneRequest,
|
| 1160 |
+
request: Request,
|
| 1161 |
+
x_admin_password: Optional[str] = Header(default=None, alias="X-Admin-Password"),
|
| 1162 |
+
):
|
| 1163 |
+
_check_admin(request, x_admin_password)
|
| 1164 |
+
if not body.policy_id and body.prefix is None:
|
| 1165 |
+
raise HTTPException(status_code=400, detail="provide policy_id or prefix")
|
| 1166 |
+
from backend import uploaded_docs as _udocs
|
| 1167 |
+
|
| 1168 |
+
res = _udocs.prune_persisted_upload(body.policy_id, prefix=body.prefix)
|
| 1169 |
+
chroma_deleted: list[str] = []
|
| 1170 |
+
chroma_errors: list[str] = []
|
| 1171 |
+
if res.get("removed"):
|
| 1172 |
+
try:
|
| 1173 |
+
from rag.ingest import get_chroma_collection
|
| 1174 |
+
_col = get_chroma_collection()
|
| 1175 |
+
for pid in res["removed"]:
|
| 1176 |
+
try:
|
| 1177 |
+
_col.delete(where={"policy_id": pid})
|
| 1178 |
+
chroma_deleted.append(pid)
|
| 1179 |
+
except Exception as e: # noqa: BLE001 β surface, don't swallow
|
| 1180 |
+
chroma_errors.append(f"{pid}: {type(e).__name__}: {e}")
|
| 1181 |
+
except Exception as e: # noqa: BLE001
|
| 1182 |
+
chroma_errors.append(f"collection: {type(e).__name__}: {e}")
|
| 1183 |
+
cache_bust = "ok"
|
| 1184 |
+
try:
|
| 1185 |
+
import backend.main as _m
|
| 1186 |
+
_m._CORPUS_PDF_IDX = None
|
| 1187 |
+
_mg = getattr(_m, "_MG_CACHE", None)
|
| 1188 |
+
if isinstance(_mg, dict):
|
| 1189 |
+
_mg["sig"] = None
|
| 1190 |
+
_mg["index"] = None
|
| 1191 |
+
except Exception as e: # noqa: BLE001
|
| 1192 |
+
cache_bust = f"{type(e).__name__}: {e}"
|
| 1193 |
+
return {
|
| 1194 |
+
**res,
|
| 1195 |
+
"chroma_deleted": chroma_deleted,
|
| 1196 |
+
"chroma_errors": chroma_errors,
|
| 1197 |
+
"cache_bust": cache_bust,
|
| 1198 |
+
}
|
|
@@ -60,6 +60,7 @@ import hashlib
|
|
| 60 |
import json
|
| 61 |
import logging
|
| 62 |
import re
|
|
|
|
| 63 |
import time
|
| 64 |
from pathlib import Path
|
| 65 |
from typing import Any, Optional
|
|
@@ -99,6 +100,46 @@ def _doc_dir(policy_id: str) -> Path:
|
|
| 99 |
return uploaded_docs_dir() / safe
|
| 100 |
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
# ---------------------------------------------------------------------------
|
| 103 |
# Heuristic field extraction -> curated-facts-shaped record
|
| 104 |
#
|
|
|
|
| 60 |
import json
|
| 61 |
import logging
|
| 62 |
import re
|
| 63 |
+
import shutil
|
| 64 |
import time
|
| 65 |
from pathlib import Path
|
| 66 |
from typing import Any, Optional
|
|
|
|
| 100 |
return uploaded_docs_dir() / safe
|
| 101 |
|
| 102 |
|
| 103 |
+
def prune_persisted_upload(
|
| 104 |
+
policy_id: Optional[str] = None, *, prefix: Optional[str] = None
|
| 105 |
+
) -> dict:
|
| 106 |
+
"""Operator/abuse prune of persisted uploaded doc(s) (#52 residual #5,
|
| 107 |
+
#77). Pass an exact `policy_id` OR a `prefix` (e.g.
|
| 108 |
+
'user-upload__e2e-verify' to bulk-remove test/abuse cards).
|
| 109 |
+
|
| 110 |
+
HARD GUARDRAIL: only ever removes a directory that is a DIRECT CHILD of
|
| 111 |
+
UPLOADED_DOCS_DIR β it can never touch rag/corpus, 40-data, or any
|
| 112 |
+
curated/extracted data. A path-safety violation RAISES (must surface;
|
| 113 |
+
a silent no-op here would be forbidden by the no-silent-failure rule).
|
| 114 |
+
Returns {removed:[ids], skipped:[ids-not-present], root}.
|
| 115 |
+
"""
|
| 116 |
+
root = uploaded_docs_dir().resolve()
|
| 117 |
+
targets: list[str] = []
|
| 118 |
+
if policy_id:
|
| 119 |
+
targets.append(policy_id)
|
| 120 |
+
if prefix is not None:
|
| 121 |
+
pfx = re.sub(r"[^a-zA-Z0-9_.\-]+", "-", prefix).strip("-")
|
| 122 |
+
if not pfx:
|
| 123 |
+
raise RuntimeError("prune prefix is empty after sanitisation")
|
| 124 |
+
for d in sorted(root.glob("*")):
|
| 125 |
+
if d.is_dir() and d.name.startswith(pfx):
|
| 126 |
+
targets.append(d.name)
|
| 127 |
+
removed: list[str] = []
|
| 128 |
+
skipped: list[str] = []
|
| 129 |
+
for pid in dict.fromkeys(targets): # dedupe, preserve order
|
| 130 |
+
ddir = _doc_dir(pid).resolve()
|
| 131 |
+
if ddir == root or root not in ddir.parents:
|
| 132 |
+
raise RuntimeError(
|
| 133 |
+
f"refusing to prune outside uploaded-docs root: {pid!r}"
|
| 134 |
+
)
|
| 135 |
+
if not ddir.exists():
|
| 136 |
+
skipped.append(pid)
|
| 137 |
+
continue
|
| 138 |
+
shutil.rmtree(ddir)
|
| 139 |
+
removed.append(pid)
|
| 140 |
+
return {"removed": removed, "skipped": skipped, "root": str(root)}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
# ---------------------------------------------------------------------------
|
| 144 |
# Heuristic field extraction -> curated-facts-shaped record
|
| 145 |
#
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#77 β prune_persisted_upload: removes a persisted uploaded doc, is
|
| 2 |
+
path-safety-guarded (can NEVER escape UPLOADED_DOCS_DIR), supports exact
|
| 3 |
+
id + prefix, and never silently no-ops a traversal attempt."""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
_REPO = Path(__file__).resolve().parent.parent
|
| 12 |
+
if str(_REPO) not in sys.path:
|
| 13 |
+
sys.path.insert(0, str(_REPO))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _seed(root: Path, pid: str) -> Path:
|
| 17 |
+
from backend import uploaded_docs as u
|
| 18 |
+
d = u._doc_dir(pid)
|
| 19 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
(d / "record.json").write_text('{"policy_id": "%s"}' % pid)
|
| 21 |
+
(d / "meta.json").write_text("{}")
|
| 22 |
+
return d
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_prune_exact_and_prefix(tmp_path, monkeypatch):
|
| 26 |
+
from backend.config import settings
|
| 27 |
+
from backend import uploaded_docs as u
|
| 28 |
+
monkeypatch.setattr(settings, "UPLOADED_DOCS_DIR", tmp_path)
|
| 29 |
+
|
| 30 |
+
a = _seed(tmp_path, "user-upload__e2e-verify-a__zzz")
|
| 31 |
+
b = _seed(tmp_path, "user-upload__e2e-verify-b__zzz")
|
| 32 |
+
keep = _seed(tmp_path, "user-upload__real-user__myplan")
|
| 33 |
+
assert a.exists() and b.exists() and keep.exists()
|
| 34 |
+
|
| 35 |
+
# exact id
|
| 36 |
+
r1 = u.prune_persisted_upload("user-upload__e2e-verify-a__zzz")
|
| 37 |
+
assert r1["removed"] == ["user-upload__e2e-verify-a__zzz"]
|
| 38 |
+
assert not a.exists() and b.exists() and keep.exists()
|
| 39 |
+
|
| 40 |
+
# prefix (bulk) β only e2e-verify-*, never the real user doc
|
| 41 |
+
r2 = u.prune_persisted_upload(prefix="user-upload__e2e-verify")
|
| 42 |
+
assert "user-upload__e2e-verify-b__zzz" in r2["removed"]
|
| 43 |
+
assert not b.exists()
|
| 44 |
+
assert keep.exists(), "prefix prune must NOT touch non-matching docs"
|
| 45 |
+
|
| 46 |
+
# absent id β skipped, not error, not silent
|
| 47 |
+
r3 = u.prune_persisted_upload("user-upload__does-not-exist")
|
| 48 |
+
assert r3["removed"] == [] and r3["skipped"] == ["user-upload__does-not-exist"]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_prune_path_traversal_raises(tmp_path, monkeypatch):
|
| 52 |
+
"""A traversal attempt MUST raise, never delete outside the root."""
|
| 53 |
+
from backend.config import settings
|
| 54 |
+
from backend import uploaded_docs as u
|
| 55 |
+
monkeypatch.setattr(settings, "UPLOADED_DOCS_DIR", tmp_path)
|
| 56 |
+
outside = tmp_path.parent / "DO_NOT_DELETE"
|
| 57 |
+
outside.mkdir(exist_ok=True)
|
| 58 |
+
(outside / "keep.txt").write_text("safe")
|
| 59 |
+
# _doc_dir sanitises slashes/dots, so the dir resolves INSIDE root and
|
| 60 |
+
# is simply "not present" β skipped; the outside dir is untouched.
|
| 61 |
+
r = u.prune_persisted_upload("../../DO_NOT_DELETE")
|
| 62 |
+
assert r["removed"] == []
|
| 63 |
+
assert outside.exists() and (outside / "keep.txt").exists()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_empty_prefix_rejected(tmp_path, monkeypatch):
|
| 67 |
+
from backend.config import settings
|
| 68 |
+
from backend import uploaded_docs as u
|
| 69 |
+
monkeypatch.setattr(settings, "UPLOADED_DOCS_DIR", tmp_path)
|
| 70 |
+
with pytest.raises(RuntimeError):
|
| 71 |
+
u.prune_persisted_upload(prefix="///")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
raise SystemExit(pytest.main([__file__, "-v"]))
|