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.

View File

@@ -103,6 +103,22 @@ class EndpointsPlugin(BasePlugin):
"""
return [
('endpoints.view', 'View endpoint telemetry', 'endpoints'),
# The FLEET-WIDE list and dashboards, separate from endpoints.view,
# which covers reading ONE endpoint that the caller already reached
# through its PC page.
#
# They were one permission, and that 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 whole fleet,
# including every logged-in username on the floor, because the
# panels and the list were gated on the same string.
#
# It is deliberately NOT named '.view'. guest_grantable_permissions
# opens any '.view' or '.read' permission to the guest role, so this
# name is what keeps the fleet roster off the public surface while
# per-asset telemetry can still be granted.
('endpoints.fleet',
'View the fleet-wide endpoint list and dashboards', 'endpoints'),
# Issue an allowlisted command to an endpoint agent (for example the
# MTConnect toggle). Separate from endpoints.view because acting on a
# shop-floor PC is a heavier grant than reading its telemetry.
@@ -189,7 +205,7 @@ class EndpointsPlugin(BasePlugin):
'icon': 'activity',
'route': '/endpoints',
'position': 16,
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'section': 'assets',
},
]
@@ -340,7 +356,7 @@ class EndpointsPlugin(BasePlugin):
'endpoint': '/api/endpoints/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 22,
'map': {
@@ -358,7 +374,7 @@ class EndpointsPlugin(BasePlugin):
'endpoint': '/api/endpoints/dashboard/stability',
'render': 'exceptions',
'severity': 'critical',
'permission': 'endpoints.view',
'permission': 'endpoints.fleet',
'empty': 'hide',
'position': 16,
'map': {