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.
152 lines
6.0 KiB
Python
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})
|