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
|
||||
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)
|
||||
|
||||
@@ -275,6 +275,7 @@ export default function RequestTimelinePage() {
|
||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestId) return
|
||||
@@ -287,22 +288,32 @@ export default function RequestTimelinePage() {
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const [snapshotResponse, historyResponse, actionsResponse] = await Promise.all([
|
||||
const [meResponse, snapshotResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/me`),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/snapshot`),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
|
||||
])
|
||||
if ([snapshotResponse, historyResponse, actionsResponse].some((response) => response.status === 401)) {
|
||||
if ([meResponse, snapshotResponse].some((response) => response.status === 401)) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!meResponse.ok) {
|
||||
throw new Error('Unable to verify your request access.')
|
||||
}
|
||||
const me = await meResponse.json()
|
||||
const viewerIsAdmin = me?.role === 'admin'
|
||||
setIsAdmin(viewerIsAdmin)
|
||||
if (!snapshotResponse.ok) {
|
||||
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
|
||||
}
|
||||
const snapshotData = await snapshotResponse.json()
|
||||
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
|
||||
setSnapshot(snapshotData)
|
||||
if (viewerIsAdmin) {
|
||||
const [historyResponse, actionsResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/requests/${requestId}/history?limit=10`),
|
||||
authFetch(`${baseUrl}/requests/${requestId}/actions?limit=10`),
|
||||
])
|
||||
if (historyResponse.ok) {
|
||||
const historyData = await historyResponse.json()
|
||||
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
|
||||
@@ -311,6 +322,10 @@ export default function RequestTimelinePage() {
|
||||
const actionsData = await actionsResponse.json()
|
||||
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
|
||||
}
|
||||
} else {
|
||||
setHistorySnapshots([])
|
||||
setHistoryActions([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
|
||||
@@ -811,7 +826,7 @@ export default function RequestTimelinePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="request-advanced">
|
||||
{isAdmin && <section className="request-advanced">
|
||||
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
|
||||
<span><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
|
||||
<span>{showDetails ? 'Hide' : 'Show'}</span>
|
||||
@@ -846,7 +861,7 @@ export default function RequestTimelinePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</section>}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user