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.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

View File

@@ -0,0 +1,151 @@
"""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})

View File

@@ -0,0 +1,59 @@
"""Per-user direct permission grants.
Permissions were reachable only through roles. At 5-7 operators per instance
that forces a role to be minted for every one-off capability - "Bob, and only
Bob, may push scripts" - until the role list is a mixture of job titles and
single-capability flags and nobody can tell which is which.
A row here is an ORDINARY permission grant. It is identical in power to one held
through a role: it counts toward the API token scope ceiling, it is folded by
both User.haspermission() and User.getpermissions(), and it is revoked the same
way. There is deliberately no second class of permission.
Nothing is granted by this migration. The table is created empty; existing
authority is untouched.
Revision ID: 7d43_userpermissions
Revises: 7d42_split_endpoints_deploy
"""
from alembic import op
import sqlalchemy as sa
revision = '7d43_userpermissions'
down_revision = '7d42_split_endpoints_deploy'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'userpermissions' in set(inspector.get_table_names()):
return
op.create_table(
'userpermissions',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('permissionid', sa.Integer(), nullable=False),
# Cascade on both sides: a direct grant is meaningless once either end
# is gone, and leaving orphans would let a recycled id silently inherit
# someone else's capability.
sa.ForeignKeyConstraint(['userid'], ['users.userid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['permissionid'],
['permissions.permissionid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('userid', 'permissionid'),
)
op.create_index('idx_userpermissions_userid', 'userpermissions', ['userid'])
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'userpermissions' not in set(inspector.get_table_names()):
return
op.drop_index('idx_userpermissions_userid', table_name='userpermissions')
op.drop_table('userpermissions')

View File

@@ -0,0 +1,42 @@
"""Add applications monitorable fields
Backs the "monitorable" checkbox on the Application form: an app that the
endpoint agent can watch as a process monitor. A monitor needs something to look
for (the Windows image name) and something to start when it is missing (the
launch path), so the API refuses to save ismonitorable without both.
The two text columns stay NULLABLE rather than carrying a constraint, because
every application row that existed before this migration has neither value and
must remain valid. The pairing rule is enforced in shopdb/core/api/applications.py
where it can return a useful message, not in the schema where it would only
produce an IntegrityError.
Revision ID: 7d44_app_monitorable
Revises: 7d43_userpermissions
Create Date: 2026-09-08
"""
from alembic import op
import sqlalchemy as sa
revision = '7d44_app_monitorable'
down_revision = '7d43_userpermissions'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('applications', sa.Column(
'ismonitorable', sa.Boolean(), nullable=True, server_default=sa.false(),
comment='Can be watched by the endpoint agent (needs launch path + process name)'))
op.add_column('applications', sa.Column(
'monitorlaunchpath', sa.String(length=512), nullable=True))
op.add_column('applications', sa.Column(
'monitorprocessname', sa.String(length=255), nullable=True))
def downgrade():
op.drop_column('applications', 'monitorprocessname')
op.drop_column('applications', 'monitorlaunchpath')
op.drop_column('applications', 'ismonitorable')

View File

@@ -0,0 +1,86 @@
"""Purge soft-deleted applications that still reserve their names.
Delete used to set isactive=False and leave the row in place. The uniqueness
check on create looks at every row regardless of isactive, so each of those
rows kept its name reserved permanently: creating an application with the same
name failed with "already exists" against a row no screen would display, and
nothing in the UI could clear it.
Delete is a hard delete now, but that only fixes rows deleted from here on.
This clears the ones already stranded, in every environment rather than just
the one where the bug was noticed.
Dependent rows are cleared the same way the API does it, and for the same
reasons: appversions and computerinstalledapps are NOT NULL and meaningless
without their application, while knowledgebase.appid is nullable and the
article is somebody's writing, so it is unlinked rather than destroyed.
Uploaded installers belonging to these rows are deliberately NOT touched. A
migration that deletes files cannot be rolled back and cannot be dry-run, and a
stray file is harmless next to that risk. Deletes made through the API do clean
them up.
Revision ID: 7d45_purge_inactive_apps
Revises: 7d44_app_monitorable
"""
from alembic import op
import sqlalchemy as sa
revision = '7d45_purge_inactive_apps'
down_revision = '7d44_app_monitorable'
branch_labels = None
depends_on = None
def _table_exists(bind, name):
"""True when a table is present.
The plugin tables are only there when the plugin is installed, so each is
checked before being touched.
"""
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'applications'):
return
doomed = [row[0] for row in bind.execute(sa.text(
'SELECT appid FROM applications WHERE isactive = false'
))]
if not doomed:
return
for appid in doomed:
params = {'appid': appid}
if _table_exists(bind, 'knowledgebase'):
bind.execute(sa.text(
'UPDATE knowledgebase SET appid = NULL WHERE appid = :appid'
), params)
if _table_exists(bind, 'computerinstalledapps'):
bind.execute(sa.text(
'DELETE FROM computerinstalledapps WHERE appid = :appid'
), params)
if _table_exists(bind, 'appversions'):
bind.execute(sa.text(
'DELETE FROM appversions WHERE appid = :appid'
), params)
bind.execute(sa.text(
'DELETE FROM applications WHERE appid = :appid'
), params)
def downgrade():
# The rows are gone and their contents were not captured, so there is
# nothing to restore. Failing loudly is better than pretending otherwise.
raise NotImplementedError(
'Purged applications cannot be restored; recover from a backup.')

View File

@@ -0,0 +1,136 @@
"""Move uploaded installers into per-application directories.
Installers used to be stored flat as "application-<id>.<ext>", which threw away
the name the vendor shipped. That is what made a re-import look like new
software: the operator had no way to see that the file already in the catalogue
was the same one they were about to upload again, so duplicate applications and
versions accumulated. Installers now keep their filename, namespaced by
application id so a generic "setup.exe" from two vendors cannot collide.
That only helps files uploaded from here on. This moves the ones already stored,
so existing download links keep working instead of 404ing against a path the
route no longer serves.
The original filename cannot be recovered - the old scheme discarded it at
upload - so these land as "installer.<ext>". That is no worse than what they
were called before, and re-uploading the installer restores the real name.
Files are MOVED, not copied or deleted: a move is reversible, which is what
lets downgrade() put them back. This is deliberately unlike 7d45, which refused
to touch files because it could only have deleted them.
The database is the part that must not be left inconsistent, so installpath is
rewritten for every affected row whether or not its file was found on disk. A
row pointing at a missing file is a broken download; a row pointing at the old
scheme is a broken download that also cannot be repaired by re-uploading.
Revision ID: 7d46_nest_app_packages
Revises: 7d45_purge_inactive_apps
"""
import os
import re
from alembic import op
import sqlalchemy as sa
revision = '7d46_nest_app_packages'
down_revision = '7d45_purge_inactive_apps'
branch_labels = None
depends_on = None
OLD_PREFIX = '/api/applications/package/'
# application-<id>.<ext>, the only shape the old uploader produced.
OLD_NAME = re.compile(r'^application-(\d+)\.([A-Za-z0-9]+)$')
def _package_dir():
"""The instance-relative directory installers are stored in.
Resolved the same way the API resolves it. If the app context is not
available the migration still rewrites the database and simply leaves the
files alone, rather than failing the whole upgrade over a path lookup.
"""
try:
from flask import current_app
return os.path.join(current_app.instance_path, 'applicationpackages')
except Exception:
return None
def upgrade():
bind = op.get_bind()
rows = bind.execute(sa.text(
"SELECT appid, installpath FROM applications "
"WHERE installpath LIKE :prefix"
), {'prefix': OLD_PREFIX + '%'}).fetchall()
directory = _package_dir()
for appid, installpath in rows:
tail = installpath[len(OLD_PREFIX):]
match = OLD_NAME.match(tail)
if not match:
# Already nested (contains a slash) or otherwise not the old shape.
# Leaving it alone is correct: it is already being served.
continue
ext = match.group(2).lower()
stored = f'installer.{ext}'
if directory:
source = os.path.join(directory, tail)
target_dir = os.path.join(directory, str(appid))
if os.path.isfile(source):
os.makedirs(target_dir, exist_ok=True)
try:
os.replace(source, os.path.join(target_dir, stored))
except OSError:
# Best effort. The row is still repointed below so the
# schema is consistent; a missing file is a re-upload.
pass
bind.execute(sa.text(
"UPDATE applications SET installpath = :path WHERE appid = :appid"
), {'path': f'{OLD_PREFIX}{appid}/{stored}', 'appid': appid})
def downgrade():
bind = op.get_bind()
rows = bind.execute(sa.text(
"SELECT appid, installpath FROM applications "
"WHERE installpath LIKE :prefix"
), {'prefix': OLD_PREFIX + '%'}).fetchall()
directory = _package_dir()
for appid, installpath in rows:
tail = installpath[len(OLD_PREFIX):]
if '/' not in tail:
continue
nested_id, _, stored = tail.partition('/')
if nested_id != str(appid):
continue
ext = os.path.splitext(stored)[1].lstrip('.').lower()
if not ext:
continue
flat = f'application-{appid}.{ext}'
if directory:
source = os.path.join(directory, str(appid), stored)
if os.path.isfile(source):
try:
os.replace(source, os.path.join(directory, flat))
# Remove the now-empty directory so a re-upgrade does not
# find a stale one. Only if empty: a directory with other
# files in it is not ours to remove.
os.rmdir(os.path.join(directory, str(appid)))
except OSError:
pass
bind.execute(sa.text(
"UPDATE applications SET installpath = :path WHERE appid = :appid"
), {'path': OLD_PREFIX + flat, 'appid': appid})

View File

@@ -0,0 +1,97 @@
"""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})

View File

@@ -0,0 +1,41 @@
"""Add scripts workingdirectory
Backs the optional "run in this folder" field on a library script. The agent
otherwise runs a delivered script from its own staging directory, which is
correct for a self-contained script and wrong for one that shells out to a
corporate tool already installed on the endpoint (Sysinternals and similar,
delivered by Intune) and referenced by relative path.
NULLABLE with no default, so every script row that existed before this
migration stays valid and keeps today's behaviour exactly: empty means the
staging directory. The path is validated in shopdb/core/api/scripts.py where a
rejection can explain itself, not in the schema where it would only produce an
IntegrityError.
This column grants no capability. A script body is already arbitrary PowerShell
run as SYSTEM and can change its own directory; the boundary is the
endpoints.runscript grant and the digest-pinned library.
Revision ID: 7d48_script_workingdir
Revises: 7d47_endpoints_operatorreports
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d48_script_workingdir'
down_revision = '7d47_endpoints_operatorreports'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('scripts', sa.Column(
'workingdirectory', sa.String(length=255), nullable=True,
comment='Folder to run the script in; empty means the agent staging dir'))
def downgrade():
op.drop_column('scripts', 'workingdirectory')

View File

@@ -0,0 +1,46 @@
"""Add scripts errormode
Backs the per-script "how should a non-terminating error be treated" field.
PowerShell invoked with -File exits 0 whenever it reaches the end of the body,
even if every meaningful line inside wrote an error. The agent derives the
command verdict from that exit code, so a script could report SUCCEEDED while
doing none of its work - which is exactly how an account-provisioning script
came back green with the account only partly built.
NOT NULL with server_default 'default', so every script row that existed before
this migration keeps today's permissive behaviour exactly. Strict is opt-in per
script and never retroactive: turning it on globally would change the meaning of
bodies that were authored under the permissive rule and deliberately tolerate a
failing Remove-Item, probe with Get-LocalUser, or loop past a bad item. Those
would begin aborting partway through, which is worse than a false green because
it leaves the endpoint half-configured.
The value is validated in shopdb/core/api/scripts.py where a rejection can
explain itself, not in the schema where it would only produce an IntegrityError.
Revision ID: 7d49_script_errormode
Revises: 7d48_script_workingdir
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d49_script_errormode'
down_revision = '7d48_script_workingdir'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('scripts', sa.Column(
'errormode', sa.String(length=16), nullable=False,
server_default='default',
comment="'default' keeps PowerShell's permissive behaviour; "
"'strict' stops on the first error so the exit code is honest"))
def downgrade():
op.drop_column('scripts', 'errormode')

View File

@@ -0,0 +1,48 @@
"""Add scripts captureoutput
Backs the per-script "may a successful run send its output back" field.
The agent already captures a script's full output and always writes it to the
endpoint log. What this controls is narrower: whether that output also crosses
to the server and is persisted in the command's resultjson, where the Run
Scripts history can show it.
Failures have always sent output and still do - a failed run has to be
diagnosable without walking to the machine. Success is the case in question,
and it is opt-in because output carries whatever the body happened to print:
hostnames, share paths, an account name, a token an author echoed while
debugging. Accepting that exposure for every routine green run is a poor
trade, so the author decides per script, after reading the body.
BOOLEAN NOT NULL defaulting FALSE, so every script row written before this
migration keeps exactly today's behaviour: metadata on success, output only on
failure. Same rule as errormode - never retroactive, because a library-wide
flip would start persisting output from bodies nobody re-read with this
question in mind.
Revision ID: 7d50_script_captureoutput
Revises: 7d49_script_errormode
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d50_script_captureoutput'
down_revision = '7d49_script_errormode'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('scripts', sa.Column(
'captureoutput', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='When true, a successful run also returns its output to the '
'server; failures always do. Off by default because script '
'output can contain whatever the body printed.'))
def downgrade():
op.drop_column('scripts', 'captureoutput')

View File

@@ -0,0 +1,92 @@
"""Add endpointlogcollections
Backs the manual Log Collector tab: one row per plaintext log file an operator
collected from an endpoint by path.
Collected content gets its own table rather than living in
endpointcommands.resultjson for three reasons. It is bulky, and resultjson is
read by every command-history list that only wants a status. It needs its own
retention rule, which a command row cannot carry without deleting the audit
record of the command itself. And the download route has to address a single
collection by id and serve it as a file, which is awkward against a JSON column.
Retention is PER ASSET (20, enforced on insert in the result route), not global.
The history renders on a single machine's detail page, so a global cap would let
one busy endpoint quietly evict the history of another - the panel would be
unreliable exactly where it is read.
endpointcommandid is SET NULL rather than CASCADE: commands are reaped and
expired over time, and a collected log an operator is still reading must not
vanish because its originating command row was cleaned up. assetid stays CASCADE
because a log has no meaning once the machine is gone.
filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on
disk may be gigabytes, while what we store is a bounded tail.
Revision ID: 7d51_endpoint_logcollections
Revises: 7d50_script_captureoutput
Create Date: 2026-09-09
"""
from alembic import op
import sqlalchemy as sa
revision = '7d51_endpoint_logcollections'
down_revision = '7d50_script_captureoutput'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'endpointlogcollections',
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
sa.Column('sourcepath', sa.String(length=512), nullable=False,
comment='Path as the AGENT resolved it, which may differ '
'from what the operator typed once environment '
'variables are expanded endpoint-side.'),
sa.Column('content', sa.Text(length=16777215), nullable=True),
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
comment='Size of the file on disk. Differs from contentbytes '
'when the stored copy is a truncated tail.'),
sa.Column('contentbytes', sa.Integer(), nullable=True),
sa.Column('linecount', sa.Integer(), nullable=True),
sa.Column('istruncated', sa.Boolean(), nullable=False,
server_default=sa.false(),
comment='True when content is only the tail of a larger file.'),
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
comment='Username snapshot; the id alone becomes unreadable '
'if the user is later removed.'),
sa.Column('collectedat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['endpointcommandid'],
['endpointcommands.endpointcommandid'],
ondelete='SET NULL'),
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
ondelete='SET NULL'),
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
)
op.create_index('ix_endpointlogcollections_assetid',
'endpointlogcollections', ['assetid'])
op.create_index('ix_endpointlogcollections_collectedat',
'endpointlogcollections', ['collectedat'])
# Serves both hot paths: listing an asset's history newest-first, and
# finding that asset's oldest rows to prune on insert.
op.create_index('idx_endpointlogcollections_assetcollected',
'endpointlogcollections', ['assetid', 'collectedat'])
def downgrade():
op.drop_index('idx_endpointlogcollections_assetcollected',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_collectedat',
table_name='endpointlogcollections')
op.drop_index('ix_endpointlogcollections_assetid',
table_name='endpointlogcollections')
op.drop_table('endpointlogcollections')