Gate operator reports behind their own grant
Operator report screenshots are an uncontrolled capture of whatever was on the operator's screen when they filed the report, so they can carry part numbers, customer names, or other shopfloor content. The rest of endpoints.view is uniform machine-collected telemetry. Reading a PC's CPU load does not imply a right to read someone's screen, so the reports and their images move to endpoints.operatorreports. The image route is the real control point, not the panel: the panel only decides whether a tab is drawn, while the bytes stay reachable by URL to anyone who can guess an id. Both GET routes now require the new grant. Migration 7d47 backfills the grant to everyone holding endpoints.view so nobody loses a panel they were already using; tightening from there is a per-account decision in the user editor. The grant is in the scope denylist, so it is a per-user direct grant and Reporter cannot hold it. Also adds test_user_editor_checkboxes_match_the_server_action_list. The SCOPE_ENDPOINT_ACTIONS comment claimed a sync guard kept the Vue checkbox list aligned with the Python tuple, but no such test existed. The lists had already drifted: endpoints.operatorreports reached a running container with no way to grant it through the UI. The guard catches drift in both directions - a name only in Python ships the feature dark, and a name only in Vue is worse, since the admin ticks a box and believes access was granted while the server refuses to store it.
This commit is contained in:
@@ -3,9 +3,11 @@
|
||||
The tray captures a screenshot and a free-text note; the agent posts them to
|
||||
POST /api/endpoints/operatorreports on the collector key. The bytes land on
|
||||
disk under the operator-reports uploads dir and the row holds metadata. The
|
||||
human-facing panel and image-serve routes are JWT + endpoints.view gated, and
|
||||
the serve route carries the inert-upload headers. A retention sweep purges rows
|
||||
and files older than the configured window.
|
||||
human-facing panel and image-serve routes are JWT + endpoints.operatorreports
|
||||
gated - deliberately NOT endpoints.view, because the screenshot is an
|
||||
uncontrolled capture of the operator's screen and reading telemetry does not
|
||||
imply a right to see it - and the serve route carries the inert-upload headers.
|
||||
A retention sweep purges rows and files older than the configured window.
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -283,3 +285,101 @@ def test_upload_refuses_an_image_over_the_cap(client, db, app):
|
||||
assert 'limit' in response.get_json()['data']['error']['message']
|
||||
assert OperatorReport.query.filter_by(
|
||||
reportedhostname='OPREPORT-HUGE').first() is None
|
||||
|
||||
|
||||
def _viewer_headers(client, db, permissions, username):
|
||||
"""Log in a user holding exactly `permissions` as direct grants.
|
||||
|
||||
Deliberately direct grants rather than a role: endpoints.operatorreports is
|
||||
in the scope denylist, so a role can never confer it and a role-based
|
||||
fixture could not express the case these tests need.
|
||||
"""
|
||||
from shopdb.core.models import Permission, User
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
user = User(username=username, email=f'{username}@test.local',
|
||||
passwordhash=generate_password_hash('testpass'))
|
||||
for name in permissions:
|
||||
perm = Permission.query.filter_by(name=name).first()
|
||||
if perm is None:
|
||||
perm = Permission(name=name, description=name,
|
||||
category='endpoints')
|
||||
db.session.add(perm)
|
||||
db.session.flush()
|
||||
user.directpermissions.append(perm)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
response = client.post('/api/auth/login',
|
||||
json={'username': username, 'password': 'testpass'})
|
||||
assert response.status_code == 200, response.get_json()
|
||||
token = response.get_json()['data']['access_token']
|
||||
return {'Authorization': f'Bearer {token}'}
|
||||
|
||||
|
||||
def _report_for(client, db, app, hostname):
|
||||
"""Upload one report on the collector key and return its row."""
|
||||
from plugins.endpoints.models import OperatorReport
|
||||
|
||||
asset = _computer(db, hostname)
|
||||
client.post(UPLOAD_URL,
|
||||
data={'hostname': hostname, 'details': 'x',
|
||||
'screenshot': (io.BytesIO(_png_bytes()), 'shot.png')},
|
||||
content_type='multipart/form-data',
|
||||
headers=_collector_headers(app))
|
||||
return OperatorReport.query.filter_by(assetid=asset.assetid).first()
|
||||
|
||||
|
||||
def test_endpoints_view_alone_cannot_fetch_a_report_screenshot(
|
||||
client, db, app):
|
||||
"""The whole point of the split: telemetry access is not screenshot access.
|
||||
|
||||
The screenshot is an uncontrolled capture of the operator's screen, so a
|
||||
user who can read a PC's telemetry must NOT get the image for free. If this
|
||||
regresses, sensitive shopfloor content is exposed to every endpoint viewer.
|
||||
"""
|
||||
report = _report_for(client, db, app, 'OPREPORT-VIEWONLY')
|
||||
headers = _viewer_headers(client, db, ['endpoints.view'], 'telemetryonly')
|
||||
|
||||
response = client.get(IMAGE_URL.format(report.operatorreportid),
|
||||
headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_endpoints_view_alone_cannot_list_operator_reports(client, db, app):
|
||||
"""The panel route leaks the report text and filenames, so it is gated too.
|
||||
|
||||
Hiding the tab in the UI is not a control; the route must refuse directly.
|
||||
"""
|
||||
report = _report_for(client, db, app, 'OPREPORT-PANELDENY')
|
||||
headers = _viewer_headers(client, db, ['endpoints.view'], 'panelviewonly')
|
||||
|
||||
response = client.get(PANEL_URL.format(report.assetid), headers=headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_the_operatorreports_grant_opens_both_routes(client, db, app):
|
||||
"""The positive case: the dedicated grant is sufficient on its own."""
|
||||
report = _report_for(client, db, app, 'OPREPORT-GRANTED')
|
||||
headers = _viewer_headers(
|
||||
client, db, ['endpoints.view', 'endpoints.operatorreports'],
|
||||
'reportviewer')
|
||||
|
||||
image = client.get(IMAGE_URL.format(report.operatorreportid),
|
||||
headers=headers)
|
||||
assert image.status_code == 200
|
||||
|
||||
panel = client.get(PANEL_URL.format(report.assetid), headers=headers)
|
||||
assert panel.status_code == 200
|
||||
reports = panel.get_json()['data']['reports']
|
||||
assert len(reports) == 1
|
||||
|
||||
|
||||
def test_operator_reports_panel_declares_its_permission():
|
||||
"""The panel carries the grant so the tab is not advertised without it."""
|
||||
from plugins.endpoints.plugin import EndpointsPlugin
|
||||
|
||||
panels = EndpointsPlugin().get_asset_panels()
|
||||
panel = next(p for p in panels
|
||||
if p.get('render') == 'operatorreports')
|
||||
assert panel['permission'] == 'endpoints.operatorreports'
|
||||
|
||||
Reference in New Issue
Block a user