feat(auth): share links open only their own widgets; unmarked routes are closed (#5033)
#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:
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import secrets
|
||||
from quart import Blueprint, current_app, jsonify, request, Response, render_template, redirect, url_for, session
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.auth.middleware import public_route, require_role
|
||||
from steward.models.users import UserRole
|
||||
from sqlalchemy import select, func, or_, and_
|
||||
from datetime import timedelta
|
||||
@@ -166,6 +166,7 @@ def _error(status: int, code: str, detail: str | None = None) -> tuple[Any, int]
|
||||
|
||||
|
||||
@host_agent_bp.post("/ingest")
|
||||
@public_route("the agent authenticates with its bearer token")
|
||||
async def ingest():
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
@@ -313,6 +314,7 @@ def _agent_version() -> str:
|
||||
|
||||
|
||||
@host_agent_bp.get("/install.sh")
|
||||
@public_route("fetched by curl on the host being enrolled; carries an enrolment token")
|
||||
async def install_script():
|
||||
token = request.args.get("token", "")
|
||||
if not token:
|
||||
@@ -344,6 +346,7 @@ async def install_script():
|
||||
|
||||
|
||||
@host_agent_bp.get("/agent.py")
|
||||
@public_route("fetched by the installer and by agent self-update; holds no secrets")
|
||||
async def agent_source():
|
||||
try:
|
||||
body = AGENT_SOURCE_PATH.read_text(encoding="utf-8")
|
||||
@@ -470,6 +473,7 @@ async def _fleet_rows(session) -> list[dict]:
|
||||
|
||||
|
||||
@host_agent_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget_table():
|
||||
"""Fleet-glance dashboard widget: one row per monitored host, latest metrics."""
|
||||
async with current_app.db_sessionmaker() as session:
|
||||
@@ -478,6 +482,7 @@ async def widget_table():
|
||||
|
||||
|
||||
@host_agent_bp.get("/widget/history")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget_history():
|
||||
host_id = request.args.get("host_id", "")
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user