Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 14s
CI / migrations-mysql (push) Successful in 45s
CI / backend (push) Successful in 4m34s

Twelve endpoints routes shared one permission, which made a reasonable policy
impossible to express. A site that wants signed-out visitors to see live status
on a PC page had to hand them the entire fleet - including every logged-in
username on the floor - because the per-asset panels and the fleet-wide list
were gated on the same string.

endpoints.fleet now gates the list and the three dashboard routes.
endpoints.view keeps the per-asset reads: status, services, processes,
interfaces, events, command history.

The name is load-bearing. guest_grantable_permissions() opens any '.view' or
'.read' permission to the guest role, so calling this one 'endpoints.fleet'
rather than 'endpoints.fleetview' is what keeps the roster off the public
surface while per-asset telemetry stays grantable. Verified both ways.

Also: the services panel emitted Start/Stop row actions to every reader,
because row_actions_for() never consulted the caller. Clicking was always
refused server-side, so nothing was exploitable, but showing a read-only viewer
controls over a shop-floor PC is the wrong signal - and it stops being cosmetic
the moment a site grants that panel to guests.

That needed a contract addition rather than a local check: caller_holds() on
shopdb.api answers whether THIS caller holds a permission without refusing the
request, which is the complement to require_permission for a surface that
serves everyone but should only offer what the caller can act on. Guests are
included, since a signed-out visitor holds whatever the guest role was granted.
Reaching into shopdb.utils.authz instead would repeat the ADR-002 violation the
contract gate caught earlier in this branch. Contract 0.24.0 -> 0.25.0.

DEPLOYMENT: endpoints.fleet is a new permission. Run 'flask seed permissions'
and grant it to the roles that should keep seeing the Fleet page. Until then
that page is admin-only, since admins bypass every check - a safe direction,
but not an automatic one.

Full suite 2124 passed, naming and contract gates green, map and spec
regenerated.
This commit is contained in:
cproudlock
2026-08-28 21:14:27 -04:00
parent 4ef22b6579
commit 54f0857e13
7 changed files with 72 additions and 13 deletions

View File

@@ -16,7 +16,8 @@ from flask_jwt_extended import current_user
from shopdb.api import (
db, Asset, Setting, success_response, error_response, paginated_response,
ErrorCodes, get_pagination_params, paginate_query, require_permission,
require_role, mint_service_token, list_service_tokens, revoke_service_tokens,
require_role, caller_holds, mint_service_token, list_service_tokens,
revoke_service_tokens,
AuditLog, collector_authorized,
)
@@ -72,7 +73,7 @@ def _format_uptime(seconds):
# =============================================================================
@endpoints_bp.route('', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def list_endpoints():
"""List reported endpoint states.
@@ -408,6 +409,8 @@ def panel_services(asset_id: int):
if not state or not state.servicesjson:
return success_response([])
maycommand = caller_holds('endpoints.command')
try:
services = json.loads(state.servicesjson)
except (TypeError, ValueError):
@@ -429,7 +432,11 @@ def panel_services(asset_id: int):
'statelabel': 'Running' if running else (raw.title() or 'Unknown'),
'statecolor': 'green' if running else ('red' if raw else 'gray'),
}
targets = row_actions_for(app, kind, name)
# Only offer Start/Stop to a caller who could actually run it. The
# emission never consulted the caller, so a read-only viewer was shown
# controls on a shop-floor PC that 403 on click - wrong signal, and it
# gets worse the moment guests can read this panel.
targets = row_actions_for(app, kind, name) if maycommand else None
if targets:
row['action'] = {
'running': running,
@@ -512,7 +519,7 @@ def panel_interfaces(asset_id: int):
# =============================================================================
@endpoints_bp.route('/dashboard/summary', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_summary():
"""High-level fleet counts for the endpoints dashboard."""
total = db.session.query(EndpointState).join(
@@ -551,7 +558,7 @@ def dashboard_summary():
@endpoints_bp.route('/dashboard/quiet', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_quiet():
"""Endpoints that reported before, then went quiet past the window.
@@ -586,7 +593,7 @@ def dashboard_quiet():
@endpoints_bp.route('/dashboard/stability', methods=['GET'])
@require_permission('endpoints.view')
@require_permission('endpoints.fleet')
def dashboard_stability():
"""Endpoints with recent crash/shutdown/corruption events.