Files
shopdb-flask/migrations/versions/7d42_split_endpoints_deploy.py
benjamin.baker 627d3f5ff5 Add endpoint locks and split endpoints.deploy into three grants
Endpoint locks
--------------
Adds EndpointLock so an admin can hold an endpoint against automated and
operator action, with migration 0018 and enforcement in the enqueue path
rather than only in the UI, so a lock cannot be bypassed by calling the
API directly.

Split endpoints.deploy
----------------------
endpoints.deploy bundled three actions of very different risk behind a
single name:

	scheduledtask_run -> starts a task already registered and allowlisted
	update_app        -> runs a pinned installer
	run_script        -> arbitrary code as SYSTEM unless the descriptor
						 pins a user context

Because they shared one grant there was no way to let an operator push an
application update without also handing them arbitrary SYSTEM code
execution on every endpoint they could reach. The permission name
described the lightest of the three while silently carrying the heaviest.

commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own
permission (endpoints.corptools, endpoints.updateapp,
endpoints.runscript). The enqueue route reads that table, so no route
logic changed. Migration 7d42 preserves existing access exactly: anyone
holding endpoints.deploy receives all three replacements.
2026-09-07 12:36:45 -04:00

152 lines
6.0 KiB
Python

"""Split endpoints.deploy into three independent scope grants.
endpoints.deploy bundled three actions of very different risk behind one name:
scheduledtask_run -> starts a task already registered and allowlisted agent-side
update_app -> runs a pinned installer
run_script -> ARBITRARY code as SYSTEM unless the descriptor pins a user
Because they shared a grant, there was no way to let an operator push an
application update without also handing them arbitrary SYSTEM code execution on
every endpoint they could reach. The permission name described the lightest of
the three and silently carried the heaviest.
commands.ELEVATED_ACTION_PERMISSIONS now maps each action to its own permission
(endpoints.corptools, endpoints.updateapp, endpoints.runscript). The enqueue
route reads that table, so no route logic changed.
This migration preserves access EXACTLY. Anyone holding endpoints.deploy today
can already do all three, so every role holding it receives all three new
grants. Nothing widens: no role that lacked endpoints.deploy gains anything. The
security win is not in this migration - it is that from here the three can be
granted separately.
endpoints.deploy itself is then removed. Leaving it behind would be worse than
useless: nothing reads it any more, but the role grid would keep offering it and
operators would keep ticking it expecting an effect.
Revision ID: 7d42_split_endpoints_deploy
Revises: 7d41_drop_guest_tools_secrets
"""
from alembic import op
import sqlalchemy as sa
revision = '7d42_split_endpoints_deploy'
down_revision = '7d41_drop_guest_tools_secrets'
branch_labels = None
depends_on = None
OLD_PERMISSION = 'endpoints.deploy'
NEW_PERMISSIONS = [
('endpoints.corptools',
'Start approved scheduled tasks on endpoints (limited to tasks already '
'registered and allowlisted on the agent)',
'endpoints'),
('endpoints.updateapp',
'Push application installers to endpoints (runs a pinned installer as '
'SYSTEM)',
'endpoints'),
('endpoints.runscript',
'Run staged scripts on endpoints (ARBITRARY CODE, executes as SYSTEM '
'unless the script pins a user context; grant only to fully trusted '
'operators)',
'endpoints'),
]
def _tables_present(bind):
inspector = sa.inspect(bind)
tables = set(inspector.get_table_names())
return {'roles', 'permissions', 'rolepermissions'} <= tables
def _ensure_permission(bind, name, description, category):
"""Insert a permission if absent. 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 exists:
return
bind.execute(sa.text(
'INSERT INTO permissions (name, description, category) '
'VALUES (:name, :description, :category)'),
{'name': name, 'description': description, 'category': category})
def upgrade():
bind = op.get_bind()
if not _tables_present(bind):
return
for name, description, category in NEW_PERMISSIONS:
_ensure_permission(bind, name, description, category)
# Preserve access: every role holding the old bundle gets all three halves.
for name, _description, _category in NEW_PERMISSIONS:
bind.execute(sa.text(
'INSERT INTO rolepermissions (roleid, permissionid) '
'SELECT rp.roleid, new.permissionid '
'FROM rolepermissions rp '
'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 rolepermissions existing '
' WHERE existing.roleid = rp.roleid '
' AND existing.permissionid = new.permissionid)'),
{'newname': name, 'oldname': OLD_PERMISSION})
bind.execute(sa.text(
'DELETE FROM rolepermissions WHERE permissionid IN '
'(SELECT permissionid FROM permissions WHERE name = :oldname)'),
{'oldname': OLD_PERMISSION})
bind.execute(sa.text(
'DELETE FROM permissions WHERE name = :oldname'),
{'oldname': OLD_PERMISSION})
def downgrade():
"""Rebuild the bundle and grant it to anyone holding any of the three.
Deliberately generous in this direction: on the way back the only safe
reading of "held one of the three" is "held the bundle", because the bundle
is all-or-nothing. Downgrading therefore restores capability rather than
quietly removing it from an operator mid-shift.
"""
bind = op.get_bind()
if not _tables_present(bind):
return
_ensure_permission(
bind, OLD_PERMISSION,
'Run staged scripts, start approved scheduled tasks, and push '
'application installers to endpoints (scripts run as SYSTEM; grant '
'only to fully trusted operators)',
'endpoints')
newnames = [name for name, _d, _c in NEW_PERMISSIONS]
bind.execute(sa.text(
'INSERT INTO rolepermissions (roleid, permissionid) '
'SELECT DISTINCT rp.roleid, old.permissionid '
'FROM rolepermissions rp '
'JOIN permissions new ON new.permissionid = rp.permissionid '
'JOIN permissions old ON old.name = :oldname '
'WHERE new.name IN :newnames '
'AND NOT EXISTS (SELECT 1 FROM rolepermissions existing '
' WHERE existing.roleid = rp.roleid '
' AND existing.permissionid = old.permissionid)'
).bindparams(sa.bindparam('newnames', expanding=True)),
{'oldname': OLD_PERMISSION, 'newnames': newnames})
for name in newnames:
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})