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.
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""Split operator report viewing out of endpoints.view.
|
|
|
|
Operator reports carry a screenshot of whatever was on the operator's screen
|
|
when they filed the report. That is an uncontrolled human capture - it can
|
|
include part numbers, customer names, email, or anything else that happened to
|
|
be visible - whereas 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 their own grant.
|
|
|
|
Backfill preserves access rather than revoking it: every user who can see
|
|
endpoints today gets the new grant, so nobody loses a panel they were already
|
|
using. Tightening from there is an administrative decision made per account in
|
|
the user editor, not something this migration should force. The grant is a
|
|
per-user direct permission (endpoints.operatorreports is in the scope
|
|
denylist), so the backfill writes userpermissions rows, not role rows.
|
|
|
|
Revision ID: 7d47_endpoints_operatorreports
|
|
Revises: 7d46_nest_app_packages
|
|
"""
|
|
from alembic import op
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
revision = '7d47_endpoints_operatorreports'
|
|
down_revision = '7d46_nest_app_packages'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
NEW_PERMISSION = (
|
|
'endpoints.operatorreports',
|
|
'View operator-submitted reports and their screenshots',
|
|
'endpoints',
|
|
)
|
|
|
|
SOURCE_PERMISSION = 'endpoints.view'
|
|
|
|
|
|
def _tables_present(bind):
|
|
inspector = sa.inspect(bind)
|
|
tables = set(inspector.get_table_names())
|
|
return {'users', 'roles', 'permissions', 'rolepermissions',
|
|
'userpermissions', 'userroles'} <= tables
|
|
|
|
|
|
def upgrade():
|
|
bind = op.get_bind()
|
|
if not _tables_present(bind):
|
|
return
|
|
|
|
name, description, category = NEW_PERMISSION
|
|
# Guarded in Python so the statement stays portable: MySQL's FROM DUAL
|
|
# idiom does not run on SQLite, which the test suite uses.
|
|
exists = bind.execute(sa.text(
|
|
'SELECT 1 FROM permissions WHERE name = :name'),
|
|
{'name': name}).first()
|
|
if not exists:
|
|
bind.execute(sa.text(
|
|
'INSERT INTO permissions (name, description, category) '
|
|
'VALUES (:name, :description, :category)'),
|
|
{'name': name, 'description': description, 'category': category})
|
|
|
|
# Preserve access for anyone who reaches operator reports today, which is
|
|
# anyone holding endpoints.view through a role. NOT EXISTS keeps the
|
|
# migration re-runnable and avoids duplicate rows for users in two
|
|
# qualifying roles.
|
|
bind.execute(sa.text(
|
|
'INSERT INTO userpermissions (userid, permissionid) '
|
|
'SELECT DISTINCT ur.userid, new.permissionid '
|
|
'FROM userroles ur '
|
|
'JOIN rolepermissions rp ON rp.roleid = ur.roleid '
|
|
'JOIN permissions old ON old.permissionid = rp.permissionid '
|
|
'JOIN permissions new ON new.name = :newname '
|
|
'WHERE old.name = :oldname '
|
|
'AND NOT EXISTS (SELECT 1 FROM userpermissions existing '
|
|
' WHERE existing.userid = ur.userid '
|
|
' AND existing.permissionid = new.permissionid)'),
|
|
{'newname': name, 'oldname': SOURCE_PERMISSION})
|
|
|
|
|
|
def downgrade():
|
|
bind = op.get_bind()
|
|
if not _tables_present(bind):
|
|
return
|
|
|
|
name = NEW_PERMISSION[0]
|
|
bind.execute(sa.text(
|
|
'DELETE FROM userpermissions WHERE permissionid IN '
|
|
'(SELECT permissionid FROM permissions WHERE name = :name)'),
|
|
{'name': name})
|
|
bind.execute(sa.text(
|
|
'DELETE FROM rolepermissions WHERE permissionid IN '
|
|
'(SELECT permissionid FROM permissions WHERE name = :name)'),
|
|
{'name': name})
|
|
bind.execute(sa.text(
|
|
'DELETE FROM permissions WHERE name = :name'), {'name': name})
|