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:
@@ -81,7 +81,14 @@ from .plugins import plugin_manager
|
||||
# endpoints command claim/result long-poll callbacks) on the same per-plugin
|
||||
# collector key + managed-token check the ingest route uses, without importing
|
||||
# core collector internals. Additive name on the surface, minor bump.
|
||||
__contract_version__ = '0.24.0'
|
||||
# 0.25.0: added caller_holds(permission) to shopdb.api so a route can ask
|
||||
# whether THIS caller holds a permission without refusing the request. It is for
|
||||
# a surface that serves everyone but should only OFFER what the caller can use:
|
||||
# the endpoints services panel emitted Start/Stop to every reader because the
|
||||
# emission never consulted the caller, so a read-only viewer was shown controls
|
||||
# over a shop-floor PC that 403 on click. Guests are included, since a signed-out
|
||||
# visitor holds whatever the guest role was granted. Additive name, minor bump.
|
||||
__contract_version__ = '0.25.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -68,7 +68,8 @@ from shopdb.utils.responses import (
|
||||
from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_any_permission, require_role
|
||||
from shopdb.utils.authz import (require_permission, require_any_permission,
|
||||
require_role, caller_holds)
|
||||
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
@@ -331,6 +332,7 @@ __all__ = [
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_any_permission',
|
||||
'caller_holds',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
'authorized_service_token',
|
||||
|
||||
@@ -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