Split the fleet roster from per-endpoint reads, and stop offering controls nobody can use
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:
@@ -86,6 +86,32 @@ def guestholds(permission_name: str) -> bool:
|
||||
return any(p.name == permission_name for p in role.permissions)
|
||||
|
||||
|
||||
def caller_holds(permission_name: str) -> bool:
|
||||
"""Whether THIS caller holds a permission, without refusing the request.
|
||||
|
||||
require_permission answers the same question by returning 401/403. This
|
||||
answers it in the body, for the case where a route serves everyone but
|
||||
should only OFFER something to the callers who can use it.
|
||||
|
||||
The endpoints services panel is the example that prompted it: it emitted
|
||||
Start/Stop buttons for every reader, because the emission never consulted
|
||||
the caller. Clicking them 403s, so nothing was exploitable, but showing a
|
||||
read-only viewer controls over a shop-floor PC is the wrong signal - and it
|
||||
gets worse the moment a site grants that panel to guests.
|
||||
|
||||
Guests are included on purpose: a signed-out visitor holds whatever the
|
||||
guest role was granted, so the answer must come from the same place
|
||||
require_permission gets it.
|
||||
"""
|
||||
user = currentuser()
|
||||
if user is None:
|
||||
return guestholds(permission_name)
|
||||
try:
|
||||
return bool(user.haspermission(permission_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
"""Gate a route behind a single permission. Admin role bypasses.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user