Files
shopdb-flask/migrations/versions/7d42_split_endpoints_deploy.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -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})