Protect advanced request diagnostics for non-admins
This commit is contained in:
@@ -255,13 +255,25 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
|
||||
return bool(user.get("auto_search_enabled", True))
|
||||
|
||||
|
||||
def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
|
||||
if _user_can_use_search_auto(user):
|
||||
return snapshot
|
||||
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
|
||||
def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
|
||||
if not _user_can_use_search_auto(user):
|
||||
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
|
||||
if user.get("role") != "admin":
|
||||
# The standard request view is intentionally collaborative, but service payloads can
|
||||
# contain requester identities, internal URLs, download hashes and diagnostic errors.
|
||||
snapshot.timeline = []
|
||||
snapshot.raw = {}
|
||||
return snapshot
|
||||
|
||||
|
||||
def _require_advanced_request_access(user: Dict[str, Any]) -> None:
|
||||
if user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Advanced request details are available to administrators only",
|
||||
)
|
||||
|
||||
|
||||
def _quality_profile_id(value: Any) -> Optional[int]:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
@@ -1766,7 +1778,7 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
return _filter_snapshot_actions_for_user(snapshot, user)
|
||||
return _filter_snapshot_for_user(snapshot, user)
|
||||
|
||||
|
||||
@router.post("/{request_id}/actions/recheck")
|
||||
@@ -1825,7 +1837,7 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
|
||||
_cache_set(f"request:{request_id}", fresh_request)
|
||||
_refresh_recent_cache_from_db()
|
||||
|
||||
snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
|
||||
snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
|
||||
status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
|
||||
message = f"Recheck complete. {status_label}."
|
||||
await asyncio.to_thread(
|
||||
@@ -2403,7 +2415,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
await _ensure_request_access(client, int(request_id), user)
|
||||
snapshot = _filter_snapshot_actions_for_user(await build_snapshot(request_id), user)
|
||||
snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
|
||||
return triage_snapshot(snapshot)
|
||||
|
||||
|
||||
@@ -2759,6 +2771,7 @@ async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_curre
|
||||
async def request_history(
|
||||
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
||||
) -> dict:
|
||||
_require_advanced_request_access(user)
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
@@ -2771,6 +2784,7 @@ async def request_history(
|
||||
async def request_actions(
|
||||
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
||||
) -> dict:
|
||||
_require_advanced_request_access(user)
|
||||
runtime = get_runtime_settings()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if client.configured():
|
||||
|
||||
@@ -12,7 +12,7 @@ from backend.app import db
|
||||
from backend.app.auth import require_admin
|
||||
from backend.app.config import settings
|
||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
||||
from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from backend.app.models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
|
||||
from backend.app.routers import auth as auth_router
|
||||
from backend.app.routers import portal as portal_router
|
||||
from backend.app.routers import requests as requests_router
|
||||
@@ -223,6 +223,85 @@ class RequestCacheTests(unittest.TestCase):
|
||||
self.assertEqual(requests_router._cache_get(key), {"id": 123})
|
||||
|
||||
|
||||
class RequestVisibilityTests(unittest.TestCase):
|
||||
def test_non_admin_snapshot_excludes_advanced_identifying_data(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="3925",
|
||||
title="Example",
|
||||
timeline=[
|
||||
TimelineHop(
|
||||
service="Seerr",
|
||||
status="approved",
|
||||
details={"requestedBy": "viewer@example.com"},
|
||||
)
|
||||
],
|
||||
raw={
|
||||
"jellyseerr": {"requestedBy": {"email": "viewer@example.com"}},
|
||||
"qbittorrent": {"downloadIds": ["secret-hash"]},
|
||||
},
|
||||
actions=[
|
||||
ActionOption(
|
||||
id="search_releases",
|
||||
label="Search and choose a download",
|
||||
risk="safe",
|
||||
)
|
||||
],
|
||||
presentation={"status": {"label": "Needs attention"}},
|
||||
)
|
||||
|
||||
filtered = requests_router._filter_snapshot_for_user(
|
||||
snapshot, {"username": "helper", "role": "user"}
|
||||
)
|
||||
|
||||
self.assertEqual(filtered.timeline, [])
|
||||
self.assertEqual(filtered.raw, {})
|
||||
self.assertEqual([action.id for action in filtered.actions], ["search_releases"])
|
||||
self.assertEqual(filtered.presentation["status"]["label"], "Needs attention")
|
||||
|
||||
def test_admin_snapshot_retains_advanced_diagnostics(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="3925",
|
||||
title="Example",
|
||||
timeline=[TimelineHop(service="Seerr", status="approved")],
|
||||
raw={"jellyseerr": {"id": 3925}},
|
||||
)
|
||||
|
||||
filtered = requests_router._filter_snapshot_for_user(
|
||||
snapshot, {"username": "admin", "role": "admin"}
|
||||
)
|
||||
|
||||
self.assertEqual(len(filtered.timeline), 1)
|
||||
self.assertEqual(filtered.raw["jellyseerr"]["id"], 3925)
|
||||
|
||||
def test_non_admin_cannot_request_advanced_history(self) -> None:
|
||||
with self.assertRaises(HTTPException) as context:
|
||||
requests_router._require_advanced_request_access(
|
||||
{"username": "helper", "role": "user"}
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.status_code, 403)
|
||||
|
||||
def test_my_requests_cache_only_returns_signed_in_users_rows(self) -> None:
|
||||
previous = dict(requests_router._recent_cache)
|
||||
requests_router._recent_cache["items"] = [
|
||||
{"request_id": 100, "requested_by_id": 7, "requested_by_norm": "zak"},
|
||||
{"request_id": 101, "requested_by_id": 8, "requested_by_norm": "someone-else"},
|
||||
]
|
||||
try:
|
||||
rows = requests_router._get_recent_from_cache(
|
||||
requested_by_norm="zak",
|
||||
requested_by_id=7,
|
||||
limit=10,
|
||||
offset=0,
|
||||
since_iso=None,
|
||||
)
|
||||
finally:
|
||||
requests_router._recent_cache.clear()
|
||||
requests_router._recent_cache.update(previous)
|
||||
|
||||
self.assertEqual([row["request_id"] for row in rows], [100])
|
||||
|
||||
|
||||
class RequestPresentationTests(unittest.TestCase):
|
||||
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
|
||||
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
||||
|
||||
Reference in New Issue
Block a user