mirror of
https://github.com/usestrix/strix.git
synced 2026-10-28 06:23:07 +00:00
feat(viewer): show Past Runs without email verification
Browsing local scan history no longer requires the work-email + OTP step. The /api/runs list and individual other-run data now unlock on the per-process session capability alone (drops auth.is_verified from the gate); the session-cookie check is preserved so an exposed --host port still cannot enumerate or read history. Email verification remains required only for emailing encrypted reports (auth.py unchanged). Backend-only; the prebuilt SPA renders the unlocked payload, no rebuild needed. Renames build_runs_payload kwarg verified->unlocked and updates the two viewer integration tests to pin is_verified=False, proving the email term is no longer on the history path.
This commit is contained in:
parent
7e7e3e16db
commit
0a9b730da1
3 changed files with 46 additions and 44 deletions
|
|
@ -78,15 +78,17 @@ def run_list_entry(run_dir: Path) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def build_runs_payload(base_dir: Path, *, verified: bool) -> dict[str, Any]:
|
||||
"""The /api/runs payload. Gates the run list behind email verification.
|
||||
def build_runs_payload(base_dir: Path, *, unlocked: bool) -> dict[str, Any]:
|
||||
"""The /api/runs payload. Gates the run list behind the caller's authorization.
|
||||
|
||||
The count is always advertised so the UI can tease the history, but the
|
||||
entries only appear once the viewer is verified.
|
||||
``unlocked`` is the caller's access decision (today: holds this process's
|
||||
session capability). When locked, the count is still advertised so the UI can
|
||||
tease the history, but the entries only appear once unlocked. Email
|
||||
verification is no longer part of this decision.
|
||||
"""
|
||||
run_dirs = _iter_run_dirs(base_dir)
|
||||
count = len(run_dirs)
|
||||
if not verified:
|
||||
if not unlocked:
|
||||
return {"locked": True, "count": count, "runs": []}
|
||||
return {"locked": False, "count": count, "runs": [run_list_entry(d) for d in run_dirs]}
|
||||
|
||||
|
|
@ -235,13 +237,17 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
|
||||
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
|
||||
# The launched run is always viewable with no verification. The
|
||||
# cross-run history list (/api/runs) unlocks its entries only for a
|
||||
# caller that holds this process's session capability *and* is email
|
||||
# verified, so merely reaching an exposed --host port never leaks the
|
||||
# run list (the payload still advertises the count as a teaser).
|
||||
# cross-run history list (/api/runs) unlocks its entries for any
|
||||
# caller that holds this process's session capability, so merely
|
||||
# reaching an exposed --host port never leaks the run list (the
|
||||
# cookie is handed only to the browser this process bootstrapped).
|
||||
# Email verification is intentionally NOT required here: browsing
|
||||
# local run history is a local convenience, and gating it behind an
|
||||
# OTP was unnecessary friction. Verification still guards emailing an
|
||||
# encrypted report (see _handle_report_send).
|
||||
if path == "/api/runs":
|
||||
unlocked = self._has_session() and auth.is_verified()
|
||||
payload = build_runs_payload(state.base_dir, verified=unlocked)
|
||||
unlocked = self._has_session()
|
||||
payload = build_runs_payload(state.base_dir, unlocked=unlocked)
|
||||
self._send_json(HTTPStatus.OK, payload)
|
||||
return
|
||||
if path == "/api/capabilities":
|
||||
|
|
@ -261,17 +267,15 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
|
|||
return
|
||||
|
||||
# The launched run is always viewable. Any *other* run's data is part
|
||||
# of the gated history: it needs this process's session capability
|
||||
# (so merely reaching an exposed --host port is not enough) *and*
|
||||
# email verification -- otherwise knowing a run name would leak its
|
||||
# metadata, vulnerabilities, report, and transcript.
|
||||
if run_dir.resolve() != state.run_dir.resolve():
|
||||
if not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
if not auth.is_verified():
|
||||
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
|
||||
return
|
||||
# of the history: it needs this process's session capability (so
|
||||
# merely reaching an exposed --host port is not enough) -- knowing a
|
||||
# run name is not sufficient to leak its metadata, vulnerabilities,
|
||||
# report, or transcript. Email verification is intentionally NOT
|
||||
# required, matching the /api/runs list: once the list is browsable,
|
||||
# its entries must be openable without an OTP step.
|
||||
if run_dir.resolve() != state.run_dir.resolve() and not self._has_session():
|
||||
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
|
||||
return
|
||||
|
||||
if path == "/api/run":
|
||||
self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
|
||||
|
|
|
|||
|
|
@ -549,15 +549,16 @@ def test_report_send_rejects_live_run(tmp_path: Path, monkeypatch: pytest.Monkey
|
|||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_verification(
|
||||
def test_historical_run_data_requires_session_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: verified["value"])
|
||||
# Email verification is pinned False for the whole test to prove it is no
|
||||
# longer consulted for browsing history: only the session capability is.
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
|
||||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
|
|
@ -567,31 +568,27 @@ def test_historical_run_data_requires_verification(
|
|||
|
||||
cookie = _session_cookie(url, token)
|
||||
|
||||
# A different run needs the session capability first: a cookie-less
|
||||
# caller is forbidden even once the machine is verified.
|
||||
verified["value"] = True
|
||||
# A different run still needs the session capability first: a cookie-less
|
||||
# caller is forbidden.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 403
|
||||
|
||||
# With the cookie but not verified, the history gate returns 401.
|
||||
verified["value"] = False
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 401
|
||||
|
||||
# With both the cookie and verification, the historical run resolves.
|
||||
verified["value"] = True
|
||||
# With the cookie, the historical run resolves even though the machine is
|
||||
# NOT email-verified -- the email gate has been removed.
|
||||
assert _get_status(f"{url}/api/run?run=other", cookie=cookie) == 200
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_runs_list_requires_session_and_verification(
|
||||
def test_runs_list_requires_session_only(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: True)
|
||||
# Pinned unverified to prove the list unlocks on the session capability alone.
|
||||
monkeypatch.setattr("strix.interface.viewer.auth.is_verified", lambda: False)
|
||||
|
||||
def _runs(cookie: str | None) -> dict[str, object]:
|
||||
headers = {"Cookie": cookie} if cookie else {}
|
||||
|
|
@ -601,14 +598,13 @@ def test_runs_list_requires_session_and_verification(
|
|||
|
||||
httpd, url, token = serve(launched, open_browser=False)
|
||||
try:
|
||||
# A cookie-less caller (even with the machine verified) only sees the
|
||||
# teaser count, never the run entries.
|
||||
# A cookie-less caller only sees the teaser count, never the run entries.
|
||||
payload = _runs(None)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
# With the session cookie and verification, the entries unlock.
|
||||
# With the session cookie the entries unlock -- no email verification.
|
||||
payload = _runs(_session_cookie(url, token))
|
||||
assert payload["locked"] is False
|
||||
assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined]
|
||||
|
|
|
|||
|
|
@ -30,23 +30,25 @@ def _make_run(base: Path, name: str, *, severity: str = "high") -> Path:
|
|||
return run_dir
|
||||
|
||||
|
||||
def test_runs_payload_locked_when_unverified(tmp_path: Path) -> None:
|
||||
def test_runs_payload_locked_when_no_session(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha")
|
||||
_make_run(tmp_path, "beta")
|
||||
|
||||
payload = build_runs_payload(base, verified=False)
|
||||
# No session capability -> locked, but the count is still advertised.
|
||||
payload = build_runs_payload(base, unlocked=False)
|
||||
assert payload["locked"] is True
|
||||
assert payload["count"] == 2
|
||||
assert payload["runs"] == []
|
||||
|
||||
|
||||
def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
||||
def test_runs_payload_lists_when_unlocked(tmp_path: Path) -> None:
|
||||
base = tmp_path / "strix_runs"
|
||||
_make_run(tmp_path, "alpha", severity="critical")
|
||||
_make_run(tmp_path, "beta", severity="info")
|
||||
|
||||
payload = build_runs_payload(base, verified=True)
|
||||
# A session-holding caller unlocks the list -- no email verification needed.
|
||||
payload = build_runs_payload(base, unlocked=True)
|
||||
assert payload["locked"] is False
|
||||
assert payload["count"] == 2
|
||||
assert len(payload["runs"]) == 2
|
||||
|
|
@ -59,7 +61,7 @@ def test_runs_payload_lists_when_verified(tmp_path: Path) -> None:
|
|||
|
||||
|
||||
def test_runs_payload_empty_base(tmp_path: Path) -> None:
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", verified=True)
|
||||
payload = build_runs_payload(tmp_path / "strix_runs", unlocked=True)
|
||||
assert payload == {"locked": False, "count": 0, "runs": []}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue