feat(auth): share links open only their own widgets; unmarked routes are closed (#5033)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 45s
CI / unit (push) Successful in 1m4s
CI / publish (push) Successful in 53s

#5033 "Share links open only their own dashboard; every route without a
role is on an explicit allow-list". Any live share token in ?s= made the
caller a viewer on every viewer route — host pages, container logs, any
dashboard — and the host-agent widget routes had no role check at all.

- steward/dashboard/share.py: a share token opens, read-only, exactly the
  widget fragment URLs its own dashboard loads — same path, same
  configured parameters; only the time window (hours/range) may change.
- require_role marks its view; public_route marks the deliberately open
  ones (login, setup, health, the share page, agent ingest, install.sh,
  agent.py) with the reason. A before_request refuses any view with
  neither, so a route that forgets its decorator is closed, not open.
- /plugins/host_agent/widget and /widget/history now require viewer.
- The history widget's range buttons keep the rest of the query (and so
  a share link's token) instead of rebuilding it; the uptime widget's
  "Full SLA" link shows to logged-in users, not to share viewers.
- tests/test_route_access.py: a source scan that fails on any unmarked
  route in core or bundled plugins, the default-deny refusal, and the
  share matcher's cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:51:29 -04:00
co-authored by Claude Opus 5.5
parent d188a673a6
commit 6a1bc437c9
10 changed files with 195 additions and 27 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ from datetime import datetime, timezone, timedelta
from urllib.parse import urlencode
from quart import Blueprint, render_template, current_app, abort, redirect, request, session
from sqlalchemy import select, func, update
from steward.auth.middleware import require_role, current_user_id
from steward.auth.middleware import public_route, require_role, current_user_id
from steward.models.users import UserRole
from steward.models.hosts import Host
from steward.models.dashboard import Dashboard, DashboardWidget, DashboardShareToken
@@ -513,6 +513,7 @@ async def revoke_share_token(dash_id: int, token_id: int):
# ── Public share view (no login required) ────────────────────────────────────
@dashboard_bp.get("/share/<raw_token>")
@public_route("a share link; the token is the credential")
async def public_share(raw_token: str):
token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
now = datetime.now(timezone.utc)