"""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})