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))
|
return bool(user.get("auto_search_enabled", True))
|
||||||
|
|
||||||
|
|
||||||
def _filter_snapshot_actions_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
|
def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
|
||||||
if _user_can_use_search_auto(user):
|
if not _user_can_use_search_auto(user):
|
||||||
return snapshot
|
|
||||||
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
|
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
|
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]:
|
def _quality_profile_id(value: Any) -> Optional[int]:
|
||||||
if isinstance(value, int):
|
if isinstance(value, int):
|
||||||
return value
|
return value
|
||||||
@@ -1766,7 +1778,7 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
|
|||||||
if client.configured():
|
if client.configured():
|
||||||
await _ensure_request_access(client, int(request_id), user)
|
await _ensure_request_access(client, int(request_id), user)
|
||||||
snapshot = await build_snapshot(request_id)
|
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")
|
@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)
|
_cache_set(f"request:{request_id}", fresh_request)
|
||||||
_refresh_recent_cache_from_db()
|
_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")
|
status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
|
||||||
message = f"Recheck complete. {status_label}."
|
message = f"Recheck complete. {status_label}."
|
||||||
await asyncio.to_thread(
|
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)
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
if client.configured():
|
if client.configured():
|
||||||
await _ensure_request_access(client, int(request_id), user)
|
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)
|
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(
|
async def request_history(
|
||||||
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_advanced_request_access(user)
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
if client.configured():
|
if client.configured():
|
||||||
@@ -2771,6 +2784,7 @@ async def request_history(
|
|||||||
async def request_actions(
|
async def request_actions(
|
||||||
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
request_id: str, limit: int = 10, user: Dict[str, str] = Depends(get_current_user)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_require_advanced_request_access(user)
|
||||||
runtime = get_runtime_settings()
|
runtime = get_runtime_settings()
|
||||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||||
if client.configured():
|
if client.configured():
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from backend.app import db
|
|||||||
from backend.app.auth import require_admin
|
from backend.app.auth import require_admin
|
||||||
from backend.app.config import settings
|
from backend.app.config import settings
|
||||||
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
|
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 auth as auth_router
|
||||||
from backend.app.routers import portal as portal_router
|
from backend.app.routers import portal as portal_router
|
||||||
from backend.app.routers import requests as requests_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})
|
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):
|
class RequestPresentationTests(unittest.TestCase):
|
||||||
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
|
def test_torrent_progress_keeps_tenths_for_live_updates(self) -> None:
|
||||||
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
self.assertEqual(_torrent_progress({"progress": 0.1344}), 13.4)
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ export default function RequestTimelinePage() {
|
|||||||
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
const [historySnapshots, setHistorySnapshots] = useState<SnapshotHistory[]>([])
|
||||||
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
const [historyActions, setHistoryActions] = useState<ActionHistory[]>([])
|
||||||
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
const [operationProgress, setOperationProgress] = useState<OperationProgress | null>(null)
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestId) return
|
if (!requestId) return
|
||||||
@@ -287,22 +288,32 @@ export default function RequestTimelinePage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const baseUrl = getApiBase()
|
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}/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()
|
clearToken()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
return
|
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) {
|
if (!snapshotResponse.ok) {
|
||||||
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
|
throw new Error(await readApiError(snapshotResponse, 'Unable to load this request.'))
|
||||||
}
|
}
|
||||||
const snapshotData = await snapshotResponse.json()
|
const snapshotData = await snapshotResponse.json()
|
||||||
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
|
if (!isSnapshotPayload(snapshotData)) throw new Error('Unable to load this request.')
|
||||||
setSnapshot(snapshotData)
|
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) {
|
if (historyResponse.ok) {
|
||||||
const historyData = await historyResponse.json()
|
const historyData = await historyResponse.json()
|
||||||
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
|
if (Array.isArray(historyData.snapshots)) setHistorySnapshots(historyData.snapshots)
|
||||||
@@ -311,6 +322,10 @@ export default function RequestTimelinePage() {
|
|||||||
const actionsData = await actionsResponse.json()
|
const actionsData = await actionsResponse.json()
|
||||||
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
|
if (Array.isArray(actionsData.actions)) setHistoryActions(actionsData.actions)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
setHistorySnapshots([])
|
||||||
|
setHistoryActions([])
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
|
setLoadError(error instanceof Error ? error.message : 'Unable to load this request.')
|
||||||
@@ -811,7 +826,7 @@ export default function RequestTimelinePage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="request-advanced">
|
{isAdmin && <section className="request-advanced">
|
||||||
<button type="button" className="request-advanced-toggle" aria-expanded={showDetails} onClick={() => setShowDetails((current) => !current)}>
|
<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><strong>Advanced details</strong><small>Service diagnostics, status history and recorded actions</small></span>
|
||||||
<span>{showDetails ? 'Hide' : 'Show'}</span>
|
<span>{showDetails ? 'Hide' : 'Show'}</span>
|
||||||
@@ -846,7 +861,7 @@ export default function RequestTimelinePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user