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

@@ -11,7 +11,8 @@ from plugins.endpoints.plugin import EndpointsPlugin, CURATED_EVENT_IDS
from plugins.endpoints.eventcatalog import EVENT_CATALOG, CRITICAL_EVENT_IDS
from plugins.endpoints.commands import (
COMMAND_ALLOWLIST, is_allowed_action, elevated_permission_for,
requires_target,
requires_target, minimum_agent_version_for, agent_meets_minimum,
parse_agent_version,
)
@@ -69,12 +70,12 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin()
panels = plugin.get_asset_panels()
assert len(panels) == 7
assert len(panels) == 14
for panel in panels:
assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/')
assert '{assetid}' in panel['endpoint']
assert panel['render'] in ('keyvalue', 'list', 'table', 'operatorreports')
assert panel['render'] in ('keyvalue', 'list', 'table', 'tabs', 'operatorreports')
def test_services_and_processes_are_split_into_two_filtered_cards():
@@ -177,6 +178,100 @@ def test_collect_now_is_allowlisted_targetless_and_routine():
assert elevated_permission_for('collect_now') is None
def test_collect_now_survives_a_policy_that_never_mentions_it():
"""Refresh Status must not be narrowed away by an unrelated policy.
A non-empty effective policy skips the permissive fallback in
ALLOWED_ACTIONS_FOR, so any action the policy does not name is intersected
out. collect_now only asks an agent to report state it already has, so
Configuration Policy is not the authority on it - without the exemption
every policied endpoint refused Refresh Status with "Action not permitted
by policy", a denial the operator could not act on because the policy was
never about that action.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_now' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION: it stays gated by
# the base endpoints.command grant, which is what keeps guests out.
assert elevated_permission_for('collect_now') is None
def test_collect_log_survives_a_policy_that_never_mentions_it():
"""Log collection must not be narrowed away by an unrelated policy.
Same intersection trap as collect_now, but with a sharper failure: every
existing policy predates this action and so cannot name it, meaning any
endpoint carrying a policy refused log collection outright - including for
an admin, who reads "requires permission" and goes looking at a grant they
already hold. Logs are also what an operator reaches for when an endpoint
is already misbehaving, so a policy gate here withholds the evidence
exactly when it is wanted.
"""
from plugins.endpoints.commands import POLICY_EXEMPT_ACTIONS
assert 'collect_log' in POLICY_EXEMPT_ACTIONS
# Exemption from POLICY is not exemption from PERMISSION. Unlike
# collect_now this one DOES carry an elevated grant, so the assertion is
# that the grant survived the exemption rather than being loosened by it.
assert elevated_permission_for('collect_log') == 'endpoints.updateapp'
def test_collect_log_declares_the_agent_version_that_implements_it():
"""collect_log names its minimum agent version.
An agent older than the action has no case for it and falls through to its
unknown-action default, answering "Unknown or disallowed action" - which
reads as an authorization refusal and sends the operator to check a grant
they already hold. The floor is what lets the server refuse first with the
actionable answer instead.
"""
assert minimum_agent_version_for('collect_log') == '1.16.0'
# Actions that every shipped agent understands must NOT declare a floor,
# or the guard starts refusing endpoints that are perfectly capable.
assert minimum_agent_version_for('collect_now') is None
assert minimum_agent_version_for('system_restart') is None
def test_agent_version_comparison_survives_real_fleet_version_strings():
"""Version comparison is numeric per part, not lexical.
Two traps, both real in this fleet. Agents report four parts ('1.15.0.0')
while the csproj and binary store carry three ('1.16.0'), so the same build
must compare equal across both spellings. And a lexical compare ranks
'1.9.0.0' ABOVE '1.16.0' because '9' > '1' - which would silently let an
old agent past the guard, the exact failure the guard exists to prevent.
"""
assert parse_agent_version('1.16.0') == parse_agent_version('1.16.0.0')
assert agent_meets_minimum('1.16.0', '1.16.0')
assert agent_meets_minimum('1.16.0.0', '1.16.0')
assert agent_meets_minimum('1.17.0.0', '1.16.0')
assert agent_meets_minimum('2.0.0.0', '1.16.0')
assert not agent_meets_minimum('1.15.0.0', '1.16.0')
assert not agent_meets_minimum('1.11.0.0', '1.16.0')
# The lexical trap.
assert not agent_meets_minimum('1.9.0.0', '1.16.0')
def test_an_unreadable_agent_version_fails_the_guard_closed():
"""An unparseable or absent version does not satisfy a minimum.
Eight endpoints in this fleet report '0.0.0-sample', and a never-checked-in
endpoint reports nothing at all. There is no honest ordering between a
placeholder and a real build, so the guard refuses rather than guessing.
Letting it through would only move the failure to the agent, where the
error message is the misleading one.
"""
assert parse_agent_version('0.0.0-sample') is None
assert not agent_meets_minimum('0.0.0-sample', '1.16.0')
assert not agent_meets_minimum(None, '1.16.0')
assert not agent_meets_minimum('', '1.16.0')
# No declared floor means no opinion: an action every agent implements must
# stay available even to an endpoint whose version we cannot read.
assert agent_meets_minimum('0.0.0-sample', None)
assert agent_meets_minimum(None, None)
def test_run_script_and_update_app_are_allowlisted_delivery_actions():
"""The two software-delivery actions are allowlisted with a targetkind so
the enqueue route accepts them and carries their subject on the wire:
@@ -189,19 +284,36 @@ def test_run_script_and_update_app_are_allowlisted_delivery_actions():
assert COMMAND_ALLOWLIST['update_app']['targetkind'] == 'app'
def test_delivery_actions_require_the_elevated_deploy_permission():
"""run_script and update_app both demand endpoints.deploy ON TOP OF
endpoints.command, so pushing software is a strictly heavier grant than a
routine service/process toggle."""
assert elevated_permission_for('run_script') == 'endpoints.deploy'
assert elevated_permission_for('update_app') == 'endpoints.deploy'
def test_big_four_actions_each_require_their_own_elevated_permission():
"""The four scope-gated actions map to four INDEPENDENT permissions.
They previously shared endpoints.deploy, which meant granting an operator
application updates also granted arbitrary SYSTEM code execution through
run_script. Keeping these distinct is the whole point of the split, so a
regression that re-bundles them must fail here.
"""
assert elevated_permission_for('system_restart') == 'endpoints.restart'
assert elevated_permission_for('run_script') == 'endpoints.runscript'
assert elevated_permission_for('update_app') == 'endpoints.updateapp'
assert elevated_permission_for('scheduledtask_run') == 'endpoints.corptools'
granted = {
elevated_permission_for(action)
for action in ('system_restart', 'run_script', 'update_app',
'scheduledtask_run')
}
assert len(granted) == 4
def test_endpoints_declares_the_deploy_permission():
"""The dedicated software-delivery permission is declared for role seeding."""
def test_endpoints_declares_the_split_delivery_permissions():
"""Each split grant is declared so role seeding can offer it, and the
retired bundle is gone so the role grid stops offering a dead permission."""
plugin = EndpointsPlugin()
permissions = {name for name, *_ in plugin.get_permissions()}
assert 'endpoints.deploy' in permissions
assert 'endpoints.runscript' in permissions
assert 'endpoints.updateapp' in permissions
assert 'endpoints.corptools' in permissions
assert 'endpoints.restart' in permissions
assert 'endpoints.deploy' not in permissions
def test_known_payload_keys_cover_persisted_and_relayed_fields():
@@ -220,18 +332,21 @@ def test_known_payload_keys_cover_persisted_and_relayed_fields():
assert persisted_or_relayed <= KNOWN_PAYLOAD_KEYS
def test_new_hardware_telemetry_keys_are_logged_not_silently_known():
"""Disk/bios/memory inventory is collected at the agent before the server
persists it. Those keys must be OUTSIDE the known set so ingest logs them
rather than dropping them silently."""
def test_hardware_telemetry_keys_are_persisted_not_merely_logged():
"""Disk/bios/memory/processor/osinfo inventory was collected at the agent and
logged as unknown keys for several versions while the shapes were proven
against real endpoint traffic. They have columns now (endpoints migration
0013), so they must be INSIDE the known set - leaving them out would log
every report as carrying unpersisted keys while the ingest quietly stores
them, which is the most misleading combination available."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
assert key not in KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext'):
assert key in KNOWN_PAYLOAD_KEYS
# 'applications' USED to be in this list. It is persisted now - that is what
# the installed-software work added - so it belongs in the known set, and
# asserting its absence would fail the moment the feature it tests works.
# 'applications' followed this same staging path earlier and is likewise
# persisted, to its own endpointapplications table rather than a state blob.
assert 'applications' in KNOWN_PAYLOAD_KEYS
@@ -287,13 +402,26 @@ def test_fleet_list_route_resolves_without_trailing_slash():
# Dockerfile, the compose files or the Windows deploy pins TZ.
def _in_zone(zonename, call):
"""Run call() with the process in a named zone, then put it back."""
"""Run call() with the process in a named zone, then put it back.
time.tzset() is POSIX-only and absent on Windows, where the process zone
cannot be moved this way at all. Rather than fail three tests permanently on
every Windows checkout - which teaches the developer to ignore a red suite -
the shift is skipped there and the assertions still run in whatever zone the
machine is in. That is weaker locally but not worthless: _parse_dt must
return the same UTC answer in EVERY zone, so the assertion is correct in all
of them, and CI runs on Linux where the real shift happens.
"""
import os
import time
tzset = getattr(time, 'tzset', None)
if tzset is None:
return call()
before = os.environ.get('TZ')
os.environ['TZ'] = zonename
time.tzset()
tzset()
try:
return call()
finally:
@@ -301,7 +429,7 @@ def _in_zone(zonename, call):
os.environ.pop('TZ', None)
else:
os.environ['TZ'] = before
time.tzset()
tzset()
def test_a_utc_timestamp_survives_a_non_utc_server():